wip
This commit is contained in:
@@ -1,15 +1,24 @@
|
|||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router";
|
||||||
import { useSupabaseAuth } from "@/auth/supabase"
|
import { useSupabaseAuth } from "@/auth/supabase";
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
import {
|
||||||
import { Label } from "@/components/ui/label"
|
Dialog,
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
DialogContent,
|
||||||
import { Input } from "@/components/ui/input"
|
DialogHeader,
|
||||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
DialogTitle,
|
||||||
import { Button } from "@/components/ui/button"
|
DialogFooter,
|
||||||
import { postAPI } from "@/lib/api"
|
} from "@/components/ui/dialog";
|
||||||
import { supabase } from "@/auth/supabase"
|
import { Label } from "@/components/ui/label";
|
||||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
CarreraCombobox,
|
||||||
|
FacultadCombobox,
|
||||||
|
} from "@/components/users/procedencia-combobox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { postAPI } from "@/lib/api";
|
||||||
|
import { supabase } from "@/auth/supabase";
|
||||||
|
import { DetailDialog } from "@/components/archivos/DetailDialog";
|
||||||
import type { RefRow } from "@/types/RefRow";
|
import type { RefRow } from "@/types/RefRow";
|
||||||
|
|
||||||
// ————————————————————————————————————————————————————————————————
|
// ————————————————————————————————————————————————————————————————
|
||||||
@@ -50,42 +59,51 @@ function extIcon(ext: string) {
|
|||||||
// ————————————————————————————————————————————————————————————————
|
// ————————————————————————————————————————————————————————————————
|
||||||
// Componente principal
|
// Componente principal
|
||||||
// ————————————————————————————————————————————————————————————————
|
// ————————————————————————————————————————————————————————————————
|
||||||
export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
export function CreatePlanDialog({
|
||||||
const router = useRouter()
|
open,
|
||||||
const auth = useSupabaseAuth()
|
onOpenChange,
|
||||||
const role = auth.claims?.role
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useSupabaseAuth();
|
||||||
|
const role = auth.claims?.role;
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
|
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "");
|
||||||
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
|
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "");
|
||||||
const [nivel, setNivel] = useState("")
|
const [nivel, setNivel] = useState("");
|
||||||
const [prompt, setPrompt] = useState(
|
const [prompt, setPrompt] = useState(
|
||||||
"Genera un plan de estudios claro y realista: "
|
"Genera un plan de estudios claro y realista: "
|
||||||
)
|
);
|
||||||
|
|
||||||
const [dbFiles, setDbFiles] = useState<{
|
const [dbFiles, setDbFiles] = useState<
|
||||||
id: string;
|
{
|
||||||
titulo: string;
|
id: string;
|
||||||
s3_file_path: string;
|
titulo: string;
|
||||||
fecha_subida?: string;
|
s3_file_path: string;
|
||||||
tags?: string[];
|
fecha_subida?: string;
|
||||||
}[]>([])
|
tags?: string[];
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
}[]
|
||||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null)
|
>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(
|
||||||
const itemsPerPage = 10
|
null
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 300)
|
);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||||
const totalPages = Math.ceil(dbFiles.length / itemsPerPage);
|
const totalPages = Math.ceil(dbFiles.length / itemsPerPage);
|
||||||
|
|
||||||
const [previewRow, setPreviewRow] = useState<RefRow | null>(null);
|
const [previewRow, setPreviewRow] = useState<RefRow | null>(null);
|
||||||
|
|
||||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
const lockFacultad =
|
||||||
const lockCarrera = role === "jefe_carrera"
|
role === "secretario_academico" || role === "jefe_carrera";
|
||||||
|
const lockCarrera = role === "jefe_carrera";
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchDbFiles() {
|
async function fetchDbFiles() {
|
||||||
@@ -94,20 +112,25 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
.from("documentos")
|
.from("documentos")
|
||||||
.select("documentos_id, titulo_archivo, fecha_subida, tags")
|
.select("documentos_id, titulo_archivo, fecha_subida, tags")
|
||||||
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
||||||
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
|
.range(
|
||||||
|
(currentPage - 1) * itemsPerPage,
|
||||||
|
currentPage * itemsPerPage - 1
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error fetching files from database:", error);
|
console.error("Error fetching files from database:", error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDbFiles((data || []).map((file: any) => ({
|
setDbFiles(
|
||||||
id: file.documentos_id,
|
(data || []).map((file: any) => ({
|
||||||
titulo: file.titulo_archivo,
|
id: file.documentos_id,
|
||||||
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
|
titulo: file.titulo_archivo,
|
||||||
fecha_subida: file.fecha_subida,
|
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
|
||||||
tags: file.tags || [],
|
fecha_subida: file.fecha_subida,
|
||||||
})));
|
tags: file.tags || [],
|
||||||
|
}))
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Unexpected error fetching files:", err);
|
console.error("Unexpected error fetching files:", err);
|
||||||
}
|
}
|
||||||
@@ -116,41 +139,59 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
if (open) fetchDbFiles();
|
if (open) fetchDbFiles();
|
||||||
}, [open, debouncedSearchTerm, currentPage]);
|
}, [open, debouncedSearchTerm, currentPage]);
|
||||||
|
|
||||||
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
const isSelected = useCallback(
|
||||||
|
(path: string) => selectedFiles.includes(path),
|
||||||
|
[selectedFiles]
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSelected = useCallback((id: string) => {
|
const toggleSelected = useCallback((id: string) => {
|
||||||
setSelectedFiles(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
|
setSelectedFiles((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replaceSelection = useCallback((id: string) => {
|
const replaceSelection = useCallback((id: string) => {
|
||||||
setSelectedFiles([id]);
|
setSelectedFiles([id]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rangeSelect = useCallback((start: number, end: number) => {
|
const rangeSelect = useCallback(
|
||||||
const [s, e] = start < end ? [start, end] : [end, start];
|
(start: number, end: number) => {
|
||||||
const ids = dbFiles.slice(s, e + 1).map(f => f.id);
|
const [s, e] = start < end ? [start, end] : [end, start];
|
||||||
setSelectedFiles(prev => Array.from(new Set([...prev, ...ids])));
|
const ids = dbFiles.slice(s, e + 1).map((f) => f.id);
|
||||||
}, [dbFiles]);
|
setSelectedFiles((prev) => Array.from(new Set([...prev, ...ids])));
|
||||||
|
},
|
||||||
|
[dbFiles]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { id: string }) => {
|
const handleCardClick = useCallback(
|
||||||
const id = file.id;
|
(e: React.MouseEvent, index: number, file: { id: string }) => {
|
||||||
|
const id = file.id;
|
||||||
|
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||||
rangeSelect(lastSelectedIndex, index);
|
rangeSelect(lastSelectedIndex, index);
|
||||||
} else if (e.metaKey || e.ctrlKey) {
|
} else if (e.metaKey || e.ctrlKey) {
|
||||||
toggleSelected(id);
|
toggleSelected(id);
|
||||||
setLastSelectedIndex(index);
|
|
||||||
} else {
|
|
||||||
if (isSelected(id) && selectedFiles.length === 1) {
|
|
||||||
// si ya es el único seleccionado, des-selecciona
|
|
||||||
setSelectedFiles([]);
|
|
||||||
setLastSelectedIndex(null);
|
|
||||||
} else {
|
|
||||||
replaceSelection(id);
|
|
||||||
setLastSelectedIndex(index);
|
setLastSelectedIndex(index);
|
||||||
|
} else {
|
||||||
|
if (isSelected(id) && selectedFiles.length === 1) {
|
||||||
|
// si ya es el único seleccionado, des-selecciona
|
||||||
|
setSelectedFiles([]);
|
||||||
|
setLastSelectedIndex(null);
|
||||||
|
} else {
|
||||||
|
replaceSelection(id);
|
||||||
|
setLastSelectedIndex(index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [isSelected, lastSelectedIndex, rangeSelect, replaceSelection, selectedFiles.length, toggleSelected]);
|
[
|
||||||
|
isSelected,
|
||||||
|
lastSelectedIndex,
|
||||||
|
rangeSelect,
|
||||||
|
replaceSelection,
|
||||||
|
selectedFiles.length,
|
||||||
|
toggleSelected,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
@@ -158,30 +199,51 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function crearConIA() {
|
async function crearConIA() {
|
||||||
setErr(null)
|
setErr(null);
|
||||||
if (!carreraId) { setErr("Selecciona una carrera."); return }
|
if (!carreraId) {
|
||||||
setSaving(true)
|
setErr("Selecciona una carrera.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await postAPI("/api/generar/plan", {
|
|
||||||
carreraId,
|
const {
|
||||||
prompt: prompt,
|
data: { session },
|
||||||
insert: true,
|
} = await supabase.auth.getSession();
|
||||||
files: selectedFiles,
|
const token = session?.access_token;
|
||||||
created_by: auth.user?.id,
|
|
||||||
})
|
const { data, error } = await supabase.functions.invoke(
|
||||||
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
"crear-plan-estudios",
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: {
|
||||||
|
carreraId,
|
||||||
|
prompt_usuario: prompt,
|
||||||
|
insert: true,
|
||||||
|
archivos_a_usar: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const res = JSON.parse(data as string);
|
||||||
|
|
||||||
|
const newId =
|
||||||
|
(res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id;
|
||||||
if (newId) {
|
if (newId) {
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
router.invalidate()
|
router.invalidate();
|
||||||
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
|
router.navigate({ to: "/plan/$planId", params: { planId: newId } });
|
||||||
} else {
|
} else {
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
router.invalidate()
|
router.invalidate();
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(typeof e?.message === "string" ? e.message : "Error al generar el plan.")
|
setErr(
|
||||||
|
typeof e?.message === "string" ? e.message : "Error al generar el plan."
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +254,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="min-w-[65vw] max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="min-w-[65vw] max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono">Nuevo plan de estudios (IA)</DialogTitle>
|
<DialogTitle className="font-mono">
|
||||||
|
Nuevo plan de estudios (IA)
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -215,7 +279,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<Label>Facultad</Label>
|
<Label>Facultad</Label>
|
||||||
<FacultadCombobox
|
<FacultadCombobox
|
||||||
value={facultadId}
|
value={facultadId}
|
||||||
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
|
onChange={(id) => {
|
||||||
|
setFacultadId(id);
|
||||||
|
setCarreraId("");
|
||||||
|
}}
|
||||||
disabled={lockFacultad}
|
disabled={lockFacultad}
|
||||||
placeholder="Elige una facultad…"
|
placeholder="Elige una facultad…"
|
||||||
/>
|
/>
|
||||||
@@ -228,7 +295,11 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
value={carreraId}
|
value={carreraId}
|
||||||
onChange={setCarreraId}
|
onChange={setCarreraId}
|
||||||
disabled={!facultadId || lockCarrera}
|
disabled={!facultadId || lockCarrera}
|
||||||
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
|
placeholder={
|
||||||
|
facultadId
|
||||||
|
? "Elige una carrera…"
|
||||||
|
: "Selecciona una facultad primero"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,11 +317,19 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<div className="text-sm text-neutral-600">
|
<div className="text-sm text-neutral-600">
|
||||||
{selectedFiles.length > 0 ? (
|
{selectedFiles.length > 0 ? (
|
||||||
<span>
|
<span>
|
||||||
{selectedFiles.length} seleccionado{selectedFiles.length > 1 ? 's' : ''}
|
{selectedFiles.length} seleccionado
|
||||||
<button className="ml-3 underline hover:no-underline" onClick={clearSelection}>Limpiar</button>
|
{selectedFiles.length > 1 ? "s" : ""}
|
||||||
|
<button
|
||||||
|
className="ml-3 underline hover:no-underline"
|
||||||
|
onClick={clearSelection}
|
||||||
|
>
|
||||||
|
Limpiar
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>Tip: ⇧ para seleccionar rango, ⌘/Ctrl para múltiples.</span>
|
<span>
|
||||||
|
Tip: ⇧ para seleccionar rango, ⌘/Ctrl para múltiples.
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,12 +337,15 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
{/* Grid de archivos con selección tipo file manager */}
|
{/* Grid de archivos con selección tipo file manager */}
|
||||||
<div className="md:col-span-2 space-y-1">
|
<div className="md:col-span-2 space-y-1">
|
||||||
<Label>Archivos de referencia (opcional)</Label>
|
<Label>Archivos de referencia (opcional)</Label>
|
||||||
<div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
<div
|
||||||
|
role="grid"
|
||||||
|
className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||||
|
>
|
||||||
{dbFiles.map((file, index) => {
|
{dbFiles.map((file, index) => {
|
||||||
const ext = fileExt(file.titulo);
|
const ext = fileExt(file.titulo);
|
||||||
const selected = isSelected(file.id);
|
const selected = isSelected(file.id);
|
||||||
console.log(file);
|
console.log(file);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
@@ -285,10 +367,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
fecha_subida: file.fecha_subida ?? null,
|
fecha_subida: file.fecha_subida ?? null,
|
||||||
tags: file.tags ?? null,
|
tags: file.tags ?? null,
|
||||||
instrucciones: "",
|
instrucciones: "",
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleCardClick(e as any, index, file);
|
handleCardClick(e as any, index, file);
|
||||||
}
|
}
|
||||||
@@ -296,31 +378,51 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
className={[
|
className={[
|
||||||
"group relative rounded-2xl border bg-white p-4 text-left shadow-sm transition",
|
"group relative rounded-2xl border bg-white p-4 text-left shadow-sm transition",
|
||||||
"hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
"hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||||
selected ? "border-blue-500 ring-2 ring-blue-500 shadow-md" : "border-neutral-200 hover:border-neutral-300",
|
selected
|
||||||
].join(' ')}
|
? "border-blue-500 ring-2 ring-blue-500 shadow-md"
|
||||||
|
: "border-neutral-200 hover:border-neutral-300",
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Outline animado tipo file manager */}
|
{/* Outline animado tipo file manager */}
|
||||||
<span className={[
|
<span
|
||||||
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
|
className={[
|
||||||
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
|
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
|
||||||
].join(' ')} />
|
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border bg-neutral-50">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border bg-neutral-50">
|
||||||
<span className="text-lg" aria-hidden>{extIcon(ext)}</span>
|
<span className="text-lg" aria-hidden>
|
||||||
|
{extIcon(ext)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-semibold text-sm md:text-base truncate" title={file.titulo}>{file.titulo}</h3>
|
<h3
|
||||||
|
className="font-semibold text-sm md:text-base truncate"
|
||||||
|
title={file.titulo}
|
||||||
|
>
|
||||||
|
{file.titulo}
|
||||||
|
</h3>
|
||||||
{file.fecha_subida ? (
|
{file.fecha_subida ? (
|
||||||
<p className="text-xs text-neutral-600">{new Date(file.fecha_subida).toLocaleDateString()}</p>
|
<p className="text-xs text-neutral-600">
|
||||||
|
{new Date(file.fecha_subida).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-neutral-500">Fecha desconocida</p>
|
<p className="text-xs text-neutral-500">
|
||||||
|
Fecha desconocida
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{file.tags && file.tags.length > 0 && (
|
{file.tags && file.tags.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{file.tags.map((tag, i) => (
|
{file.tags.map((tag, i) => (
|
||||||
<span key={i} className="text-[10px] px-2 py-0.5 bg-neutral-100 text-neutral-700 rounded-full">#{tag}</span>
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-[10px] px-2 py-0.5 bg-neutral-100 text-neutral-700 rounded-full"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -347,50 +449,69 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
fecha_subida: file.fecha_subida ?? null,
|
fecha_subida: file.fecha_subida ?? null,
|
||||||
tags: file.tags ?? null,
|
tags: file.tags ?? null,
|
||||||
instrucciones: "",
|
instrucciones: "",
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
>Previsualizar</Button>
|
>
|
||||||
|
Previsualizar
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer compacto */}
|
{/* Footer compacto */}
|
||||||
<div className="mt-4 flex items-center justify-between text-xs text-neutral-600">
|
<div className="mt-4 flex items-center justify-between text-xs text-neutral-600">
|
||||||
<span className="truncate">{ext.toUpperCase()}</span>
|
<span className="truncate">{ext.toUpperCase()}</span>
|
||||||
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
|
{selected ? (
|
||||||
|
<span className="font-medium">Seleccionado</span>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-60">
|
||||||
|
Click para seleccionar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{dbFiles.length === 0 && (
|
{dbFiles.length === 0 && (
|
||||||
<p className="text-sm text-neutral-500">No se encontraron archivos.</p>
|
<p className="text-sm text-neutral-500">
|
||||||
|
No se encontraron archivos.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Paginación mejorada */}
|
{/* Paginación mejorada */}
|
||||||
{dbFiles.length > itemsPerPage && (
|
{dbFiles.length > itemsPerPage && (
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-sm text-neutral-700">Página {currentPage} de {totalPages}</div>
|
<div className="text-sm text-neutral-700">
|
||||||
|
Página {currentPage} de {totalPages}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||||
>Anterior</Button>
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
className="h-8 w-16 text-center"
|
className="h-8 w-16 text-center"
|
||||||
value={currentPage}
|
value={currentPage}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = parseInt(e.target.value || '1', 10);
|
const v = parseInt(e.target.value || "1", 10);
|
||||||
if (!isNaN(v)) setCurrentPage(Math.min(Math.max(v, 1), totalPages));
|
if (!isNaN(v))
|
||||||
|
setCurrentPage(Math.min(Math.max(v, 1), totalPages));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
onClick={() =>
|
||||||
>Siguiente</Button>
|
setCurrentPage((p) => Math.min(p + 1, totalPages))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -400,19 +521,26 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
{err && <div className="text-sm text-red-600">{err}</div>}
|
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
<Button
|
||||||
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={crearConIA}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
{saving ? "Generando…" : "Generar y crear"}
|
{saving ? "Generando…" : "Generar y crear"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
{previewRow && (
|
{previewRow && (
|
||||||
<DetailDialog
|
<DetailDialog row={previewRow} onClose={() => setPreviewRow(null)} />
|
||||||
row={previewRow}
|
|
||||||
onClose={() => setPreviewRow(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react";
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react";
|
||||||
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query"
|
import {
|
||||||
import { Button } from "@/components/ui/button"
|
useSuspenseQuery,
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
useMutation,
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
useQueryClient,
|
||||||
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
queryOptions,
|
||||||
import { toast } from "sonner"
|
} from "@tanstack/react-query";
|
||||||
import ReactMarkdown from 'react-markdown'
|
import { Button } from "@/components/ui/button";
|
||||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
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
|
// @ts-ignore
|
||||||
import AIChatModal from "../ai/AIChatModal"
|
import AIChatModal from "../ai/AIChatModal";
|
||||||
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Query keys & fetcher
|
Query keys & fetcher
|
||||||
@@ -18,33 +28,29 @@ import AIChatModal from "../ai/AIChatModal"
|
|||||||
export const planKeys = {
|
export const planKeys = {
|
||||||
root: ["plan"] as const,
|
root: ["plan"] as const,
|
||||||
byId: (id: string) => [...planKeys.root, id] as const,
|
byId: (id: string) => [...planKeys.root, id] as const,
|
||||||
}
|
};
|
||||||
|
|
||||||
export type PlanTextFields = {
|
export type PlanTextFields = {
|
||||||
objetivo_general?: string | string[] | null
|
objetivo_general?: string | string[] | null;
|
||||||
sistema_evaluacion?: string | string[] | null
|
sistema_evaluacion?: string | string[] | null;
|
||||||
perfil_ingreso?: string | string[] | null
|
perfil_ingreso?: string | string[] | null;
|
||||||
perfil_egreso?: string | string[] | null
|
perfil_egreso?: string | string[] | null;
|
||||||
competencias_genericas?: string | string[] | null
|
competencias_genericas?: string | string[] | null;
|
||||||
competencias_especificas?: string | string[] | null
|
competencias_especificas?: string | string[] | null;
|
||||||
indicadores_desempeno?: string | string[] | null
|
indicadores_desempeno?: string | string[] | null;
|
||||||
pertinencia?: string | string[] | null
|
pertinencia?: string | string[] | null;
|
||||||
prompt?: string | null
|
prompt?: string | null;
|
||||||
historico?: string | null
|
historico?: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("plan_estudios")
|
.from("plan_estudios")
|
||||||
.select(
|
.select(`*`)
|
||||||
`objetivo_general, sistema_evaluacion, perfil_ingreso, perfil_egreso,
|
|
||||||
competencias_genericas, competencias_especificas, indicadores_desempeno,
|
|
||||||
pertinencia, prompt`
|
|
||||||
)
|
|
||||||
.eq("id", planId)
|
.eq("id", planId)
|
||||||
.single()
|
.single();
|
||||||
if (error) throw error
|
if (error) throw error;
|
||||||
return (data ?? {}) as PlanTextFields
|
return (data ?? {}) as PlanTextFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const planTextOptions = (planId: string) =>
|
export const planTextOptions = (planId: string) =>
|
||||||
@@ -52,283 +58,503 @@ export const planTextOptions = (planId: string) =>
|
|||||||
queryKey: planKeys.byId(planId),
|
queryKey: planKeys.byId(planId),
|
||||||
queryFn: () => fetchPlanText(planId),
|
queryFn: () => fetchPlanText(planId),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Color helpers
|
Color helpers
|
||||||
===================================================== */
|
===================================================== */
|
||||||
function hexToRgb(hex?: string | null): [number, number, number] {
|
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||||
if (!hex) return [37, 99, 235]
|
if (!hex) return [37, 99, 235];
|
||||||
const h = hex.replace("#", "")
|
const h = hex.replace("#", "");
|
||||||
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
|
const v =
|
||||||
const n = parseInt(v, 16)
|
h.length === 3
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
? 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
|
Expandable text
|
||||||
===================================================== */
|
===================================================== */
|
||||||
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
|
function ExpandableText({
|
||||||
const [open, setOpen] = useState(false)
|
text,
|
||||||
|
mono = false,
|
||||||
|
}: {
|
||||||
|
text?: string | string[] | null;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
if (!text || (Array.isArray(text) && text.length === 0)) {
|
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 content = Array.isArray(text) ? text.join("\n• ") : text;
|
||||||
const rendered = Array.isArray(text) ? `• ${content}` : content
|
const rendered = Array.isArray(text) ? `• ${content}` : content;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ReactMarkdown>{rendered}</ReactMarkdown>
|
<ReactMarkdown>{rendered}</ReactMarkdown>
|
||||||
{String(rendered).length > 220 && (
|
{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"}
|
{open ? "Ver menos" : "Ver más"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Section panel
|
Section panel
|
||||||
===================================================== */
|
===================================================== */
|
||||||
function SectionPanel({ title, icon: Icon, color, children, id }: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
function SectionPanel({
|
||||||
const rgb = hexToRgb(color)
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
children,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: any;
|
||||||
|
color?: string | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
return (
|
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="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
|
||||||
<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%)` }} />
|
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>
|
||||||
<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)` }}>
|
<div
|
||||||
<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) }}>
|
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" />
|
<Icon className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<h3 className="font-semibold">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 p-5">{children}</div>
|
<div className="relative z-10 p-5">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
AcademicSections (con React Query)
|
AcademicSections (con React Query)
|
||||||
===================================================== */
|
===================================================== */
|
||||||
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
export function AcademicSections({
|
||||||
const qc = useQueryClient()
|
planId,
|
||||||
const auth = useSupabaseAuth()
|
color,
|
||||||
const [openHistorial, setOpenHistorial] = useState(false)
|
}: {
|
||||||
const [openModalIa, setopenModalIa] = useState(false)
|
planId: string;
|
||||||
if(!planId) return <div>Cargando…</div>
|
color?: string | null;
|
||||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
}) {
|
||||||
|
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 [editing, setEditing] = useState<null | {
|
||||||
const [draft, setDraft] = useState("")
|
key: keyof PlanTextFields;
|
||||||
|
title: string;
|
||||||
|
}>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
// --- mutation con actualización optimista ---
|
// --- mutation con actualización optimista ---
|
||||||
const updateField = useMutation({
|
const updateField = useMutation({
|
||||||
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
|
mutationFn: async ({
|
||||||
const payload: Record<string, any> = { [key]: value }
|
key,
|
||||||
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
|
value,
|
||||||
if (error) throw error
|
}: {
|
||||||
return payload
|
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 }) => {
|
onMutate: async ({ key, value }) => {
|
||||||
await qc.cancelQueries({ queryKey: planKeys.byId(planId) })
|
await qc.cancelQueries({ queryKey: planKeys.byId(planId) });
|
||||||
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId))
|
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId));
|
||||||
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({ ...(old ?? {}), [key]: value }))
|
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({
|
||||||
return { prev }
|
...(old ?? {}),
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
return { prev };
|
||||||
},
|
},
|
||||||
onError: (e, _vars, ctx) => {
|
onError: (e, _vars, ctx) => {
|
||||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev)
|
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev);
|
||||||
toast.error((e as any)?.message || "No se pudo guardar 😓")
|
toast.error((e as any)?.message || "No se pudo guardar 😓");
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Guardado ✅")
|
toast.success("Guardado ✅");
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) })
|
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const sections = useMemo(
|
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-clave",
|
||||||
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
title: "Clave del plan",
|
||||||
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
icon: Icons.Key,
|
||||||
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
key: "clave_del_plan_de_estudios" as const,
|
||||||
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
mono: true,
|
||||||
{ 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-area",
|
||||||
{ id: "sec-hist", title: "Histórico de cambios", icon: Icons.History, key: "historico" as const, mono: false }
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
{sections.map((s) => {
|
{sections.map((s) => {
|
||||||
const text = plan[s.key] ?? null
|
const text = String(plan[s.key]) ?? null;
|
||||||
return (
|
return (
|
||||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
<SectionPanel
|
||||||
{s.key === "historico" ? (
|
key={s.id}
|
||||||
<>
|
id={s.id}
|
||||||
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
title={s.title}
|
||||||
Ver historial
|
icon={s.icon}
|
||||||
</Button>
|
color={color}
|
||||||
<Button variant="outline" size="sm" onClick={() => setopenModalIa(true)}>
|
>
|
||||||
Promt
|
{s.key === "historico" ? (
|
||||||
</Button>
|
<>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ExpandableText text={text} mono={s.mono} />
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
onClick={() => setOpenHistorial(true)}
|
||||||
onClick={() => {
|
|
||||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
|
||||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Copiar
|
Ver historial
|
||||||
</Button>
|
</Button>
|
||||||
{s.key !== "prompt" && (
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="ghost"
|
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"
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
!text || (Array.isArray(text) && text.length === 0)
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
const toCopy = Array.isArray(text)
|
||||||
setEditing({ key: s.key, title: s.title })
|
? text.join("\n")
|
||||||
setDraft(current)
|
: (text ?? "");
|
||||||
|
if (toCopy) navigator.clipboard.writeText(toCopy);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Editar
|
Copiar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{s.key !== "prompt" && (
|
||||||
</div>
|
<Button
|
||||||
</>
|
variant="ghost"
|
||||||
)}
|
size="sm"
|
||||||
</SectionPanel>
|
onClick={() => {
|
||||||
)
|
const current = Array.isArray(text)
|
||||||
})}
|
? text.join("\n")
|
||||||
|
: (text ?? "");
|
||||||
|
setEditing({ key: s.key, title: s.title });
|
||||||
|
setDraft(current);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionPanel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diálogo de edición */}
|
{/* Diálogo de edición */}
|
||||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
<Dialog
|
||||||
<DialogContent className="max-w-2xl">
|
open={!!editing}
|
||||||
<DialogHeader>
|
onOpenChange={(o) => {
|
||||||
<DialogTitle className="font-mono">
|
if (!o) setEditing(null);
|
||||||
{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}
|
}}
|
||||||
</DialogTitle>
|
>
|
||||||
</DialogHeader>
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-mono">
|
||||||
|
{editing
|
||||||
|
? `Editar: ${sections.find((x) => x.key === editing.key)?.title}`
|
||||||
|
: ""}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
||||||
placeholder="Escribe aquí…"
|
placeholder="Escribe aquí…"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||||
<Button
|
Cancelar
|
||||||
onClick={async () => {
|
</Button>
|
||||||
if (!editing) return
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
// 1️⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
|
// 1️⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
|
||||||
const oldValue = (plan as any)[editing.key]
|
const oldValue = (plan as any)[editing.key];
|
||||||
|
|
||||||
// 2️⃣ Crear un diff tipo JSON Patch
|
// 2️⃣ Crear un diff tipo JSON Patch
|
||||||
const diff = [{
|
const diff = [
|
||||||
op: "replace",
|
{
|
||||||
path: `/${editing.key}`,
|
op: "replace",
|
||||||
from: oldValue,
|
path: `/${editing.key}`,
|
||||||
value: draft
|
from: oldValue,
|
||||||
}]
|
value: draft,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 3️⃣ Guardar respaldo antes de actualizar
|
// 3️⃣ Guardar respaldo antes de actualizar
|
||||||
const { error: backupError } = await supabase.from("historico_cambios").insert({
|
const { error: backupError } = await supabase
|
||||||
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
|
.from("historico_cambios")
|
||||||
json_cambios: diff,
|
.insert({
|
||||||
user_id:auth.user?.id,
|
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
|
||||||
created_at: new Date().toISOString()
|
json_cambios: diff,
|
||||||
})
|
user_id: auth.user?.id,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
if (backupError) {
|
if (backupError) {
|
||||||
console.error("Error al guardar respaldo:", backupError)
|
console.error("Error al guardar respaldo:", backupError);
|
||||||
alert("No se pudo guardar el respaldo de los cambios")
|
alert("No se pudo guardar el respaldo de los cambios");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4️⃣ Ejecutar la mutación original
|
// 4️⃣ Ejecutar la mutación original
|
||||||
updateField.mutate({ key: editing.key, value: draft })
|
updateField.mutate({ key: editing.key, value: draft });
|
||||||
|
|
||||||
// 5️⃣ Cerrar el diálogo
|
// 5️⃣ Cerrar el diálogo
|
||||||
setEditing(null)
|
setEditing(null);
|
||||||
}}
|
}}
|
||||||
disabled={updateField.isPending}
|
disabled={updateField.isPending}
|
||||||
>
|
>
|
||||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!editing) return
|
if (!editing) return;
|
||||||
const current = draft
|
const current = draft;
|
||||||
setIaContext({
|
setIaContext({
|
||||||
key: editing.key,
|
key: editing.key,
|
||||||
title: editing.title,
|
title: editing.title,
|
||||||
content: current,
|
content: current,
|
||||||
})
|
});
|
||||||
setopenModalIa(true)
|
setopenModalIa(true);
|
||||||
setEditing(null) // 🔹 Cierra el modal de edición
|
setEditing(null); // 🔹 Cierra el modal de edición
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Mejorar con IA
|
Mejorar con IA
|
||||||
</Button>
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
</DialogFooter>
|
</Dialog>
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<HistorialCambiosModal
|
<HistorialCambiosModal
|
||||||
open={openHistorial}
|
open={openHistorial}
|
||||||
onClose={() => setOpenHistorial(false)}
|
onClose={() => setOpenHistorial(false)}
|
||||||
planId={planId}
|
planId={planId}
|
||||||
onRestore={async (key, value) => {
|
onRestore={async (key, value) => {
|
||||||
updateField.mutate({ key, value })
|
updateField.mutate({ key, value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AIChatModal
|
<AIChatModal
|
||||||
open={openModalIa}
|
open={openModalIa}
|
||||||
onClose={() => setopenModalIa(false)}
|
onClose={() => setopenModalIa(false)}
|
||||||
context={{
|
context={{
|
||||||
section: iaContext?.title,
|
section: iaContext?.title,
|
||||||
fieldKey: iaContext?.key,
|
fieldKey: iaContext?.key,
|
||||||
originalText: iaContext?.content,
|
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.
|
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 frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
|
||||||
No uses introducciones, despedidas ni texto de relleno.
|
No uses introducciones, despedidas ni texto de relleno.
|
||||||
Entrega solo el contenido útil.`,
|
Entrega solo el contenido útil.`,
|
||||||
}}
|
}}
|
||||||
onAccept={(newText: string) => {
|
onAccept={(newText: string) => {
|
||||||
if (iaContext) {
|
if (iaContext) {
|
||||||
updateField.mutate({ key: iaContext.key, value: newText })
|
updateField.mutate({ key: iaContext.key, value: newText });
|
||||||
setIaContext(null)
|
setIaContext(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,15 +26,17 @@ export function planByIdOptions(planId: string) {
|
|||||||
queryKey: planKeys.byId(planId),
|
queryKey: planKeys.byId(planId),
|
||||||
queryFn: async (): Promise<PlanFull> => {
|
queryFn: async (): Promise<PlanFull> => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("plan_estudios")
|
.from("plan_estudios")
|
||||||
.select(`
|
.select(`
|
||||||
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
*,
|
||||||
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
carreras (
|
||||||
pertinencia, prompt, estado, fecha_creacion,
|
id,
|
||||||
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
nombre,
|
||||||
`)
|
facultades ( id, nombre, color, icon )
|
||||||
.eq("id", planId)
|
)
|
||||||
.maybeSingle()
|
`)
|
||||||
|
.eq("id", planId)
|
||||||
|
.maybeSingle();
|
||||||
if (error || !data) throw error ?? new Error("Plan no encontrado")
|
if (error || !data) throw error ?? new Error("Plan no encontrado")
|
||||||
return data as unknown as PlanFull
|
return data as unknown as PlanFull
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function fetchDashboard(): Promise<LoaderData> {
|
|||||||
supabase
|
supabase
|
||||||
.from('plan_estudios')
|
.from('plan_estudios')
|
||||||
.select(
|
.select(
|
||||||
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
|
'*'
|
||||||
),
|
),
|
||||||
supabase
|
supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ function RouteComponent() {
|
|||||||
<CardContent ref={statsRef}>
|
<CardContent ref={statsRef}>
|
||||||
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||||
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
|
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
|
||||||
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
|
<StatCard label="Duración" value={plan.total_de_ciclos_del_plan_de_estudios ?? "—"} Icon={Icons.Clock} accent={facColor} />
|
||||||
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
|
<StatCard label="Modalidad educativa" value={plan.modalidad_educativa ?? "—"} Icon={Icons.Layers} accent={facColor} />
|
||||||
|
<StatCard label="Diseño curricular" value={plan.diseno_curricular ?? "—"} Icon={Icons.Layout} accent={facColor} />
|
||||||
<StatCard label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} Icon={Icons.CalendarDays} accent={facColor} />
|
<StatCard label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} Icon={Icons.CalendarDays} accent={facColor} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user