wip
This commit is contained in:
@@ -1,16 +1,26 @@
|
||||
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"
|
||||
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"
|
||||
|
||||
import AIChatModal from "../ai/AIChatModal";
|
||||
|
||||
/* =====================================================
|
||||
Query keys & fetcher
|
||||
@@ -18,33 +28,29 @@ import AIChatModal from "../ai/AIChatModal"
|
||||
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
|
||||
}
|
||||
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`
|
||||
)
|
||||
.select(`*`)
|
||||
.eq("id", planId)
|
||||
.single()
|
||||
if (error) throw error
|
||||
return (data ?? {}) as PlanTextFields
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return (data ?? {}) as PlanTextFields;
|
||||
}
|
||||
|
||||
export const planTextOptions = (planId: string) =>
|
||||
@@ -52,283 +58,503 @@ export const planTextOptions = (planId: string) =>
|
||||
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]
|
||||
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})`
|
||||
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)
|
||||
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>
|
||||
return <span className="text-neutral-400">—</span>;
|
||||
}
|
||||
const content = Array.isArray(text) ? text.join("\n• ") : text
|
||||
const rendered = Array.isArray(text) ? `• ${content}` : content
|
||||
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">
|
||||
<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)
|
||||
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">
|
||||
<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
|
||||
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) }}>
|
||||
<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))
|
||||
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 [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
|
||||
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 }
|
||||
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 😓")
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev);
|
||||
toast.error((e as any)?.message || "No se pudo guardar 😓");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Guardado ✅")
|
||||
toast.success("Guardado ✅");
|
||||
},
|
||||
onSettled: async () => {
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) })
|
||||
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 }
|
||||
{
|
||||
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)
|
||||
|
||||
);
|
||||
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">
|
||||
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"
|
||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||
onClick={() => {
|
||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||
}}
|
||||
onClick={() => setOpenHistorial(true)}
|
||||
>
|
||||
Copiar
|
||||
Ver historial
|
||||
</Button>
|
||||
{s.key !== "prompt" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
<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 current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
setEditing({ key: s.key, title: s.title })
|
||||
setDraft(current)
|
||||
const toCopy = Array.isArray(text)
|
||||
? text.join("\n")
|
||||
: (text ?? "");
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy);
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionPanel>
|
||||
)
|
||||
})}
|
||||
|
||||
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>
|
||||
{/* 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í…"
|
||||
/>
|
||||
<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
|
||||
<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]
|
||||
// 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
|
||||
}]
|
||||
// 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()
|
||||
})
|
||||
// 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
|
||||
}
|
||||
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 })
|
||||
// 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>
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
}}
|
||||
onAccept={(newText: string) => {
|
||||
if (iaContext) {
|
||||
updateField.mutate({ key: iaContext.key, value: newText });
|
||||
setIaContext(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user