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
T

347 lines
13 KiB
TypeScript

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: null,//,iaContext?.title,
fieldKey: null,//iaContext?.key,
originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`,
}}
onAccept={(newText: string) => {
if (iaContext) {
updateField.mutate({ key: iaContext.key, value: newText })
setIaContext(null)
}
}}
/>
</>
)
}