This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/components/planes/academic-sections.tsx
2025-11-27 19:41:44 -06:00

561 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as Icons from "lucide-react";
import { useMemo, useState } from "react";
import {
useSuspenseQuery,
useMutation,
useQueryClient,
queryOptions,
} from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { supabase, useSupabaseAuth } from "@/auth/supabase";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal";
// @ts-ignore
import AIChatModal from "../ai/AIChatModal";
/* =====================================================
Query keys & fetcher
===================================================== */
export const planKeys = {
root: ["plan"] as const,
byId: (id: string) => [...planKeys.root, id] as const,
};
export type PlanTextFields = {
objetivo_general?: string | string[] | null;
sistema_evaluacion?: string | string[] | null;
perfil_ingreso?: string | string[] | null;
perfil_egreso?: string | string[] | null;
competencias_genericas?: string | string[] | null;
competencias_especificas?: string | string[] | null;
indicadores_desempeno?: string | string[] | null;
pertinencia?: string | string[] | null;
prompt?: string | null;
historico?: string | null;
};
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
const { data, error } = await supabase
.from("plan_estudios")
.select(`*`)
.eq("id", planId)
.single();
if (error) throw error;
return (data ?? {}) as PlanTextFields;
}
export const planTextOptions = (planId: string) =>
queryOptions({
queryKey: planKeys.byId(planId),
queryFn: () => fetchPlanText(planId),
staleTime: 60_000,
});
/* =====================================================
Color helpers
===================================================== */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235];
const h = hex.replace("#", "");
const v =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const n = parseInt(v, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (rgb: [number, number, number], a: number) =>
`rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
/* =====================================================
Expandable text
===================================================== */
function ExpandableText({
text,
mono = false,
}: {
text?: string | string[] | null;
mono?: boolean;
}) {
const [open, setOpen] = useState(false);
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>;
}
const content = Array.isArray(text) ? text.join("\n• ") : text;
const rendered = Array.isArray(text) ? `${content}` : content;
return (
<div>
<ReactMarkdown>{rendered}</ReactMarkdown>
{String(rendered).length > 220 && (
<button
onClick={() => setOpen((v) => !v)}
className="mt-2 text-xs font-medium text-neutral-600 hover:underline"
>
{open ? "Ver menos" : "Ver más"}
</button>
)}
</div>
);
}
/* =====================================================
Section panel
===================================================== */
function SectionPanel({
title,
icon: Icon,
color,
children,
id,
}: {
title: string;
icon: any;
color?: string | null;
children: React.ReactNode;
id: string;
}) {
const rgb = hexToRgb(color);
return (
<section
id={id}
className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60"
>
<div className="pointer-events-none absolute inset-0 -z-0">
<div
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
style={{
background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)`,
}}
/>
<div
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
style={{
background: `radial-gradient(circle, ${rgba(rgb, 0.14)}, transparent 60%)`,
}}
/>
</div>
<div
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
style={{
background: `linear-gradient(180deg, ${rgba(rgb, 0.1)}, transparent)`,
}}
>
<span
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
style={{ borderColor: rgba(rgb, 0.25) }}
>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="relative z-10 p-5">{children}</div>
</section>
);
}
/* =====================================================
AcademicSections (con React Query)
===================================================== */
export function AcademicSections({
planId,
color,
}: {
planId: string;
color?: string | null;
}) {
const qc = useQueryClient();
const auth = useSupabaseAuth();
const [openHistorial, setOpenHistorial] = useState(false);
const [openModalIa, setopenModalIa] = useState(false);
if (!planId) return <div>Cargando</div>;
const { data: plan } = useSuspenseQuery(planTextOptions(planId));
const [editing, setEditing] = useState<null | {
key: keyof PlanTextFields;
title: string;
}>(null);
const [draft, setDraft] = useState("");
// --- mutation con actualización optimista ---
const updateField = useMutation({
mutationFn: async ({
key,
value,
}: {
key: keyof PlanTextFields;
value: string | string[] | null;
}) => {
const payload: Record<string, any> = { [key]: value };
const { error } = await supabase
.from("plan_estudios")
.update(payload)
.eq("id", planId);
if (error) throw error;
return payload;
},
onMutate: async ({ key, value }) => {
await qc.cancelQueries({ queryKey: planKeys.byId(planId) });
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId));
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({
...(old ?? {}),
[key]: value,
}));
return { prev };
},
onError: (e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev);
toast.error((e as any)?.message || "No se pudo guardar 😓");
},
onSuccess: () => {
toast.success("Guardado ✅");
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) });
},
});
const sections = useMemo(
() => [
{
id: "sec-clave",
title: "Clave del plan",
icon: Icons.Key,
key: "clave_del_plan_de_estudios" as const,
mono: true,
},
{
id: "sec-area",
title: "Área de estudio",
icon: Icons.Library,
key: "area_de_estudio" as const,
mono: false,
},
// --- Estructura Temporal ---
{
id: "sec-ciclos",
title: "Total de ciclos",
icon: Icons.CalendarRange,
key: "total_de_ciclos_del_plan_de_estudios" as const,
mono: false,
},
{
id: "sec-duracion-ciclo",
title: "Duración del ciclo (semanas)",
icon: Icons.CalendarDays,
key: "duracion_del_ciclo_escolar" as const,
mono: false,
},
{
id: "sec-carga",
title: "Carga horaria semanal",
icon: Icons.Clock,
key: "carga_horaria_a_la_semana" as const,
mono: false,
},
// --- Perfiles y Fines ---
{
id: "sec-antecedente",
title: "Antecedente académico",
icon: Icons.BookOpen,
key: "antecedente_academico" as const,
mono: false,
},
{
id: "sec-ingreso",
title: "Perfil de ingreso",
icon: Icons.UserPlus,
key: "perfil_de_ingreso" as const,
mono: false,
},
{
id: "sec-fines",
title: "Fines de aprendizaje",
icon: Icons.Target,
key: "fines_de_aprendizaje_o_formacion" as const,
mono: false,
},
{
id: "sec-egreso",
title: "Perfil de egreso",
icon: Icons.UserCheck,
key: "perfil_de_egreso" as const,
mono: false,
},
// --- Operatividad y Modelo ---
{
id: "sec-admin",
title: "Administración y operatividad",
icon: Icons.Briefcase,
key: "administracion_y_operatividad_del_plan_de_estudios" as const,
mono: false,
},
{
id: "sec-sustento",
title: "Sustento teórico",
icon: Icons.Book,
key: "sustento_teorico_del_modelo_curricular" as const,
mono: false,
},
{
id: "sec-justificacion",
title: "Justificación curricular",
icon: Icons.MessageSquareText,
key: "justificacion_de_la_propuesta_curricular" as const,
mono: false,
},
{
id: "sec-evaluacion",
title: "Evaluación periódica",
icon: Icons.CheckCircle2,
key: "propuesta_de_evaluacion_periodica_del_plan_de_estudios" as const,
mono: false,
},
// --- Específicos / Opcionales ---
{
id: "sec-investigacion",
title: "Programa de investigación",
icon: Icons.Microscope,
key: "programa_de_investigacion" as const,
mono: false,
},
{
id: "sec-propedeutico",
title: "Curso propedéutico",
icon: Icons.School,
key: "curso_propedeutico" as const,
mono: false,
},
// --- Meta / Sistema ---
{
id: "sec-prm",
title: "Prompt (origen)",
icon: Icons.Code2,
key: "prompt" as const,
mono: true,
},
{
id: "sec-hist",
title: "Histórico de cambios",
icon: Icons.History,
key: "historico" as const,
mono: false,
},
],
[]
);
const [iaContext, setIaContext] = useState<{
key: keyof PlanTextFields;
title: string;
content: string;
} | null>(null);
return (
<>
<div className="grid gap-5 md:grid-cols-2">
{sections.map((s) => {
const text = String(plan[s.key]) ?? null;
return (
<SectionPanel
key={s.id}
id={s.id}
title={s.title}
icon={s.icon}
color={color}
>
{s.key === "historico" ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setOpenHistorial(true)}
>
Ver historial
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setopenModalIa(true)}
>
Promt
</Button>
</>
) : (
<>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={
!text || (Array.isArray(text) && text.length === 0)
}
onClick={() => {
const toCopy = Array.isArray(text)
? text.join("\n")
: (text ?? "");
if (toCopy) navigator.clipboard.writeText(toCopy);
}}
>
Copiar
</Button>
{s.key !== "prompt" && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const current = Array.isArray(text)
? text.join("\n")
: (text ?? "");
setEditing({ key: s.key, title: s.title });
setDraft(current);
}}
>
Editar
</Button>
)}
</div>
</>
)}
</SectionPanel>
);
})}
</div>
{/* Diálogo de edición */}
<Dialog
open={!!editing}
onOpenChange={(o) => {
if (!o) setEditing(null);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono">
{editing
? `Editar: ${sections.find((x) => x.key === editing.key)?.title}`
: ""}
</DialogTitle>
</DialogHeader>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
placeholder="Escribe aquí…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancelar
</Button>
<Button
onClick={async () => {
if (!editing) return;
// 1⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
const oldValue = (plan as any)[editing.key];
// 2⃣ Crear un diff tipo JSON Patch
const diff = [
{
op: "replace",
path: `/${editing.key}`,
from: oldValue,
value: draft,
},
];
// 3⃣ Guardar respaldo antes de actualizar
const { error: backupError } = await supabase
.from("historico_cambios")
.insert({
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
json_cambios: diff,
user_id: auth.user?.id,
created_at: new Date().toISOString(),
});
if (backupError) {
console.error("Error al guardar respaldo:", backupError);
alert("No se pudo guardar el respaldo de los cambios");
return;
}
// 4⃣ Ejecutar la mutación original
updateField.mutate({ key: editing.key, value: draft });
// 5⃣ Cerrar el diálogo
setEditing(null);
}}
disabled={updateField.isPending}
>
{updateField.isPending ? "Guardando…" : "Guardar"}
</Button>
<Button
variant="secondary"
onClick={() => {
if (!editing) return;
const current = draft;
setIaContext({
key: editing.key,
title: editing.title,
content: current,
});
setopenModalIa(true);
setEditing(null); // 🔹 Cierra el modal de edición
}}
>
Mejorar con IA
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<HistorialCambiosModal
open={openHistorial}
onClose={() => setOpenHistorial(false)}
planId={planId}
onRestore={async (key, value) => {
updateField.mutate({ key, value });
}}
/>
<AIChatModal
open={openModalIa}
onClose={() => setopenModalIa(false)}
context={{
section: iaContext?.title,
fieldKey: iaContext?.key,
originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
No uses introducciones, despedidas ni texto de relleno.
Entrega solo el contenido útil.`,
}}
onAccept={(newText: string) => {
if (iaContext) {
updateField.mutate({ key: iaContext.key, value: newText });
setIaContext(null);
}
}}
/>
</>
);
}