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:
2026-04-27 12:22:05 -04:00
parent 675fa1e299
commit f6a3f6aedf
23 changed files with 2580 additions and 24 deletions
@@ -22,10 +22,12 @@ import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer";
import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer";
import MermaidViewer from "@/components/blocks/MermaidViewer";
import ScormPlayer from "@/components/blocks/ScormPlayer";
import PluginBlock from "@/components/blocks/PluginBlock";
import InteractiveTranscript from "@/components/InteractiveTranscript";
import AITutor from "@/components/AITutor";
import LessonLockedView from "@/components/LessonLockedView";
import StudentNotes from "@/components/StudentNotes";
import CollaborativeWhiteboard from "@/components/CollaborativeWhiteboard";
import { ListMusic, StickyNote } from "lucide-react";
import ReactMarkdown from "react-markdown";
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
@@ -522,6 +524,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
);
case 'mermaid':
return <MermaidViewer block={block} />;
case 'plugin':
return (
<PluginBlock
pluginId={block.id}
name={block.title || 'Plugin'}
componentUrl={block.component_url || ''}
config={block.config as Record<string, unknown> | undefined}
/>
);
case 'scorm':
return (
<ScormPlayer
@@ -608,6 +619,10 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
)}
</div>
)}
<div className="pt-12 border-t border-black/5 dark:border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
<CollaborativeWhiteboard lessonId={params.lessonId} />
</div>
</article>
</div>
@@ -0,0 +1,388 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { lmsApi, CollaborativeCanvasState } from "@/lib/api";
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
type ConflictInfo = {
localStrokes: Stroke[];
remoteStrokes: Stroke[];
remoteRevision: number;
remoteUpdatedAt: string;
};
type Point = { x: number; y: number };
type Stroke = {
points: Point[];
color: string;
width: number;
};
type Props = {
lessonId: string;
};
const DEFAULT_CANVAS: CollaborativeCanvasState = { strokes: [] };
function toStrokeArray(state: CollaborativeCanvasState): Stroke[] {
if (!Array.isArray(state.strokes)) {
return [];
}
return state.strokes
.map((stroke) => {
const points = Array.isArray(stroke.points)
? stroke.points
.filter((p): p is Point => typeof p?.x === "number" && typeof p?.y === "number")
.map((p) => ({ x: p.x, y: p.y }))
: [];
return {
points,
color: typeof stroke.color === "string" ? stroke.color : "#1f2937",
width: typeof stroke.width === "number" ? stroke.width : 2,
};
})
.filter((stroke) => stroke.points.length > 1);
}
export default function CollaborativeWhiteboard({ lessonId }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [draftStroke, setDraftStroke] = useState<Stroke | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
const isDrawing = useRef(false);
const allStrokes = useMemo(() => {
return draftStroke ? [...strokes, draftStroke] : strokes;
}, [strokes, draftStroke]);
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrapper = wrapperRef.current;
if (!canvas || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * scale));
canvas.height = Math.max(1, Math.floor(340 * scale));
canvas.style.width = `${rect.width}px`;
canvas.style.height = "340px";
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.scale(scale, scale);
}, []);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const stroke of allStrokes) {
if (stroke.points.length < 2) continue;
ctx.beginPath();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i += 1) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
}
}, [allStrokes]);
useEffect(() => {
resizeCanvas();
draw();
}, [resizeCanvas, draw]);
useEffect(() => {
const onResize = () => {
resizeCanvas();
draw();
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [draw, resizeCanvas]);
const getPoint = (event: PointerEvent<HTMLCanvasElement>): Point | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return { x, y };
};
const loadCanvas = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
const loadedStrokes = toStrokeArray(data.canvas_state || DEFAULT_CANVAS);
setStrokes(loadedStrokes);
setDraftStroke(null);
setLastSavedAt(data.updated_at || null);
setRevision(data.revision || 0);
setDirty(false);
} catch (e) {
console.error("Error loading collaborative canvas", e);
setError("No se pudo cargar la pizarra colaborativa.");
} finally {
setLoading(false);
}
}, [lessonId]);
const saveCanvas = useCallback(async (force = false) => {
if (saving || loading || isDrawing.current) return;
setSaving(true);
setError(null);
const expectedRev = force ? undefined : revision;
try {
const result = await lmsApi.updateLessonCollaborativeCanvas(lessonId, { strokes }, expectedRev);
setLastSavedAt(result.updated_at);
setRevision(result.revision);
setDirty(false);
setConflict(null);
} catch (e) {
console.error("Error saving collaborative canvas", e);
if (e instanceof Error && e.message.toLowerCase().includes("conflicto")) {
// Fetch remote state to show diff panel
try {
const remote = await lmsApi.getLessonCollaborativeCanvas(lessonId);
setConflict({
localStrokes: strokes,
remoteStrokes: toStrokeArray(remote.canvas_state || DEFAULT_CANVAS),
remoteRevision: remote.revision || 0,
remoteUpdatedAt: remote.updated_at ?? new Date().toISOString(),
});
} catch {
setError("Conflicto detectado y no se pudo obtener el estado remoto. Recarga manualmente.");
}
return;
}
setError("No se pudo guardar la pizarra. Intenta nuevamente.");
} finally {
setSaving(false);
}
}, [lessonId, loading, revision, saving, strokes]);
useEffect(() => {
loadCanvas();
}, [loadCanvas]);
const acceptRemote = useCallback(() => {
if (!conflict) return;
setStrokes(conflict.remoteStrokes);
setRevision(conflict.remoteRevision);
setLastSavedAt(conflict.remoteUpdatedAt);
setDirty(false);
setConflict(null);
setError(null);
}, [conflict]);
const forceLocal = useCallback(() => {
setConflict(null);
void saveCanvas(true);
}, [saveCanvas]);
useEffect(() => {
if (!dirty || loading || saving || isDrawing.current) {
return;
}
const timeoutId = setTimeout(() => {
void saveCanvas();
}, 1500);
return () => clearTimeout(timeoutId);
}, [dirty, loading, saveCanvas, saving, strokes]);
useEffect(() => {
const interval = setInterval(async () => {
if (dirty || isDrawing.current) return;
try {
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
const serverStamp = data.updated_at || null;
if (serverStamp !== lastSavedAt) {
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
setLastSavedAt(serverStamp);
setRevision(data.revision || 0);
}
} catch (e) {
console.error("Polling collaborative canvas failed", e);
}
}, 5000);
return () => clearInterval(interval);
}, [dirty, lastSavedAt, lessonId]);
return (
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">Pizarra colaborativa (MVP)</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">Dibuja ideas rápidas de la lección y compártelas con tu grupo.</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setStrokes([]);
setDraftStroke(null);
setDirty(true);
}}
className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100"
>
<Trash2 className="h-3 w-3" /> Limpiar
</button>
<button
type="button"
onClick={() => void saveCanvas()}
disabled={saving || (!dirty && !loading)}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100 disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />} Guardar
</button>
<button
type="button"
onClick={loadCanvas}
disabled={loading}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} /> Recargar
</button>
</div>
</div>
{error && <p className="text-xs font-semibold text-red-600">{error}</p>}
{conflict && (
<div className="rounded-2xl border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-black text-amber-800 dark:text-amber-300">
Conflicto de edición detectado
</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
Otro usuario guardó mientras editabas. Elige qué versión conservar:
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-[11px]">
<div className="rounded-xl border border-blue-200 bg-blue-50 dark:bg-blue-900/20 p-3">
<p className="font-black text-blue-800 dark:text-blue-300 mb-1">Tu versión (local)</p>
<p className="text-blue-700 dark:text-blue-400">
{conflict.localStrokes.length} trazos
</p>
<p className="text-blue-500 dark:text-blue-500 mt-1">No guardada</p>
</div>
<div className="rounded-xl border border-green-200 bg-green-50 dark:bg-green-900/20 p-3">
<p className="font-black text-green-800 dark:text-green-300 mb-1">Versión del servidor</p>
<p className="text-green-700 dark:text-green-400">
{conflict.remoteStrokes.length} trazos · rev. {conflict.remoteRevision}
</p>
<p className="text-green-500 dark:text-green-500 mt-1">
{new Date(conflict.remoteUpdatedAt).toLocaleTimeString()}
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={forceLocal}
disabled={saving}
className="inline-flex items-center gap-1 rounded-lg border border-blue-300 bg-blue-100 px-3 py-1.5 text-xs font-semibold text-blue-800 hover:bg-blue-200 disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
Guardar mi versión
</button>
<button
type="button"
onClick={acceptRemote}
className="inline-flex items-center gap-1 rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-xs font-semibold text-green-800 hover:bg-green-200"
>
<CheckCircle className="h-3 w-3" />
Usar versión del servidor
</button>
</div>
</div>
)}
<div ref={wrapperRef} className="w-full overflow-hidden rounded-2xl border border-black/10 bg-white">
<canvas
ref={canvasRef}
className="touch-none"
onPointerDown={(event) => {
const point = getPoint(event);
if (!point) return;
isDrawing.current = true;
setDraftStroke({ points: [point], color: "#1f2937", width: 2 });
}}
onPointerMove={(event) => {
if (!isDrawing.current) return;
const point = getPoint(event);
if (!point) return;
setDraftStroke((prev) => {
if (!prev) return prev;
return { ...prev, points: [...prev.points, point] };
});
}}
onPointerUp={() => {
if (!isDrawing.current) return;
isDrawing.current = false;
setDraftStroke((prev) => {
if (!prev || prev.points.length < 2) return null;
setStrokes((current) => [...current, prev]);
setDirty(true);
return null;
});
}}
onPointerLeave={() => {
if (!isDrawing.current) return;
isDrawing.current = false;
setDraftStroke((prev) => {
if (!prev || prev.points.length < 2) return null;
setStrokes((current) => [...current, prev]);
setDirty(true);
return null;
});
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-medium text-gray-500 dark:text-gray-400">
<span>Trazos: {strokes.length}</span>
<span>
{saving
? "Guardando..."
: dirty
? "Cambios sin guardar (autosave en 1.5s)"
: "Sin cambios pendientes"}
</span>
<span>Revision: {revision}</span>
<span>Última sincronización: {lastSavedAt ? new Date(lastSavedAt).toLocaleTimeString() : "nunca"}</span>
</div>
</section>
);
}
@@ -0,0 +1,103 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { Puzzle, AlertTriangle, ExternalLink } from "lucide-react";
interface PluginBlockProps {
pluginId: string;
name: string;
componentUrl: string;
config?: Record<string, unknown>;
}
/**
* Renderiza un Web Component externo dentro de un iframe sandboxed.
* El sandbox permite scripts y same-origin pero bloquea navegación superior,
* formularios externos y acceso a cámara/micrófono sin permiso explícito.
*/
export default function PluginBlock({ pluginId, name, componentUrl, config = {} }: PluginBlockProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
// Solo permitir HTTPS
const isSecure = componentUrl.startsWith("https://");
useEffect(() => {
if (!isSecure) {
setError("Este plugin no puede cargarse: la URL debe usar HTTPS.");
return;
}
// Enviar config al iframe cuando cargue vía postMessage
const handleLoad = () => {
setLoaded(true);
iframeRef.current?.contentWindow?.postMessage(
{ type: "OPENCCB_PLUGIN_CONFIG", pluginId, config },
new URL(componentUrl).origin
);
};
const iframe = iframeRef.current;
if (iframe) {
iframe.addEventListener("load", handleLoad);
return () => iframe.removeEventListener("load", handleLoad);
}
}, [componentUrl, config, isSecure, pluginId]);
if (!isSecure) {
return (
<div className="flex items-center gap-3 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-5 h-5 shrink-0" />
<span>El plugin <strong>{name}</strong> no puede cargarse: URL no segura.</span>
</div>
);
}
return (
<div className="rounded-2xl border border-black/10 dark:border-white/10 overflow-hidden bg-white dark:bg-black/20">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/3">
<div className="flex items-center gap-2 text-sm font-medium">
<Puzzle className="w-4 h-4 text-indigo-500" />
{name}
</div>
<a
href={componentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-black/30 dark:text-white/30 hover:text-black/60 dark:hover:text-white/60 flex items-center gap-1 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Abrir
</a>
</div>
{/* Loading state */}
{!loaded && (
<div className="flex items-center justify-center h-48 text-black/30 dark:text-white/30 text-sm animate-pulse">
Cargando plugin
</div>
)}
{/* Iframe sandboxed */}
<iframe
ref={iframeRef}
src={componentUrl}
title={name}
className={`w-full transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0 h-0"}`}
style={{ minHeight: loaded ? "400px" : "0px", border: "none" }}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
loading="lazy"
onError={() => setError("No se pudo cargar el plugin.")}
/>
{error && (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</div>
);
}
+62 -2
View File
@@ -184,7 +184,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm' | 'plugin';
title: string;
content?: string;
url?: string;
@@ -228,6 +228,21 @@ export interface Block {
// SCORM/xAPI fields
launch_url?: string;
metadata?: any;
// Plugin fields
component_url?: string;
}
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 TrackXapiPayload {
@@ -295,6 +310,28 @@ export interface LessonDependency {
created_at: string;
}
export interface CollaborativeCanvasState {
strokes?: Array<{
points: Array<{ x: number; y: number }>;
color?: string;
width?: number;
}>;
[key: string]: unknown;
}
export interface CollaborativeCanvas {
lesson_id: string;
canvas_state: CollaborativeCanvasState;
revision: number;
updated_at?: string | null;
}
export interface CollaborativeCanvasUpdateResult {
lesson_id: string;
revision: number;
updated_at: string;
}
export interface UserGrade {
id: string;
user_id: string;
@@ -846,6 +883,24 @@ export const lmsApi = {
return apiFetch(`/lessons/${id}`);
},
async getLessonCollaborativeCanvas(lessonId: string): Promise<CollaborativeCanvas> {
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`);
},
async updateLessonCollaborativeCanvas(
lessonId: string,
canvasState: CollaborativeCanvasState,
expectedRevision?: number
): Promise<CollaborativeCanvasUpdateResult> {
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`, {
method: 'PUT',
body: JSON.stringify({
canvas_state: canvasState,
expected_revision: expectedRevision,
})
});
},
async register(payload: AuthPayload): Promise<AuthResponse> {
return apiFetch('/auth/register', {
method: 'POST',
@@ -1278,5 +1333,10 @@ export const lmsApi = {
},
async verifyCertificate(code: string): Promise<any> {
return apiFetch(`/certificates/verify/${code}`);
}
},
// Fase 35: Plugins
getEnabledPlugins(): Promise<OrgPlugin[]> {
return apiFetch('/plugins/enabled', {}, true);
},
};
+379
View File
@@ -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>
);
}
+31 -2
View File
@@ -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` },
],
},
+91
View File
@@ -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;
}
File diff suppressed because one or more lines are too long