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-26 19:44:45 -06:00

350 lines
14 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(
`objetivo_general, sistema_evaluacion, perfil_ingreso, perfil_egreso,
competencias_genericas, competencias_especificas, indicadores_desempeno,
pertinencia, prompt`
)
.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("")
const plan_format={
"objetivo_general": "...",
"sistema_evaluacion": "...",
"perfil_ingreso": "...",
"perfil_egreso": "...",
"competencias_genericas": "...",
"competencias_especificas": "...",
"indicadores_desempeno": "...",
"pertinencia": "..."
}
// --- 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-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ 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 = 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
plan_format={plan_format}
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)
}
}}
/>
</>
)
}