10 Commits

17 changed files with 1084 additions and 695 deletions

View File

@@ -1,4 +0,0 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
#VITE_BACK_ORIGIN=http://localhost:3001
VITE_BACK_ORIGIN=http://localhost:3001

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ count.txt
.env*
.nitro
.tanstack
.cta.json

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase";
import ReactMarkdown from "react-markdown"
import ReactMarkdown from "react-markdown";
/* ---------- UI Mocks (sin cambios) ---------- */
const Paperclip = (props) => (
@@ -33,153 +33,113 @@ const CardContent = ({ className, children }) => <div className={`p-4 ${classNam
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
/* ------------- COMPONENT ------------- */
export default function AIChatModal({ open, onClose, context, onAccept }) {
export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) {
const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVector, setSelectedVector] = useState(null);
const [selectedFiles, setSelectedFiles] = useState([]);
const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreviews, setAttachedPreviews] = useState([]);
// chat
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
// loading states
const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [loadingVectors, setLoadingVectors] = useState(false);
// conversation control
const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
const [creatingConversation, setCreatingConversation] = useState(false);
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;
if (!resp) return null;
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;
};
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);
setSelectedFiles([]);
setAttachedFiles([]);
setAttachedPreviews([]);
setConversationId(null);
setSelectedVector(null);
setVectorFiles([]);
return;
}
// inyectar contexto como system message
if (context) {
setMessages([
{
role: "system",
//content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
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) ----------
// ---------- CREATE CONVERSATION ----------
const createConversation = async () => {
try {
setCreatingConversation(true);
try {
setCreatingConversation(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const resp = await supabase.functions.invoke("conversation-format", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
});
// llamada
const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "start" , role:"system", content:context.cont_conversation, }
});
let parsed = null;
if (typeof resp?.data === "string") {
try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
} else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
else parsed = resp;
console.log("createConversation -> raw resp:", resp);
const convId =
parsed?.conversationId ||
parsed?.data?.conversationId ||
parsed?.data?.id ||
parsed?.id ||
parsed?.conversation_id ||
parsed?.data?.conversation_id;
// 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);
if (!convId) { setCreatingConversation(false); return; }
setConversationId(convId);
} catch (err) {
console.error("Error creando conversación:", err);
} finally {
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) ----------
// ---------- DELETE CONVERSATION ----------
const deleteConversation = async (convIdParam) => {
try {
const convIdToUse = convIdParam ?? conversationId;
@@ -187,13 +147,11 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
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", {
await supabase.functions.invoke("conversation-format", {
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);
@@ -209,11 +167,13 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
reader.readAsDataURL(file);
});
// ---------- SEND MESSAGE (usa conversationId) ----------
// ---------- HANDLE CONVERSATION (envío) ----------
const handleConversation = async ({ text }) => {
let contextText = "";
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
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;
}
@@ -222,44 +182,48 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// archivos adjuntos (locales) -> base64
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
});
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}`
});
}
}
// archivos seleccionados del vector (por id)
if (selectedFiles.length > 0) {
const filesFromVectors = selectedFiles.map(f => ({
type: "input_file",
file_id: f.id
}));
filesInput = [...filesInput, ...filesFromVectors];
}
const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`;
const payload = {
action: "message",
format: plan_format,
conversationId,
vectorStoreId: selectedVectorFile?.vector_store_id ?? null,
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
vectorStoreId: selectedVector ?? null,
fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
input: [
{
role: "user",
content: [
{ type: "input_text", text },
{ type: "input_text", text: promptFinal },
...filesInput
]
}
]
};
const { data: invokeData, error } = await supabase.functions.invoke(
"modal-conversation",
"conversation-format",
{
headers: { Authorization: `Bearer ${token}` },
body: payload
@@ -267,40 +231,24 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
);
if (error) throw error;
console.log("handleConversation -> RAW invokeData:", invokeData);
const parsed = normalizeInvokeResponse({ data: invokeData });
console.log("handleConversation -> PARSED:", parsed);
// 🔥 EXTRACTOR DEFINITIVO
// Extraer texto del assistant (robusto)
let assistantText = null;
// 1) directo
if (parsed?.data?.output_text) {
assistantText = parsed.data.output_text;
}
// 2) buscar el message
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
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;
}
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) }
]);
setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]);
// limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos)
setAttachedFiles([]);
setAttachedPreviews([]);
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
setSelectedFiles([]);
} catch (err) {
console.error("Error en handleConversation:", err);
@@ -358,32 +306,52 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
};
// ---------- UI helpers ----------
const handleAttach = (e) => {
const files = Array.from(e.target.files);
if (!files.length) return;
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)]);
};
setAttachedFiles(prev => [...prev, ...files]);
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
};
// Al hacer click en un vector: expandir (solo uno a la vez) y cargar sus archivos
const handleVectorClick = async (vector) => {
if (selectedVector === vector.id) {
// colapsar
setSelectedVector(null);
setVectorFiles([]);
setSelectedFiles([]);
return;
}
setSelectedVector(vector.id);
setSelectedFiles([]);
await loadFilesForVector(vector.id);
};
const handleSelectVectorFile = (file) => {
setSelectedVectorFile(file);
// Toggle selección de archivo (checkbox)
const toggleFileSelection = (file) => {
if (selectedFiles.some(f => f.id === file.id)) {
setSelectedFiles(prev => prev.filter(f => f.id !== file.id));
} else {
setSelectedFiles(prev => [...prev, file]);
}
};
const removeSelectedFile = (fileId) => {
setSelectedFiles(prev => prev.filter(f => f.id !== fileId));
};
// ---------- Send flow ----------
const handleSend = async () => {
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
// no permitir enviar si no hay nada
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) 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
// no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
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." }]);
@@ -391,151 +359,225 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
}
}
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
const userText = input.trim() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
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) => {
function cleanAIResponse(text) {
if (!text) return text;
let cleaned = text;
// -------------------------
// 1. Eliminar emojis
// -------------------------
cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, "");
// -------------------------
// 2. Eliminar separadores tipo ---
// -------------------------
cleaned = cleaned.replace(/^---+$/gm, "");
// -------------------------
// 3. Eliminar saludos y frases meta
// -------------------------
const metaPatterns = [
/^hola[!¡., ]*/i,
/^buen(os|as) (días|tardes|noches)[!¡., ]*/i,
/estoy aquí para ayudarte[.! ]*/gi,
/aquí tienes[,:]*/gi,
/claro[,:]*/gi,
/como pediste[,:]*/gi,
/como solicitaste[,:]*/gi,
/el texto íntegro que compartiste.*$/gi,
/te lo dejo a continuación.*$/gi,
/¿te gustaría.*$/gi,
/¿en qué más puedo.*$/gi,
/si necesitas algo más.*$/gi,
/con gusto.*$/gi,
];
metaPatterns.forEach(p => {
cleaned = cleaned.replace(p, "").trim();
});
// -------------------------
// 4. Extraer solo contenido útil
// -------------------------
const startMarker = "CONTEXTO DEL CAMPO";
const startIndex = cleaned.indexOf(startMarker);
if (startIndex !== -1) {
cleaned = cleaned.substring(startIndex).trim();
}
// -------------------------
// 5. Eliminar líneas vacías múltiples
// -------------------------
cleaned = cleaned.replace(/\n{2,}/g, "\n\n");
// -------------------------
// 6. Quitar numeraciones de cortesía (opcional)
// Ejemplo: “1. ” al inicio de líneas
// -------------------------
cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, "");
return cleaned.trim();
}
const handleApply = () => {
const last = [...messages].reverse().find(m => m.role === "assistant");
if (last && onAccept) {
const cleaned = cleanAIResponse(last.content);
onAccept(cleaned);
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,
];
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();
}
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>
<Dialog open={open} onOpenChange={onClose} >
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative"
>
<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>
<h3 className="font-semibold text-sm mb-3">Repositorio de archivos</h3>
<ScrollArea className="flex-1">
{loadingVectors ? (
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
<p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
) : vectorStores.length === 0 ? (
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
<p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
) : (
<ul className="space-y-2">
<div className="space-y-3">
{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>
<div key={vector.id}>
{/* VECTOR */}
<div
onClick={() => handleVectorClick(vector)}
className={`p-3 rounded-lg border cursor-pointer transition flex items-center justify-between
${selectedVector === vector.id ? "bg-blue-50 border-blue-400 shadow" : "bg-white border-gray-300"}`}
>
<div className="truncate">
<strong className="block truncate">{vector.name || vector.id}</strong>
<p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
</div>
<div className="text-xs text-gray-500">{selectedVector === vector.id ? "▼" : "▶"}</div>
</div>
{/* ARCHIVOS cuando está expandido */}
{selectedVector === vector.id && (
<div className="ml-4 mt-2 mb-2 space-y-2">
{loadingFiles ? (
<p className="text-gray-400 text-sm">Cargando archivos...</p>
) : vectorFiles.length === 0 ? (
<p className="text-gray-400 text-sm">No hay archivos en este repositorio</p>
) : (
vectorFiles.map((file) => (
<label key={file.id} className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selectedFiles.some(f => f.id === file.id)}
onChange={() => toggleFileSelection(file)}
/>
<div className="text-sm">
<div className="font-medium">{file.filename ?? file.name ?? file.id}</div>
<div className="text-xs text-gray-400">{file.id}</div>
</div>
</label>
))
)}
</div>
)}
</div>
))}
</div>
)}
</ScrollArea>
{/* Resumen de archivos seleccionados (de vectores) */}
<div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Archivos seleccionados</h4>
{selectedFiles.length === 0 ? (
<p className="text-sm text-gray-500">No has seleccionado archivos del repositorio</p>
) : (
<ul className="space-y-2 max-h-40 overflow-auto">
{selectedFiles.map((f) => (
<li key={f.id} className="flex items-center justify-between p-2 rounded-md border bg-white">
<div className="text-sm">
<div className="font-medium">{f.filename ?? f.name ?? f.id}</div>
<div className="text-xs text-gray-400 truncate">{f.id}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{/* optionally show vector id */}</span>
<button onClick={() => removeSelectedFile(f.id)} className="text-sm text-red-500 hover:underline">Quitar</button>
</div>
</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">
{/* <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>
</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>
<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>
))
)}
)}
{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 ref={messagesEndRef} />
</div>
</div>
{attachedPreviews.length > 0 && (
@@ -549,7 +591,7 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
<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} />
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
</label>
<textarea
@@ -567,7 +609,7 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
style={{ minHeight: "38px" }}
/>
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0)} className="shadow-md">
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
</Button>

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { supabase } from "@/auth/supabase"
import ReactMarkdown from "react-markdown"
import { useSupabaseAuth } from "@/auth/supabase"
export function HistorialCambiosModal({

View File

@@ -6,15 +6,12 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from "canvas-confetti"
import { useQueryClient } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries"
import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const router = useRouter()
const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false)

View File

@@ -33,7 +33,6 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
const sectionGap = 10 // Espacio entre recuadros de sección
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

View File

@@ -12,6 +12,7 @@ import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
import AIChatModal from "../ai/AIChatModal"
/* =====================================================
Query keys & fetcher
===================================================== */
@@ -54,6 +55,8 @@ export const planTextOptions = (planId: string) =>
staleTime: 60_000,
})
/* =====================================================
Color helpers
===================================================== */
@@ -69,7 +72,7 @@ const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb
/* =====================================================
Expandable text
===================================================== */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
function ExpandableText({ text }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>
@@ -124,6 +127,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("")
// --- mutation con actualización optimista ---
const updateField = useMutation({
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
@@ -299,26 +303,24 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
</DialogFooter>
</DialogContent>
</Dialog>
<HistorialCambiosModal
<HistorialCambiosModal
open={openHistorial}
onClose={() => setOpenHistorial(false)}
planId={planId}
onRestore={async (key, value) => {
updateField.mutate({ key, value })
updateField.mutate({ key: key as keyof PlanTextFields, value })
}}
/>
<AIChatModal
//plan_format={plan_format}
open={openModalIa}
onClose={() => setopenModalIa(false)}
context={{
section: iaContext?.title,
fieldKey: iaContext?.key,
section: null,//,iaContext?.title,
fieldKey: null,//iaContext?.key,
originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} 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.`,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`,
}}
onAccept={(newText: string) => {
if (iaContext) {

10
src/formatos/plan.json Normal file
View File

@@ -0,0 +1,10 @@
{
"objetivo_general": "...",
"sistema_evaluacion": "...",
"perfil_ingreso": "...",
"perfil_egreso": "...",
"competencias_genericas": "...",
"competencias_especificas": "...",
"indicadores_desempeno": "...",
"pertinencia": "..."
}

View File

@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { supabase } from "@/auth/supabase";
/**
@@ -11,8 +11,6 @@ export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
tableName: string,
idKey: keyof T = "id" as keyof T
) {
const qc = useQueryClient();
// Generar diferencias tipo JSON Patch
function generateDiff(oldData: T, newData: Partial<T>) {
const changes: any[] = [];

View File

@@ -0,0 +1,39 @@
import { supabase } from "@/auth/supabase"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
type EdgeArgs = {
module: EdgeModule
action: string
params?: Record<string, any>
}
export async function callFilesAndVectorStoresApi<T = unknown>(
args: EdgeArgs,
): Promise<T> {
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: args,
},
)
if (error) {
console.error(error)
throw error
}
const payload = data ?? {}
if (payload.error) {
const msg =
typeof payload.error === "string"
? payload.error
: payload.error.message ?? "Error en la función Edge"
throw new Error(msg)
}
// Soporta tanto `{ data: [...] }` como `[...]`
const result = payload.data !== undefined ? payload.data : payload
return result as T
}

View File

@@ -149,8 +149,6 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth()
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')

View File

@@ -1,6 +1,6 @@
// routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { use, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -9,92 +9,204 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import { DetailDialog } from "@/components/archivos/DetailDialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
interface VectorStore {
id: string
object: "vector_store"
created_at: number
name: string | null
description?: string | null
usage_bytes: number
file_counts: {
in_progress: number
completed: number
failed: number
cancelled: number
total: number
}
status: string
last_active_at?: number | null
metadata?: Record<string, any> | null
}
interface VectorStoreFile {
id: string
object: string
created_at: number
vector_store_id: string
status: string
usage_bytes: number
last_error?: { code: string; message: string } | null
}
interface VectorStoreFileMeta {
id: string
user_id: string | null
vector_store_id: string
openai_file_id: string
label: string | null
tags: string[] | null
created_at: string
}
type EdgeArgs = {
module: EdgeModule
action: string
params?: Record<string, any>
}
async function callFilesAndVectorStoresApi<T = unknown>(
args: EdgeArgs,
): Promise<T> {
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: args,
},
)
if (error) {
console.error(error)
throw error
}
const payload = data ?? {}
if (payload.error) {
const msg =
typeof payload.error === "string"
? payload.error
: payload.error.message ?? "Error en la función Edge"
throw new Error(msg)
}
const result = payload.data !== undefined ? payload.data : payload
return result as T
}
export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent,
loader: async () => {
const { data, error } = await supabase
.from("documentos")
.select("*")
.order("fecha_subida", { ascending: false })
.limit(200)
if (error) throw error
return (data ?? []) as RefRow[]
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
module: "vectorStores",
action: "list",
params: {
limit: 10,
},
})
return stores ?? []
},
})
function chipTint(ok?: boolean | null) {
return ok
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-amber-50 text-amber-800 border-amber-200"
/* ====== UI helpers ====== */
function StatusBadge({ status }: { status: string }) {
const label =
status === "completed"
? "Completado"
: status === "in_progress"
? "Procesando"
: status
const base = "text-[10px] px-2 py-0.5 rounded-full border"
if (status === "completed") {
return (
<span
className={`${base} bg-emerald-50 text-emerald-700 border-emerald-200`}
>
{label}
</span>
)
}
if (status === "in_progress") {
return (
<span
className={`${base} bg-amber-50 text-amber-800 border-amber-200`}
>
{label}
</span>
)
}
return (
<span className={`${base} bg-neutral-50 text-neutral-700 border-neutral-200`}>
{label}
</span>
)
}
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
function RouteComponent() {
const router = useRouter()
const rows = Route.useLoaderData() as RefRow[]
const vectorStores = Route.useLoaderData() as VectorStore[]
const [q, setQ] = useState("")
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
const [viewing, setViewing] = useState<RefRow | null>(null)
const [uploadOpen, setUploadOpen] = useState(false)
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
const [selected, setSelected] = useState<VectorStore | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
return rows.filter((r) => {
if (estado === "proc" && !r.procesado) return false
if (estado === "pend" && r.procesado) return false
if (scope === "internos" && !r.interno) return false
if (scope === "externos" && r.interno) return false
if (!t) return true
const hay =
[r.titulo_archivo, r.descripcion, r.fuente_autoridad, r.tipo_contenido, ...(r.tags ?? [])]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(t))
return hay
const term = q.trim().toLowerCase()
return vectorStores.filter((vs) => {
if (statusFilter !== "all" && vs.status !== statusFilter) return false
if (!term) return true
return (
(vs.name ?? "").toLowerCase().includes(term) ||
(vs.description ?? "").toLowerCase().includes(term)
)
})
}, [rows, q, estado, scope])
}, [vectorStores, q, statusFilter])
async function remove(id: string) {
if (!confirm("¿Eliminar archivo de referencia?")) return
const { error } = await supabase
.from("documentos")
.delete()
.eq("documentos_id", id)
if (error) return alert(error.message)
function openDetails(vs: VectorStore) {
setSelected(vs)
setDialogOpen(true)
}
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentos_id: id }),
await callFilesAndVectorStoresApi({
module: "vectorStores",
action: "delete",
params: { vector_store_id: id },
})
if (!res.ok) {
throw new Error("Se falló al eliminar el documento")
}
} catch (err) {
console.error("Error al eliminar el documento:", err)
}
await supabase
.from("vector_store_files_meta")
.delete()
.eq("vector_store_id", id)
router.invalidate()
router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
}
}
return (
<div className="p-6 space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono">Archivos de referencia</CardTitle>
<CardTitle className="font-mono">Repositorios de archivos</CardTitle>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
@@ -102,240 +214,502 @@ function RouteComponent() {
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por título, etiqueta, fuente…"
placeholder="Buscar por nombre o descripción…"
className="pl-8"
/>
</div>
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="proc">Procesados</SelectItem>
<SelectItem value="pend">Pendientes</SelectItem>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="completed">Completados</SelectItem>
<SelectItem value="in_progress">En proceso</SelectItem>
</SelectContent>
</Select>
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
<SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Ámbito" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="internos">Internos</SelectItem>
<SelectItem value="externos">Externos</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setUploadOpen(true)}>
<Icons.Upload className="w-4 h-4 mr-2" /> Nuevo
<Button onClick={() => setCreateOpen(true)}>
<Icons.FolderPlus className="w-4 h-4 mr-2" />
Nuevo repositorio
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((r) => (
<article
key={r.documentos_id}
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
>
<header className="min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
{r.procesado ? "Procesado" : "Pendiente"}
</span>
{filtered.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((vs) => (
<article
key={vs.id}
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
>
<header className="min-w-0 space-y-1">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold truncate">
{vs.name || "(Sin nombre)"}
</h3>
<StatusBadge status={vs.status} />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<Badge variant="outline">
Archivos: {vs.file_counts?.completed ?? 0}
</Badge>
{typeof vs.usage_bytes === "number" && (
<span>
{(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB
</span>
)}
{vs.last_active_at && (
<span className="inline-flex items-center gap-1">
<Icons.Clock3 className="w-3 h-3" />
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
</span>
)}
</div>
</header>
{vs.description && (
<p className="text-sm text-neutral-700 line-clamp-3">
{vs.description}
</p>
)}
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={() => openDetails(vs)}>
<Icons.Eye className="w-4 h-4 mr-1" /> Abrir
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(vs.id)}
disabled={deletingId === vs.id}
>
<Icons.Trash2 className="w-4 h-4 mr-1" />
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
</Button>
</div>
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
{r.interno != null && (
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
)}
{r.fecha_subida && (
<span className="inline-flex items-center gap-1">
<Icons.CalendarClock className="w-3 h-3" />
{new Date(r.fecha_subida).toLocaleDateString()}
</span>
)}
</div>
</header>
{r.descripcion && (
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
)}
{/* Tags
{r.tags && r.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{r.tags.map((t, i) => (
<span key={i} className="text-[10px] px-2 py-0.5 rounded-full border bg-white/60">
#{t}
</span>
))}
</div>
)} */}
<div className="mt-auto flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
</Button>
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
</Button>
</div>
</article>
))}
</div>
{!filtered.length && (
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
</article>
))}
</div>
) : (
<div className="text-center text-sm text-neutral-500 py-10">
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
</div>
)}
</CardContent>
</Card>
{/* Detalle */}
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
<CreateVectorStoreDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
{/* Subida */}
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
<VectorStoreDialog
store={selected}
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => router.invalidate()}
/>
</div>
)
}
/* ========= Subida ========= */
function UploadDialog({
open, onOpenChange, onDone,
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
const supabaseAuth = useSupabaseAuth()
const [file, setFile] = useState<File | null>(null)
const [instrucciones, setInstrucciones] = useState("")
const [tags, setTags] = useState("")
const [interno, setInterno] = useState(true)
const [fuente, setFuente] = useState("")
const [subiendo, setSubiendo] = useState(false)
/* ====== Crear repositorio ====== */
async function toBase64(f: File): Promise<string> {
const buf = await f.arrayBuffer()
const bytes = new Uint8Array(buf)
let binary = ""
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
return btoa(binary)
}
function CreateVectorStoreDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}) {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [creating, setCreating] = useState(false)
async function upload() {
if (!file) { alert("Selecciona un archivo"); return }
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
setSubiendo(true)
async function handleCreate() {
if (!name.trim()) {
alert("Escribe un nombre para el repositorio")
return
}
setCreating(true)
try {
const fileBase64 = await toBase64(file)
// Enviamos al motor (inserta en la tabla si insert=true)
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: instrucciones,
fileBase64,
insert: true,
uuid: supabaseAuth.user?.id ?? null,
}),
await callFilesAndVectorStoresApi<VectorStore>({
module: "vectorStores",
action: "create",
params: { name: name.trim(), description: description.trim() || undefined },
})
if (!res.ok) {
const txt = await res.text()
throw new Error(txt || "Error al subir")
}
// Ajustes extra (tags, interno, fuente) si el motor no los llenó
// Intentamos leer el id que regrese el servicio; si no, solo invalidamos.
let createdId: string | null = null
try {
const payload = await res.json()
createdId =
payload?.documentos_id ||
payload?.id ||
payload?.data?.documentos_id ||
null
} catch { /* noop */ }
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
await supabase
.from("documentos")
.update({
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
fuente_autoridad: fuente.trim() || undefined,
interno,
})
.eq("documentos_id", createdId)
}
onOpenChange(false)
onDone()
} catch (e: any) {
alert(e?.message ?? "Error al subir el documento")
setName("")
setDescription("")
onCreated()
} catch (err: any) {
alert(err?.message ?? "Error al crear el repositorio")
} finally {
setSubiendo(false)
setCreating(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
<DialogDescription>
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
<em> procesado </em> cuando termine el flujo.
Crea un Vector Store para agrupar archivos relacionados.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1">
<Label>Archivo</Label>
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
{file && (
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
)}
</div>
<div className="space-y-1">
<Label>Instrucciones</Label>
<Textarea
value={instrucciones}
onChange={(e) => setInstrucciones(e.target.value)}
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
className="min-h-[120px]"
<Label>Nombre</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Planeación curricular, Entrevistas…"
/>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Tags (separados por coma)</Label>
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
</div>
<div className="space-y-1">
<Label>Fuente de autoridad</Label>
<Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
</div>
</div>
<div className="space-y-1">
<Label>Ámbito</Label>
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="true">Interno</SelectItem>
<SelectItem value="false">Externo</SelectItem>
</SelectContent>
</Select>
<Label>Descripción (opcional)</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Breve descripción del contenido de este repositorio."
className="min-h-[80px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
{subiendo ? "Subiendo…" : "Subir"}
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? "Creando…" : "Crear repositorio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* ====== Detalle de un repositorio: archivos + subida ====== */
type FileRow = {
file: VectorStoreFile
meta: VectorStoreFileMeta | null
}
function VectorStoreDialog({
store,
open,
onOpenChange,
onUpdated,
}: {
store: VectorStore | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: () => void
}) {
const supabaseAuth = useSupabaseAuth()
const [files, setFiles] = useState<FileRow[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [label, setLabel] = useState("")
useEffect(() => {
if (!open || !store) return
void refreshFiles()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, store?.id])
async function refreshFiles() {
if (!store) return
setLoading(true)
setError(null)
try {
const vectorFiles = await callFilesAndVectorStoresApi<VectorStoreFile[]>({
module: "vectorStoreFiles",
action: "list",
params: { vector_store_id: store.id },
})
const { data: metaRows, error: metaError } = await supabase
.from("vector_store_files_meta")
.select("*")
.eq("vector_store_id", store.id)
.order("created_at", { ascending: false })
if (metaError) throw metaError
const meta = (metaRows ?? []) as VectorStoreFileMeta[]
const merged: FileRow[] = (vectorFiles ?? []).map((vf) => ({
file: vf,
meta: meta.find((m) => m.openai_file_id === vf.id) ?? null,
}))
setFiles(merged)
} catch (err: any) {
console.error(err)
setError(err?.message ?? "No se pudieron cargar los archivos")
} finally {
setLoading(false)
}
}
async function handleUpload() {
if (!store || !file) {
alert("Selecciona un archivo")
return
}
setUploading(true)
try {
// 1) Subir archivo a OpenAI vía Edge con FormData (igual que en tu script)
const formData = new FormData()
formData.append("module", "files")
formData.append("action", "upload")
formData.append("file", file)
formData.append("purpose", "assistants") // o lo que uses en tu flujo
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: formData,
},
)
if (error) {
console.error(error)
throw error
}
const uploaded = data
// La respuesta es el objeto "file" de OpenAI:
// { object: "file", id: "file-xxx", ... }
const openaiFileId: string | undefined = uploaded?.id
if (!openaiFileId) {
console.error("Respuesta Edge inesperada:", uploaded)
throw new Error("La Edge Function no devolvió el id del archivo")
}
// 2) Mapear archivo al Vector Store (JSON normal)
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "create",
params: {
vector_store_id: store.id,
body: {
file_id: openaiFileId,
},
},
})
// 3) Guardar metadata en Supabase
const { error: insertError } = await supabase
.from("vector_store_files_meta")
.insert({
user_id: supabaseAuth.user?.id ?? null,
vector_store_id: store.id,
openai_file_id: openaiFileId,
label: label.trim() || file.name,
})
if (insertError) throw insertError
setFile(null)
setLabel("")
await refreshFiles()
onUpdated()
} catch (err: any) {
console.error(err)
alert(err?.message ?? "Error al subir el archivo")
} finally {
setUploading(false)
}
}
async function handleDeleteFile(fileId: string) {
if (!store) return
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return
setRefreshing(true)
try {
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "delete",
params: {
vector_store_id: store.id,
file_id: fileId,
},
})
// Opcional: eliminar también el archivo global de OpenAI
await callFilesAndVectorStoresApi<any>({
module: "files",
action: "delete",
params: { id: fileId },
})
await supabase
.from("vector_store_files_meta")
.delete()
.eq("openai_file_id", fileId)
await refreshFiles()
onUpdated()
} catch (err: any) {
console.error(err)
alert(err?.message ?? "Error al eliminar el archivo")
} finally {
setRefreshing(false)
}
}
if (!store) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.Folder className="h-4 w-4" />
{store.name || "(Sin nombre)"}
</DialogTitle>
<DialogDescription>
Gestiona los archivos asociados a este repositorio (Vector Store).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
<StatusBadge status={store.status} />
<Badge variant="outline">
Archivos completados: {store.file_counts?.completed ?? 0}
</Badge>
<Badge variant="outline">
Total archivos: {store.file_counts?.total ?? 0}
</Badge>
{typeof store.usage_bytes === "number" && (
<span>{(store.usage_bytes / 1024 / 1024).toFixed(2)} MB</span>
)}
</div>
{/* Subida de archivo */}
<div className="space-y-2 rounded-lg border bg-muted/50 p-4">
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Agregar archivo al repositorio
</Label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end">
<div className="space-y-1">
<Input
type="file"
accept=".pdf,.doc,.docx,.txt,.md"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file && (
<div className="text-xs text-neutral-600">
{file.name} · {(file.size / 1024).toFixed(1)} KB
</div>
)}
</div>
<div className="space-y-1">
<Label>Título / etiqueta</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ej.: Plan 2025, Entrevista 3…"
/>
<Button
className="mt-2 w-full sm:w-auto"
onClick={handleUpload}
disabled={uploading || !file}
>
{uploading ? "Subiendo…" : "Subir al repositorio"}
</Button>
</div>
</div>
</div>
{/* Lista de archivos */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Archivos en este repositorio</Label>
<Button
variant="ghost"
size="sm"
onClick={() => refreshFiles()}
disabled={loading || refreshing}
>
<Icons.RefreshCw className="h-4 w-4 mr-1" />
Actualizar
</Button>
</div>
{loading ? (
<div className="text-xs text-neutral-500 py-4">
Cargando archivos
</div>
) : error ? (
<div className="text-xs text-red-500 py-4">{error}</div>
) : files.length === 0 ? (
<div className="text-xs text-neutral-500 py-4">
Todavía no hay archivos en este repositorio
</div>
) : (
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
{files.map(({ file, meta }) => (
<li
key={file.id}
className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2"
>
<div className="min-w-0">
<p className="font-medium truncate">
{meta?.label || file.id}
</p>
<p className="text-xs text-neutral-500 truncate">
{new Date(file.created_at * 1000).toLocaleString()} ·{" "}
{(file.usage_bytes / 1024).toFixed(1)} KB
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={file.status} />
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteFile(file.id)}
>
<Icons.Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cerrar
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
@@ -81,7 +81,7 @@ async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
const planIds = await fetchPlanIdsByScope(search)
if (planIds && planIds.length === 0) return []
console.log(AsignaturaCard);
let query = supabase
.from('asignaturas')
.select(`
@@ -168,25 +168,25 @@ function RouteComponent() {
const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos')
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [groupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
/* useEffect(() => {
const timeout = setTimeout(() => {
router.navigate({
to: '/asignaturas',
search: { ...search, q },
replace: true,
})
}, 400)
return () => clearTimeout(timeout)
}, [q]) */
/* useEffect(() => {
const timeout = setTimeout(() => {
router.navigate({
to: '/asignaturas',
search: { ...search, q },
replace: true,
})
}, 400)
return () => clearTimeout(timeout)
}, [q]) */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setQ(value)
router.navigate({
@@ -199,30 +199,30 @@ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
})
}
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [planes])
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [planes])
// 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
if (
p.carrera?.id &&
p.carrera?.nombre &&
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
) {
unique.set(p.carrera.id, p.carrera.nombre)
}
})
return Array.from(unique.entries())
}, [planes, facultad])
// 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
if (
p.carrera?.id &&
p.carrera?.nombre &&
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
) {
unique.set(p.carrera.id, p.carrera.nombre)
}
})
return Array.from(unique.entries())
}, [planes, facultad])
// NEW: Clonado individual
@@ -256,12 +256,6 @@ const carrerasList = useMemo(() => {
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud
const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
@@ -274,29 +268,29 @@ const carrerasList = useMemo(() => {
}, [asignaturas])
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const planOK = !search.planId || a.plan?.id === search.planId
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const planOK = !search.planId || a.plan?.id === search.planId
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
})
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
})
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
// Agrupación
@@ -316,18 +310,19 @@ const carrerasList = useMemo(() => {
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
const clearFilters = () => {
setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas')
// Actualiza la URL limpiando todos los query params
router.navigate({
to: '/asignaturas',
search: {
q: '',
planId: '',
carreraId: '',
facultadId: '',
f: ''
},
})
router.navigate({
to: '/asignaturas',
search: {
q: '',
planId: '',
carreraId: '',
facultadId: '',
f: ''
},
})
}
// NEW: util para clonar 1 asignatura
@@ -363,7 +358,7 @@ const carrerasList = useMemo(() => {
if (error) throw error
}
// NEW: abrir modal clon individual
function openClone(a: Asignatura) {
@@ -550,7 +545,12 @@ const carrerasList = useMemo(() => {
value={search.planId ?? "todos"}
onValueChange={(val) => {
router.navigate({
search: { ...search, planId: val === "todos" ? "" : val },
to: '/asignaturas',
search: {
...search,
planId: val === 'todos' ? '' : val,
},
replace: true,
})
}}
>
@@ -828,15 +828,14 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
console.log(a);
return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
style={{
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
}}
style={{
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
}}
>
<div className="p-3">
<div className="flex items-start gap-3">
@@ -890,15 +889,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
{a.plan.carrera && (
<InfoChip
icon={<Icons.GraduationCap className="h-3 w-3" />}
label={a.plan.carrera.nombre}
/>
label={a.plan.carrera.nombre}
/>
)}
{a.plan.carrera?.facultad && (
<InfoChip
icon={<Icons.Building2 className="h-3 w-3" />}
label={a.plan.carrera.facultad.nombre}
tint={a.plan.carrera.facultad.color}
/>
tint={a.plan.carrera.facultad.color}
/>
)}
</div>
)}

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
import { GradientMesh } from "../../../components/planes/GradientMesh"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { softAccentStyle } from "@/components/planes/planHelpers"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"

View File

@@ -17,13 +17,6 @@ import { toast } from "sonner"
/* -------------------- Tipos -------------------- */
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
/* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = {
@@ -149,35 +142,6 @@ function RouteComponent() {
carrera_id?: string | null
}>({ email: "", password: "" })
function genPassword() {
/*
Supabase requiere que las contraseñas tengan las siguientes características:
- Mínimo de 6 caracteres
- Debe contener al menos una letra minúscula
- Debe contener al menos una letra mayúscula
- Debe contener al menos un número
- Debe contener al menos un carácter especial
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
*/
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
const positions = Array.from({ length: 12 }, (_, i) => i);
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
// 2. Las correspondencias son las siguientes:
// - El primer número indica la posición de la letra minúscula
// - El segundo número indica la posición de la letra mayúscula
// - El tercer número indica la posición del número
// - El cuarto número indica la posición del carácter especial
// - En las demás posiciones puede haber cualquier caracter alfanumérico
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
return s.slice(0, 14)
}
function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role]
@@ -197,61 +161,6 @@ function RouteComponent() {
router.invalidate()
}
const upsertNombramiento = useMutation({
mutationFn: async (opts: {
user_id: string
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
facultad_id?: string | null
carrera_id?: string | null
}) => {
// cierra vigentes
if (opts.puesto === "jefe_carrera") {
if (!opts.carrera_id) throw new Error("Selecciona carrera")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", "jefe_carrera")
.eq("carrera_id", opts.carrera_id)
.is("hasta", null)
} else {
if (!opts.facultad_id) throw new Error("Selecciona facultad")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", opts.puesto)
.eq("facultad_id", opts.facultad_id)
.is("hasta", null)
}
const { error } = await supabase.from("nombramientos").insert({
user_id: opts.user_id,
puesto: opts.puesto,
facultad_id: opts.facultad_id ?? null,
carrera_id: opts.carrera_id ?? null,
desde: new Date().toISOString().slice(0, 10),
hasta: null,
})
if (error) throw error
},
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
})
const toggleBan = useMutation({
mutationFn: async (u: UserClaims) => {
throw new Error("Funcionalidad de baneo no implementada aún.")
const banned = false // cuando se tenga acceso a ese campo
// const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
// if (error) throw new Error(error.message)
return !banned
},
onSuccess: async (isBanned) => {
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
await invalidateAll()
},
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
})
const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => {
// Validaciones previas
@@ -409,7 +318,7 @@ function RouteComponent() {
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
</Button>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
@@ -425,7 +334,7 @@ function RouteComponent() {
</div>
</div>
<div className="sm:hidden self-start shrink-0 flex gap-1">
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
</div>
</div>

14
staticwebapp.config.json Normal file
View File

@@ -0,0 +1,14 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}

12
swa-cli.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
"configurations": {
"acad-ia": {
"appLocation": ".",
"outputLocation": "dist",
"appBuildCommand": "npm run build",
"run": "npm run dev",
"appDevserverUrl": "http://localhost:5173"
}
}
}