16 Commits

Author SHA1 Message Date
1475a65938 genración del pdf de plan de estudios ya funciona 2025-12-04 14:01:09 -06:00
9a1d8279a1 los planes de estudios ya tienen la estructura de la plantilla de la sep. Ya se visualizan con esa estructura y se pueden crear planes con dicha estructura 2025-11-28 02:55:04 -06:00
0456a1063d wip 2025-11-27 19:41:44 -06:00
a41136a224 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-27 15:05:17 -06:00
102c21927e gitingore modificado 2025-11-27 15:04:47 -06:00
566e23ad34 Se quitan respuestas del asistente y se agrega boton de cerrar modal 2025-11-25 15:21:32 -06:00
872c495d30 Se agrega modelo de respuestas y conversaciones archivos multiples y contexto de id plan de estudios 2025-11-25 11:34:00 -06:00
7951f9d8c5 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-24 16:17:53 -06:00
4894543c57 Se agrega funcionalidades de crear conversacion, archivos y vectores ademas de MCP 2025-11-24 16:17:49 -06:00
efe7faa65f Cambios de Roberto 2025-11-21 17:05:16 -06:00
c9d66ce2e5 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-18 15:17:15 -06:00
f7a29ad510 Version estable conversacion normal 2025-11-18 15:17:11 -06:00
e7a47f56f8 Merge pull request '[#67] dummy' (!2) from task/67-dummy into main
Reviewed-on: #2
2025-11-13 21:32:48 +00:00
214d17cf98 [#67] dummy
https://proyectos.apps.lci.ulsa.mx/work_packages/67
2025-11-13 15:22:28 -06:00
8c890d76e0 Se agrega titulo a pdf 2025-11-13 10:23:04 -06:00
6d264a8214 Merge branch 'master' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-10-30 14:38:56 -06:00
13 changed files with 1466 additions and 1146 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,7 @@ dist
dist-ssr dist-ssr
*.local *.local
count.txt count.txt
.env .env*
.nitro .nitro
.tanstack .tanstack
.cta.json

25
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"carbone-sdk-js": "^1.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -2819,6 +2820,12 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/carbone-sdk-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/carbone-sdk-js/-/carbone-sdk-js-1.2.2.tgz",
"integrity": "sha512-3bc4F04DizC8ULB6j6JsOq8G0sW/pwdwvfbbZ8exBZbcOCV4WE8KHsY6GiED/3tmH++Z1I5XMppjkBhjg60zjA==",
"license": "Apache-2.0"
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"license": "MIT", "license": "MIT",
@@ -3372,6 +3379,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"license": "MIT", "license": "MIT",
@@ -5514,7 +5535,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {

View File

@@ -34,6 +34,7 @@
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"carbone-sdk-js": "^1.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

View File

@@ -0,0 +1,585 @@
import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase";
import ReactMarkdown from "react-markdown"
/* ---------- UI Mocks (sin cambios) ---------- */
const Paperclip = (props) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
);
const Dialog = ({ open, onOpenChange, children }) =>
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
const DialogContent = ({ className, children }) =>
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
onClick={(e) => e.stopPropagation()}>{children}</div>;
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
const Button = ({ onClick, disabled, className, variant, children }) => (
<button onClick={onClick} disabled={disabled}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
${variant === "outline"
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
: "bg-blue-600 text-white hover:bg-blue-700"}
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
{children}
</button>
);
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
/* ------------- COMPONENT ------------- */
export default function AIChatModal({ open, onClose, context, onAccept }) {
const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreviews, setAttachedPreviews] = useState([]);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [loadingVectors, setLoadingVectors] = useState(false);
const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
const messagesEndRef = useRef(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
useEffect(scrollToBottom, [messages]);
const normalizeInvokeResponse = (resp) => {
if (!resp) return null;
// cuando invocas funciones, Supabase siempre regresa:
// { data: "...string...", error: null, response: {} }
const raw = resp.data;
if (typeof raw === "string") {
try {
return JSON.parse(raw);
} catch (e) {
console.warn("❗ No se pudo parsear resp.data:", raw);
return null;
}
}
// si ya viene como objeto
if (typeof raw === "object" && raw !== null) return raw;
return null;
};
// Al abrir: reset o crear conversación
useEffect(() => {
console.log(context.cont_conversation);
console.log(context);
if (!open) {
// si ya existe una conversación la eliminamos
if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e));
}
setMessages([]);
setInput("");
setSelectedVectorFile(null);
setAttachedFiles([]);
setAttachedPreviews([]);
setConversationId(null);
return;
}
// inyectar contexto como system message
if (context) {
setMessages([
{
role: "system",
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
}
]);
} else {
setMessages(prev => prev); // no hacer nada si no hay contexto
}
// crear conversación y esperar a que termine antes de permitir enviar
(async () => {
await createConversation();
// tras crear podemos también cargar vector stores
fetchVectorStores();
})();
}, [open]);
// --------- CREATE CONVERSATION (robusto) ----------
const createConversation = async () => {
try {
setCreatingConversation(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// llamada
const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "start" , role:"system", content:context.cont_conversation, }
});
console.log("createConversation -> raw resp:", resp);
// resp puede ser { data: "...json string..." } o { data: { ... } }
let parsed = null;
if (typeof resp?.data === "string") {
try {
parsed = JSON.parse(resp.data);
} catch (e) {
console.warn("No se pudo parsear resp.data como JSON:", e, resp.data);
parsed = null;
}
} else if (typeof resp?.data === "object" && resp.data !== null) {
parsed = resp.data;
} else {
// fallback: quizá la respuesta viene en resp (sin data)
parsed = resp;
}
console.log("createConversation -> parsed payload:", parsed);
// buscar el id en varios lugares (robusto)
const convId =
parsed?.conversationId ||
parsed?.data?.conversationId ||
parsed?.data?.id ||
parsed?.id ||
parsed?.conversation_id ||
parsed?.data?.conversation_id;
if (!convId) {
console.warn("No se encontró conversationId en la respuesta parseada:", parsed);
setCreatingConversation(false);
return;
}
setConversationId(convId);
console.log("🟢 Conversación creada y guardada:", convId);
} catch (err) {
console.error("Error creando conversación:", err);
} finally {
setCreatingConversation(false);
}
};
// --------- DELETE CONVERSATION (robusto) ----------
const deleteConversation = async (convIdParam) => {
try {
const convIdToUse = convIdParam ?? conversationId;
if (!convIdToUse) return;
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// algunas implementations esperan { action: "end", conversationId }, otras { action: "end", id }
const { data, error } = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse }
});
console.log("deleteConversation -> response:", data);
setConversationId(null);
} catch (err) {
console.error("Error eliminando conversación:", err);
}
};
// ---------- CONVERT FILE TO BASE64 ----------
const fileToBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = (e) => reject(e);
reader.onload = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(file);
});
// ---------- SEND MESSAGE (usa conversationId) ----------
const handleConversation = async ({ text }) => {
if (!conversationId) {
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
// si no hay conv, opcionalmente intentar crear una sin que el usuario note
return;
}
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
let filesInput = [];
if (attachedFiles.length > 0) {
for (const file of attachedFiles) {
const base64 = await fileToBase64(file);
filesInput.push({
type: "input_file",
filename: file.name,
file_data: `data:${file.type};base64,${base64}`
});
}
}
if (selectedVectorFile) {
// si el archivo del vector viene sólo con id
filesInput.push({
type: "input_file",
file_id: selectedVectorFile.id
});
}
const payload = {
action: "message",
conversationId,
vectorStoreId: selectedVectorFile?.vector_store_id ?? null,
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
input: [
{
role: "user",
content: [
{ type: "input_text", text },
...filesInput
]
}
]
};
const { data: invokeData, error } = await supabase.functions.invoke(
"modal-conversation",
{
headers: { Authorization: `Bearer ${token}` },
body: payload
}
);
if (error) throw error;
console.log("handleConversation -> RAW invokeData:", invokeData);
const parsed = normalizeInvokeResponse({ data: invokeData });
console.log("handleConversation -> PARSED:", parsed);
// 🔥 EXTRACTOR DEFINITIVO
let assistantText = null;
// 1) directo
if (parsed?.data?.output_text) {
assistantText = parsed.data.output_text;
}
// 2) buscar el message
if (!assistantText && Array.isArray(parsed?.data?.output)) {
const msgBlock = parsed.data.output.find(o => o.type === "message");
if (msgBlock?.content?.[0]?.text) {
assistantText = msgBlock.content[0].text;
}
}
// 3) fallback
assistantText = assistantText || "Sin respuesta del modelo.";
setMessages(prev => [
...prev,
{ role: "assistant", content: cleanAssistantResponse(assistantText) }
]);
setAttachedFiles([]);
setAttachedPreviews([]);
} catch (err) {
console.error("Error en handleConversation:", err);
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
} finally {
setLoading(false);
}
};
// ---------- VECTORES ----------
const fetchVectorStores = async () => {
try {
setLoadingVectors(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
headers: { Authorization: `Bearer ${token}` },
body: { module: "vectorStores", action: "list" }
});
if (error) throw error;
setVectorStores(Array.isArray(data) ? data : (data?.data ?? []));
} catch (err) {
console.error("Error loading vector stores:", err);
setVectorStores([]);
} finally {
setLoadingVectors(false);
}
};
useEffect(() => {
if (open) fetchVectorStores();
}, [open]);
const loadFilesForVector = async (vectorStoreId) => {
try {
setLoadingFiles(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
headers: { Authorization: `Bearer ${token}` },
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
});
if (error) throw error;
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
} catch (err) {
console.error("Error loading vector files:", err);
setVectorFiles([]);
} finally {
setLoadingFiles(false);
}
};
// ---------- UI helpers ----------
const handleAttach = (e) => {
const files = Array.from(e.target.files);
if (!files.length) return;
setAttachedFiles(prev => [...prev, ...files]);
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
};
const handleSelectVectorFile = (file) => {
setSelectedVectorFile(file);
};
// ---------- Send flow ----------
const handleSend = async () => {
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
// esperar si aún se está creando la conversación
if (creatingConversation) {
console.log("Esperando a que se cree la conversación...");
// opcional: podrías mostrar un toast; aquí simplemente retornamos
return;
}
if (!conversationId) {
console.warn("No hay conversationId — intentaremos crear una ahora.");
await createConversation();
if (!conversationId) {
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
return;
}
}
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
setMessages(prev => [...prev, { role: "user", content: userText }]);
setInput("");
await handleConversation({ text: userText });
};
const handleApply = () => {
const last = [...messages].reverse().find(m => m.role === "assistant");
if (last && onAccept) {
onAccept(last.content);
onClose();
}
};
const cleanAssistantResponse = (text) => {
if (!text) return text;
// Frases que quieres eliminar (puedes agregar más)
const patterns = [
/^claro[, ]*/i,
/^por supuesto[, ]*/i,
/^aquí tienes[, ]*/i,
/^con gusto[, ]*/i,
/^hola[, ]*/i,
/^perfecto[, ]*/i,
/^entendido[, ]*/i,
/^muy bien[, ]*/i,
/^ok[, ]*/i,
];
let cleaned = text.trim();
for (const p of patterns) {
cleaned = cleaned.replace(p, "").trim();
}
return cleaned;
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative">
{/* Botón siempre visible */}
<button
onClick={onClose}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"
>
</button>
<DialogHeader>
<DialogTitle>Asistente Inteligente</DialogTitle>
</DialogHeader>
<div className="flex-1 pt-4 min-h-0">
<div className="flex gap-6 h-full min-h-0">
{/* Left: vectors */}
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
<CardContent className="flex flex-col flex-1 p-4">
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
<ScrollArea className="flex-1">
{loadingVectors ? (
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
) : vectorStores.length === 0 ? (
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
) : (
<ul className="space-y-2">
{vectorStores.map((vector) => (
<li key={vector.id}
onClick={() => loadFilesForVector(vector.id)}
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
>
<strong className="truncate">{vector.name || vector.id}</strong>
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
</li>
))}
</ul>
)}
</ScrollArea>
<div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4>
{loadingFiles ? (
<p className="text-sm text-gray-500">Cargando archivos...</p>
) : selectedVectorFile ? (
<div className="text-sm text-gray-700 mb-2">
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
</div>
) : (
<p className="text-sm text-gray-500">Selecciona un archivo del vector</p>
)}
<ul className="space-y-2 max-h-40 overflow-auto mt-2">
{vectorFiles.map((file) => (
<li key={file.id}
onClick={() => handleSelectVectorFile(file)}
className={`p-2 rounded-lg cursor-pointer border ${selectedVectorFile?.id === file.id ? "bg-blue-50 border-blue-300" : "bg-white"}`}
>
<div className="text-sm font-medium">{file.filename}</div>
<div className="text-xs text-gray-400">{file.id}</div>
</li>
))}
</ul>
</div>
<div className="mt-4 flex-shrink-0">
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
</div>
</CardContent>
</Card>
{/* Right: Chat */}
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
<div className="flex-1 flex flex-col min-h-0">
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
{messages.length === 0 ? (
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
) : (
messages.map((m, i) => (
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
<ReactMarkdown>{m.content}</ReactMarkdown>
</div>
))
)}
{loading && (
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{attachedPreviews.length > 0 && (
<ul className="text-xs text-gray-600 mt-2">
{attachedPreviews.map((name, i) => (
<li key={i}>📄 {name}</li>
))}
</ul>
)}
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
<Paperclip className="w-5 h-5" />
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
</label>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Escribe tu pregunta..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
rows={1}
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
style={{ minHeight: "38px" }}
/>
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
</Button>
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
Aplicar mejora
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,371 +0,0 @@
import React, { useEffect, useState } from "react"
import { supabase } from "@/auth/supabase"
import type { PlanTextFields } from "../planes/academic-sections";
// 🔹 SIMULACIÓN DE ICONO LUCIDE-REACT
const Paperclip = (props) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-paperclip">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
);
// 🔹 SIMULACIÓN DE SHADCN/UI
const Dialog = ({ open, onOpenChange, children }) => open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
const DialogContent = ({ className, children }) => <div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`} onClick={(e) => e.stopPropagation()}>{children}</div>;
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
const Button = ({ onClick, disabled, className, variant, children }) => (
<button
onClick={onClick}
disabled={disabled}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
variant === "outline" ? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50" :
"bg-blue-600 text-white hover:bg-blue-700"
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
{children}
</button>
);
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
// ====================================================================
type AIChatModalProps = {
open: boolean
onClose: () => void
edgeFunctionUrl: string
context?: {
section?: string
fieldKey?: keyof PlanTextFields
originalText?: string
}
onAccept?: (newText: string) => void
}
export default function AIChatModal({ open, onClose, edgeFunctionUrl, context, onAccept }: AIChatModalProps) {
const [files, setFiles] = useState<any[]>([])
const [attachedFile, setAttachedFile] = useState<File | null>(null)
const [attachedPreview, setAttachedPreview] = useState<string | null>(null)
const [messages, setMessages] = useState<{ role: string; content: string }[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
// Referencia para desplazar al final del chat
const messagesEndRef = React.useRef<HTMLDivElement>(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
useEffect(scrollToBottom, [messages])
useEffect(() => {
if (!open) {
// 🧹 Limpia mensajes y archivos al cerrar
setMessages([])
setInput("")
setAttachedFile(null)
setAttachedPreview(null)
} else if (context) {
// 🧩 Muestra el contexto inicial al abrir
setMessages([
{
role: "system",
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`,
},
])
}
}, [open, context])
// 🔹 Obtener lista de archivos del Vector Store (Lógica de API original)
useEffect(() => {
if (!open) return
const fetchVectorFiles = async () => {
// Nota: La verificación de Supabase ahora pasa por el mock.
if (typeof supabase === 'undefined' || !supabase.auth) {
console.error("Supabase no está disponible (Simulación). Saltando fetch de archivos.");
return;
}
try {
setLoading(true)
const { data: { session } } = await supabase.auth.getSession()
const token = session?.access_token
// 🟢 TU LÓGICA DE FETCH ORIGINAL
const res = await fetch(`${edgeFunctionUrl}?action=list_files`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (data.files) setFiles(data.files)
else console.warn("No se encontraron archivos en el vector store.")
} catch (err) {
console.error("Error al cargar archivos del vector store:", err)
} finally {
setLoading(false)
}
}
fetchVectorFiles()
}, [open, edgeFunctionUrl])
// 📎 Adjuntar archivo
const handleAttach = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setAttachedFile(file)
setAttachedPreview(file.name)
}
// 🚀 Enviar prompt o archivo al Edge Function (Lógica de API original)
const handleSend = async () => {
if (!input.trim() && !attachedFile) return
// 🧩 Crear el mensaje visible del usuario
let userMessageContent = input.trim()
if (attachedFile) {
userMessageContent += (userMessageContent ? " | " : "") + `Adjunto: ${attachedFile.name}`
}
if (!userMessageContent && attachedFile) {
userMessageContent = `Consulta de archivo: ${attachedFile.name}`
}
setMessages((prev) => [...prev, { role: "user", content: userMessageContent }])
setInput("")
setLoading(true)
try {
if (typeof supabase === "undefined" || !supabase.functions) {
throw new Error("Supabase no está disponible o no soporta Edge Functions.")
}
const formData = new FormData()
// 🧠 Construimos un prompt limpio con el contexto del campo
const contextText = context?.originalText || "Sin texto original"
const section = context?.section ? `Sección: ${context.section}` : ""
const field = context?.fieldKey ? `Campo: ${context.fieldKey}` : ""
const fullPrompt = `
${section}
${field}
Texto original:
${contextText}
Solicitud del usuario:
${input}
Responde con una versión mejorada del texto, sin agregar frases como “Aquí tienes” ni explicaciones.
`.trim()
formData.append("prompt", fullPrompt)
if (attachedFile) formData.append("file", attachedFile)
// 🟢 Llamada a la Edge Function
const { data, error } = await supabase.functions.invoke("simple-chat", {
body: formData,
})
if (error) throw error
setMessages((prev) => [
...prev,
{ role: "assistant", content: data?.text || "Sin respuesta del modelo." },
])
} catch (err: any) {
console.error("Error al enviar prompt:", err)
setMessages((prev) => [
...prev,
{ role: "assistant", content: "Ocurrió un error al conectar con la API." },
])
} finally {
setLoading(false)
setAttachedFile(null)
setAttachedPreview(null)
}
}
return (
    <Dialog open={open} onOpenChange={onClose}>
      {/* DialogContent ya define el tamaño h-[85vh] y es flex-col */}
      <DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col">
       
{/* Encabezado fijo (flex-shrink-0) */}
        <DialogHeader>
          <DialogTitle className="text-lg font-semibold">Asistente Inteligente</DialogTitle>
        </DialogHeader>
{/* CONTENEDOR PRINCIPAL QUE AHORA GESTIONA EL SCROLL DEL CONTENIDO */}
{/* flex-1: toma el espacio restante. overflow-y-auto: permite scroll si el contenido se desborda */}
        <div className="flex-1 overflow-y-auto pt-4">
{/* Contenido que originalmente estaba justo debajo del header */}
<div className="flex gap-6 min-h-full">
          {/* 📂 Archivos del Vector Store */}
          <Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
            {/* Se mantiene flex-1 en CardContent para el ScrollArea */}
            <CardContent className="flex flex-col flex-1 p-4">
              <h3 className="font-semibold text-sm mb-3">Archivos del Vector Store</h3>
              {/* ScrollArea toma el espacio disponible (flex-1) */}
              <ScrollArea className="flex-1">
                {files.length === 0 ? (
                  <p className="text-gray-500 text-sm text-center mt-10">
                    {loading ? "Cargando archivos..." : "No hay archivos cargados."}
                  </p>
                ) : (
                  <ul className="space-y-2">
                    {files.map((file) => (
                      <li
                        key={file.id}
                        className="border border-gray-200 bg-white rounded-lg p-2 text-sm shadow-sm hover:shadow-lg transition-shadow cursor-pointer"
                      >
                        <strong className="block text-gray-700 truncate">{file.name}</strong>
                        <p className="text-xs text-gray-400 truncate">{file.path}</p>
                      </li>
                    ))}
                  </ul>
                )}
              </ScrollArea>
              <div className="mt-4 flex-shrink-0">
                <Button variant="outline" className="w-full">
                  Subir archivo
                </Button>
              </div>
            </CardContent>
          </Card>
          {/* 💬 Chat con GPT */}
          <Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
            <CardContent className="flex flex-col flex-1 p-4">
              <h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
              {/* Mensajes - EL CONTENEDOR QUE DEBE HACER SCROLL */}
               <div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
                {messages.length === 0 ? (
                  <p className="text-gray-400 text-sm text-center mt-10">
                    Inicia una conversación...
                  </p>
                ) : (
                  messages.map((m, i) => (
                    <div
                      key={i}
                      className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${
                        m.role === "user"
                          ? "bg-blue-50 text-blue-800 ml-auto"
                          : "bg-white text-gray-800 mr-auto border border-gray-200"
                      }`}
                    >
                      <strong className="font-bold">{m.role === "user" ? "Tú:" : "IA:"}</strong>{" "}
                      {m.content}
                    </div>
                  ))
                )}
                {loading && (
                  <div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
                    <svg
                      className="animate-spin h-4 w-4 text-blue-500"
                      xmlns="http://www.w3.org/2000/svg"
                      fill="none"
                      viewBox="0 0 24 24"
                    >
                      <circle
                        className="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        strokeWidth="4"
                      ></circle>
                      <path
                        className="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2
                        5.291A7.962 7.962 0 014 12H0c0
                        3.042 1.135 5.824 3 7.938l3-2.647z"
                      ></path>
                    </svg>
                    <span className="text-sm text-gray-600">La IA está respondiendo...</span>
                  </div>
                )}
                <div ref={messagesEndRef} />
              </div>
              {/* Archivo adjunto - flex-shrink-0 */}
              {attachedPreview && (
                <div className="flex items-center justify-between mt-2 p-3 border border-gray-300 rounded-xl text-sm bg-gray-100 shadow-inner flex-shrink-0">
                  <span className="truncate flex items-center gap-2 text-gray-700">
                    <Paperclip className="w-4 h-4 text-blue-500" />
                    {attachedPreview}
                  </span>
                  <Button
                    variant="ghost"
                    size="sm"
                    className="text-red-500 hover:bg-gray-200"
                    onClick={() => {
                      setAttachedFile(null)
                      setAttachedPreview(null)
                      setInput("")
                    }}
                  >
                    Quitar
                  </Button>
                </div>
              )}
              {/* Entrada de texto y enviar - flex-shrink-0 */}
              <div className="flex gap-2 mt-4 items-end flex-shrink-0">
                <label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
                  <Paperclip className="w-5 h-5" />
                  <input type="file" className="hidden" onChange={handleAttach} />
                </label>
                <textarea
                  value={input}
                  onChange={(e) => setInput(e.target.value)}
                  placeholder="Escribe tu pregunta..."
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && !e.shiftKey) {
                      e.preventDefault()
                      handleSend()
                    }
                  }}
                  rows={1}
                  className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
                  style={{ minHeight: "38px" }}
                />
                <Button
                  onClick={handleSend}
                  disabled={loading || (!input.trim() && !attachedFile)}
                  className="shadow-md"
                >
                  {loading ? "Enviando..." : "Enviar"}
                </Button>
<Button
onClick={() => {
const lastMessage = messages[messages.length - 1]
if (onAccept && lastMessage?.role === "assistant") {
onAccept(lastMessage.content)
onClose()
}
}}
disabled={!messages.some((m) => m.role === "assistant")}
className="shadow-md"
>
Aplicar mejora
</Button>
              </div>
            </CardContent>
          </Card>
        </div>
</div>
      </DialogContent>
    </Dialog>
  )
}

View File

@@ -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: {
carrera_id: carreraId,
prompt_usuario: prompt,
insert: true,
archivos_a_usar: [],
},
}
);
if (error) throw error;
const res = data;
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>
) );
} }

View File

@@ -1,427 +1,145 @@
import { jsPDF } from "jspdf" import { supabase } from "@/auth/supabase";
import { Button } from "../ui/button" import { Button } from "../ui/button";
import { Download } from "lucide-react" import { Download } from "lucide-react";
// Importamos 'react' para poder usar el hook de estado si fuera necesario.
/** export type PlanLike = Record<
* Tipo mínimo para el plan. Hemos añadido 'number' a la unión string,
* para permitir propiedades como 'total_creditos' que son numéricas, string | number | object | null | undefined
* lo cual resuelve el error de asignación con PlanFull. >;
*/
export type PlanLike = Record<string, string | number | object | null | undefined> // CORREGIDO: Se agregó 'object'
// Usamos el tipo corregido PlanLike en la prop 'plan' export function DownloadPlanPDF({ plan }: { plan: Record<string, any> }) {
export function DownloadPlanPDF({ plan }: { plan: PlanLike }) { async function fetchPDF() {
// console.log(plan) // Mantener el log para debug const planObj = {
...plan,
nivel_y_nombre_del_plan_de_estudios: `${plan["nivel"]} en ${plan["nombre"]}`,
nivel: undefined,
nombre: undefined,
};
const fileName = `Plan_${planObj.nivel_y_nombre_del_plan_de_estudios || "Desconocido"}.pdf`;
// const jsonData = JSON.stringify(planObj);
function generatePDF() { const triggerDownload = (blob: Blob, name: string) => {
// Inicialización del documento const url = URL.createObjectURL(blob);
const doc = new jsPDF({ const a = document.createElement("a");
orientation: "portrait", a.href = url;
unit: "mm", a.setAttribute("download", name);
format: "letter", document.body.appendChild(a);
}) a.click();
a.remove();
URL.revokeObjectURL(url);
};
const pageWidth = doc.internal.pageSize.getWidth() const fetchBinaryFallback = async () => {
const pageHeight = doc.internal.pageSize.getHeight() // Intenta construir la URL del runtime de Functions
const margin = 20 const anyClient = supabase as any;
const maxWidth = pageWidth - margin * 2 const baseUrl =
anyClient?.functions?.url ||
// Parámetros de estilo institucional (basados en las capturas) `${(anyClient?.supabaseUrl || "").replace(/\/$/, "")}/functions/v1`;
const lineHeight = 5.0 // mm por línea (ajustado para más texto por página) const { data: sess } = await supabase.auth.getSession();
const sectionGap = 10 // Espacio entre recuadros de sección const token = sess?.session?.access_token;
const bodyFontSize = 10.5
const headingFontSize = 12
const subHeadingFontSize = 10
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
const bulletIndent = 6 // Sangría para el texto de la lista
let cursorY = margin console.log(JSON.stringify(planObj, null, 2));
console.log(planObj);
// Variable para controlar si ya se dibujaron todas las secciones (para la última caja)
let totalSections = 0;
let drawnSections = 0;
// --- Utilidades de Dibujo --- const resp = await fetch(`${baseUrl}/carbone-io-api`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/pdf",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
action: "downloadReport",
templateId: "1302213091201757023",
fileName,
convertTo: "pdf",
data: planObj,
}),
});
// Dibuja el encabezado ("Anexo 1") y pie de página (Numeración) en todas las páginas if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const drawHeaderAndFooter = () => { const blob = await resp.blob();
// FIX: Usamos (doc as any) para acceder a getNumberOfPages() y evitar el error de TS triggerDownload(blob, fileName);
const pageCount = (doc as any).internal.getNumberOfPages() };
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i)
// Encabezado (Anexo 1)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
doc.text("Anexo 1", pageWidth - margin, margin - 5, { align: "right" })
// Pie de página (Numeración) try {
// Usamos el mismo tamaño y posición que en el ejemplo // const { data, error } = await supabase.functions.invoke(
doc.setFontSize(8) // "carbone-io-api",
doc.text( // {
`Página ${i} de ${pageCount}`, // method: "POST",
pageWidth - margin, // Posicionado a la derecha // headers: { Accept: "application/octet-stream" }, // preferir binario
pageHeight - 10, // body: {
{ align: "right" } // action: "downloadReport",
) // templateId: "1302213091201757023",
} // fileName,
// Regresar al último estado de la página para continuar dibujando // convertTo: "pdf",
doc.setPage(pageCount) // data: planObj,
} // },
// }
// );
// Verifica si se necesita una nueva página antes de dibujar una línea o un elemento. // if (error) throw error;
const addPageIfNeeded = (neededHeight: number = lineHeight) => {
// Aseguramos que haya espacio para la altura necesaria + un poco de margen de seguridad
// El margen de seguridad ayuda a que la línea de pie de página no se solape
if (cursorY + neededHeight > pageHeight - 15) {
doc.addPage()
cursorY = margin
// El encabezado "Anexo 1" se dibuja al final en drawHeaderAndFooter()
}
}
/** // // Si ya viene binario, descargar directo
* Dibuja un título de sección con el estilo de recuadro gris (como en las capturas). // if (typeof Blob !== "undefined" && data instanceof Blob) {
* Retorna la altura ocupada por el recuadro para el cálculo de la altura total de la sección. // triggerDownload(data, fileName);
*/ // return;
const drawHeadingBox = (text: string, marginTop: number = 0): number => { // }
doc.setFont("helvetica", "bold") // if (data instanceof ArrayBuffer) {
doc.setFontSize(headingFontSize) // triggerDownload(
// new Blob([data], { type: "application/pdf" }),
// Espacio antes del título // fileName
cursorY += marginTop // );
// return;
const titleLines = doc.splitTextToSize(text.toUpperCase(), maxWidth - 4) // Pequeño padding // }
const titleHeight = titleLines.length * lineHeight + 4 // Texto + padding vertical
// 1. Verificar si el recuadro cabe en la página // // Si vino como string (ej. empieza con %PDF), usa el fallback binario
addPageIfNeeded(titleHeight + 5) // 5mm de margen de seguridad // if (typeof data === "string") {
// await fetchBinaryFallback();
// 2. Dibujar Recuadro Gris (Relleno) // return;
doc.setFillColor(230, 230, 230) // Gris claro // }
doc.rect(margin, cursorY, maxWidth, titleHeight, "F")
// 3. Dibujar texto centrado
const textX = pageWidth / 2
const textY = cursorY + titleHeight / 2 + 0.8 // 0.8mm para centrado óptico
doc.text(titleLines, textX, textY, { align: "center" })
cursorY += titleHeight // Avanzar el cursor justo después del recuadro // // Si vino JSON con base64, decodificar y descargar
return titleHeight // if (data && typeof data === "object") {
} // const b64 =
// (data as any).file || (data as any).buffer || (data as any).base64;
// if (typeof b64 === "string") {
// const clean = b64.replace(/^data:.*;base64,/, "");
// const binary = atob(clean);
// const bytes = new Uint8Array(binary.length);
// for (let i = 0; i < binary.length; i++)
// bytes[i] = binary.charCodeAt(i);
// triggerDownload(
// new Blob([bytes], { type: "application/pdf" }),
// fileName
// );
// return;
// }
// }
/** // console.warn("Respuesta no reconocida para descarga de PDF.", {
* Dibuja un bloque de texto (párrafo o lista) manejando el salto de página línea por línea, // type: typeof data,
* y envuelto en un recuadro. // });
*/
const drawContentBox = (text?: string | null, isList: boolean = false, isLastSection: boolean = false) => {
// Manejamos 'text' que ahora puede ser string o number
const content = (text !== null && text !== undefined) ? String(text).trim() : "Sin información."
doc.setFont("helvetica", "normal")
doc.setFontSize(bodyFontSize)
let initialY = cursorY // Guardar Y inicial para dibujar el recuadro final
// El contenido se dibuja en un recuadro. Dejamos un padding interno. await fetchBinaryFallback();
const innerMargin = margin + 2 return;
const innerMaxWidth = maxWidth - 4 } catch (error) {
let currentContentY = cursorY + 2 // Iniciar con 2mm de padding superior console.error("Error al obtener PDF:", error);
// Dividir el contenido en bloques (párrafos o ítems de lista)
const blocks = isList ?
content.split('\n').filter(line => line.trim().length > 0) :
content.split('\n').filter(line => line.trim().length > 0)
let contentDrawn = false
for (const block of blocks) {
let cleanBlock = block.trim()
// Si es lista, limpiamos los posibles marcadores (1., *, -)
if (isList) {
cleanBlock = cleanBlock.replace(/^(\d+\.|\*|[\-\•]|\u27A2|\u21D2)\s*/, '').trim()
}
if (!cleanBlock) continue
// Líneas que componen el bloque actual
const textWidth = isList ? innerMaxWidth - bulletIndent : innerMaxWidth
const lines = doc.splitTextToSize(cleanBlock, textWidth)
for (let i = 0; i < lines.length; i++) {
// 1. Verificar si se necesita un salto de página ANTES de dibujar la línea
if (currentContentY + lineHeight > pageHeight - 15) {
// Cierra el recuadro en la página actual
doc.rect(margin, initialY, maxWidth, pageHeight - 15 - initialY)
doc.addPage()
// En la nueva página, el punto de inicio del recuadro es el margen superior
initialY = margin
currentContentY = margin + 2 // Iniciar con padding
cursorY = margin // El cursorY global se actualiza para la siguiente sección/línea
}
const currentLine = lines[i]
if (isList && i === 0) {
// Dibujar el glifo solo en la primera línea del ítem
doc.text(bulletGlifo, innerMargin, currentContentY)
doc.text(currentLine, innerMargin + bulletIndent, currentContentY)
} else if (isList && i > 0) {
// Dibujar líneas subsiguientes con sangría (sin glifo)
doc.text(currentLine, innerMargin + bulletIndent, currentContentY)
} else {
// Dibujar párrafo normal
doc.text(currentLine, innerMargin, currentContentY)
}
currentContentY += lineHeight // Avanzar el cursor de contenido
}
// Espacio entre ítems de lista o entre párrafos
currentContentY += isList ? 1.5 : 4
contentDrawn = true
}
// 2. Después de dibujar todo el contenido, dibujar el recuadro exterior
if (contentDrawn) {
let finalY = currentContentY - 2 // Ajuste final de padding y espacio
// FIX: Usamos (doc as any) para acceder a los métodos internos y evitar el error de TS
if (isLastSection &&
(doc as any).internal.getCurrentPageInfo().pageNumber === (doc as any).internal.getNumberOfPages()) {
// Si es la ÚLTIMA sección Y estamos en la ÚLTIMA página,
// forzamos el recuadro a ir hasta el final (pageHeight - 15)
finalY = pageHeight - 15;
}
// Dibujar el recuadro completo (desde el Y inicial guardado hasta el Y final)
doc.rect(margin, initialY, maxWidth, finalY - initialY)
cursorY = finalY + sectionGap // Actualizar el cursor global para la siguiente sección
} else {
// Si no se dibuja contenido, solo saltar la altura del recuadro vacío.
doc.rect(margin, initialY, maxWidth, 10) // Dibuja una caja vacía de 10mm
cursorY += 10 + sectionGap
}
}
// --- Portada (Estilo Institucional) ---
const drawTitlePage = () => {
cursorY = 40 // Empezar más abajo
// UNIVERSIDAD LA SALLE
doc.setFont("helvetica", "bold")
doc.setFontSize(14)
doc.text("UNIVERSIDAD LA SALLE", pageWidth / 2, cursorY, { align: "center" })
cursorY += 5
// Separador horizontal
doc.line(margin, cursorY, pageWidth - margin, cursorY)
cursorY += 15
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
doc.setFontSize(18)
// Manejamos la conversión a string si es necesario
const mainTitle = (plan["titulo"] !== null && plan["titulo"] !== undefined ? String(plan["titulo"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
cursorY += mainTitleLines.length * 8
// Nivel y Nombre del Plan de Estudios
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
doc.text("Nivel y Nombre del Plan de Estudios", pageWidth / 2, cursorY, { align: "center" })
cursorY += 5
// Separador horizontal
doc.line(margin, cursorY, pageWidth - margin, cursorY)
cursorY += 10
// Escolar / Presencial (Modalidad Educativa)
doc.setFont("helvetica", "bold")
doc.setFontSize(14)
doc.text("Escolar / Presencial", pageWidth / 2, cursorY, { align: "center" })
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
cursorY += 5
doc.text("Modalidad Educativa", pageWidth / 2, cursorY, { align: "center" })
cursorY += 15
// Recuadros de Vigencia, Antecedente y Área (Simulación del Layout)
// Recuadro Vigencia (Parte superior central)
const boxWidth = maxWidth * 0.5
const boxX = (pageWidth - boxWidth) / 2
const boxY = cursorY
doc.rect(boxX, boxY, boxWidth, 20)
doc.rect(boxX, boxY + 15, boxWidth, 5)
doc.setFontSize(10)
doc.text("Vigencia", boxX + boxWidth / 2, boxY + 18, { align: "center" })
cursorY += 30 // Espacio para el primer recuadro
// Recuadro Antecedente Académico (Izquierda)
const smallBoxWidth = maxWidth * 0.4
const smallBoxHeight = 35
const smallBoxX1 = margin
doc.rect(smallBoxX1, cursorY, smallBoxWidth, smallBoxHeight)
doc.rect(smallBoxX1, cursorY + smallBoxHeight - 5, smallBoxWidth, 5)
doc.setFontSize(10)
doc.text("Bachillerato", smallBoxX1 + smallBoxWidth / 2, cursorY + smallBoxHeight / 2, { align: "center" })
doc.text("Antecedente Académico", smallBoxX1 + smallBoxWidth / 2, cursorY + smallBoxHeight - 2, { align: "center" })
// Recuadro Área de Estudio (Derecha)
const smallBoxX2 = pageWidth - margin - smallBoxWidth
doc.rect(smallBoxX2, cursorY, smallBoxWidth, smallBoxHeight)
doc.rect(smallBoxX2, cursorY + smallBoxHeight - 5, smallBoxWidth, 5)
doc.setFontSize(10)
doc.text("Ingeniería, manufactura y construcción", smallBoxX2 + smallBoxWidth / 2, cursorY + smallBoxHeight / 2, { align: "center" })
doc.text("Área de Estudio", smallBoxX2 + smallBoxWidth / 2, cursorY + smallBoxHeight - 2, { align: "center" })
cursorY += smallBoxHeight + 10
// Datos Fijos (Abajo)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
const drawDataPair = (label: string, value: string) => {
const labelX = margin
const valueX = margin + maxWidth * 0.4
doc.text(label + ":", labelX, cursorY)
doc.setFont("helvetica", "bold")
doc.text(value, valueX, cursorY)
doc.setFont("helvetica", "normal")
cursorY += 5
}
drawDataPair("Clave del Plan de Estudios", "2020")
drawDataPair("Diseño Curricular", "Rígido")
// Usamos plan.total_ciclos si existe
drawDataPair("Total de Ciclos del Plan de Estudios", plan["total_ciclos"] ? String(plan["total_ciclos"]) : "9")
drawDataPair("Duración del Ciclo Escolar", "16 semanas")
drawDataPair("Carga Horaria a la Semana", "27")
// Pie de página institucional (simulado)
doc.text(
"Dirección de Asuntos Académicos - Anexo 1",
pageWidth / 2,
pageHeight - margin,
{ align: "center" }
)
}
// --- Ejecución Principal ---
// 1. Dibuja la portada
drawTitlePage()
// 2. Comienza el contenido del plan en la nueva página
doc.addPage()
cursorY = margin
// Las secciones se ajustan a las que generas, pero también a las adicionales del documento de referencia
const SECTIONS: Array<{ key: string; title: string; isList: boolean }> = [
{ key: "objetivo_general", title: "Objetivo General", isList: false },
// La sección FIN DE APRENDIZAJE O FORMACIÓN es el Objetivo General del documento institucional, la mapearemos aquí.
{ key: "fin_aprendizaje", title: "FIN DE APRENDIZAJE O FORMACIÓN", isList: false }, // Mapea al objetivo general
{ key: "perfil_ingreso", title: "PERFIL DE INGRESO", isList: true },
{ key: "perfil_egreso", title: "PERFIL DE EGRESO", isList: true },
{ key: "competencias_genericas", title: "COMPETENCIAS GENÉRICAS", isList: true },
{ key: "competencias_especificas", title: "COMPETENCIAS ESPECÍFICAS", isList: true },
{ key: "indicadores_desempeno", title: "INDICADORES DE DESEMPEÑO", isList: true },
{ key: "sistema_evaluacion", title: "SISTEMA DE EVALUACIÓN", isList: false },
{ key: "pertinencia", title: "PERTINENCIA", isList: false },
// Nuevas secciones basadas en las imágenes que suelen ir con "No aplica"
{ key: "administracion", title: "ADMINISTRACIÓN Y OPERATIVIDAD DEL PLAN DE ESTUDIOS", isList: false },
{ key: "sustento_teorico", title: "SUSTENTO TEÓRICO DEL MODELO CURRICULAR", isList: false },
{ key: "justificacion_curricular", title: "JUSTIFICACIÓN DE LA PROPUESTA CURRICULAR EN LA MODALIDAD NO ESCOLARIZADA O MIXTA", isList: false },
{ key: "programa_investigacion", title: "PROGRAMA DE INVESTIGACIÓN", isList: false },
{ key: "curso_propedeutico", title: "CURSO PROPEDÉUTICO", isList: false },
{ key: "propuesta_evaluacion", title: "PROPUESTA DE EVALUACIÓN PERIÓDICA DEL PLAN DE ESTUDIOS", isList: false },
]
// Contar el número total de secciones con contenido
totalSections = SECTIONS.length;
for (const s of SECTIONS) {
drawnSections++; // Incrementar contador de secciones dibujadas
// Obtenemos el valor (que puede ser string, number, object, null, o undefined)
let value = plan[s.key]
// Mapeo especial para el objetivo general institucional (si existe)
if (s.key === "fin_aprendizaje" && (value === null || value === undefined)) {
value = plan["objetivo_general"]
}
// Inicializar content como string para pasarlo a la función de dibujo
let content: string | null = null;
// Si el valor no es nulo/undefined, convertir a string
if (value !== null && value !== undefined) {
// Si es un objeto, lo ignoramos y usamos un string vacío.
// Esto es clave para 'carreras', que no tiene un formato textual.
if (typeof value === 'object' && !Array.isArray(value)) {
content = "";
} else {
content = String(value);
}
}
// Si el contenido es nulo o vacío, usamos un placeholder común en el documento institucional
if (!content || content.trim() === "") {
// Para las secciones del plan generado, si no hay contenido, usar "Sin información."
if (["objetivo_general", "perfil_ingreso", "perfil_egreso", "competencias_genericas", "competencias_especificas", "indicadores_desempeno", "sistema_evaluacion", "pertinencia"].includes(s.key)) {
content = "Sin información."
} else {
// Para las secciones auxiliares del formato institucional
if (s.key === "administracion" || s.key === "sustento_teorico" || s.key === "justificacion_curricular" || s.key === "programa_investigacion") {
content = "No aplica"
} else if (s.key === "curso_propedeutico") {
content = "No tiene"
} else if (s.key === "propuesta_evaluacion") {
// Texto de la Propuesta de Evaluación (última página)
content = "La Universidad La Salle aplica una metodología para la evaluación y modificación de los programas académicos de licenciatura o posgrado que imparte. Los principales niveles, estudios, acciones y plazos que comprende dicha metodología son los siguientes:\n\nNIVEL DE EVALUACIÓN CURRICULAR INTERNA: DIAGNÓSTICO DE ESTRUCTURA Y OPERACIÓN.\n1. Análisis técnico-pedagógico del planteamiento curricular vigente.\n2. Estudio con directivos del área académica correspondiente, para analizar y valorar las problemáticas en la estructura y gestión del programa académico durante el periodo en que se ha desarrollado.\n3. Consulta a profesores sobre: a) problemáticas percibidas en la formación académica, profesional y actitudinal de los estudiantes, b) problemáticas en la operación, c) necesidades sociales, avances disciplinarios y/o tecnológicos detectados en su propio ejercicio profesional, que consideran importante incluir en el planteamiento curricular.\n4. Estudio de opinión de estudiantes sobre las problemáticas que aprecian en la formación que reciben respecto a la operación y estructura del programa académico.\n\nNIVEL DE EVALUACIÓN CURRICULAR EXTERNA: DIAGNÓSTICO DE IMPACTO Y PRÁCTICAS PROFESIONALES.\n5. Estudio sobre el estado del conocimiento en que se encuentran el o los campos disciplinarios vinculados con el programa académico, en México y, de ser posible, en otros países.\n6. Análisis de la oferta y la evolución que, en términos estadísticos, han tenido programas académicos similares en el ámbito de influencia y/o en el país.\n7. Estudio sobre requerimientos y tendencias en la formación, a partir del análisis de criterios, perfiles, estándares y parámetros de organismos evaluadores o acreditadores de programas académicos (si existen para el campo profesional), así como de la comparación general del programa en evaluación con otros similares y prestigiosos, de IES nacionales y, de ser posible, extranjeras.\n8. Estudio con egresados del programa académico para conocer su opinión sobre: a) el mismo programa; b) formación recibida; c) sitios de inserción laboral y características de sus prácticas profesionales, y d) aspectos disciplinarios, tecnológicos y/o actitudinales que, a la luz de su experiencia, consideren necesario incluir como parte de la formación.\n9. Estudio con empleadores para conocer su valoración sobre las prácticas profesionales de los egresados del programa académico, y su apreciación sobre nuevos requerimientos en el campo."
} else {
continue; // Si sigue siendo nulo, saltar la sección
}
}
}
// Determinar si es la última sección que se dibujará
const isLastSection = drawnSections === totalSections;
// Dibuja el recuadro del título de la sección
drawHeadingBox(s.title, sectionGap)
// Dibuja el contenido de la sección dentro de su recuadro.
// Pasamos isLastSection para que drawContentBox sepa si debe forzar el cierre.
drawContentBox(content, s.isList, isLastSection)
}
// Finalizar y dibujar encabezados/pies en todas las páginas (se dibuja en el paso final)
drawHeaderAndFooter()
// Guardar el documento
const name = (plan["prompt"] ? `Plan_${plan["prompt"]}` : `Plan_de_estudios`).replace(/\s+/g, "_")
doc.save(`${name}_Institucional.pdf`)
} }
}
return (
<Button variant="outline" className="flex items-center gap-2 " onClick={generatePDF}> return (
Descargar PDF <Button
<Download className="w-4 h-4" /> variant="outline"
className="flex items-center gap-2"
onClick={() => void fetchPDF()}
>
Descargar PDF
<Download className="w-4 h-4" />
</Button> </Button>
);
)
} }
export default DownloadPlanPDF export default DownloadPlanPDF;

View File

@@ -1,15 +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 {
import AIChatModal from "../ai/AIChatModal" 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 Query keys & fetcher
@@ -17,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) =>
@@ -51,279 +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
open={openModalIa}
onClose={() => setopenModalIa(false)}
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
context={{
section: iaContext?.title,
fieldKey: iaContext?.key,
originalText: iaContext?.content,
}}
onAccept={(newText: string) => {
if (iaContext) {
updateField.mutate({ key: iaContext.key, value: newText })
setIaContext(null)
}
}}
/>
<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);
}
}}
/>
</> </>
) );
} }

View File

@@ -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
}, },

View File

@@ -1,3 +1,4 @@
// dummy test
import { StrictMode } from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router' import { RouterProvider, createRouter } from '@tanstack/react-router'

View File

@@ -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')

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
{ {
"include": ["**/*.ts", "**/*.tsx"], "include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"jsx": "react-jsx", "jsx": "react-jsx",