Compare commits
9 Commits
main
...
feat/reest
| Author | SHA1 | Date | |
|---|---|---|---|
| 1475a65938 | |||
| 9a1d8279a1 | |||
| 0456a1063d | |||
| a41136a224 | |||
| 102c21927e | |||
| 566e23ad34 | |||
| 872c495d30 | |||
| 7951f9d8c5 | |||
| 4894543c57 |
@@ -1,38 +0,0 @@
|
|||||||
name: Deploy to Azure Static Web Apps
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
env:
|
|
||||||
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
VITE_BACK_ORIGIN: ${{ vars.VITE_BACK_ORIGIN }}
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
# No hace falta instalar el CLI globalmente, usamos bunx
|
|
||||||
- name: Deploy to Azure Static Web Apps
|
|
||||||
env:
|
|
||||||
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
|
|
||||||
run: |
|
|
||||||
bunx @azure/static-web-apps-cli deploy ./dist \
|
|
||||||
--env production \
|
|
||||||
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
> Why do I have a folder named ".vercel" in my project?
|
|
||||||
The ".vercel" folder is created when you link a directory to a Vercel project.
|
|
||||||
|
|
||||||
> What does the "project.json" file contain?
|
|
||||||
The "project.json" file contains:
|
|
||||||
- The ID of the Vercel project that you linked ("projectId")
|
|
||||||
- The ID of the user or team your Vercel project is owned by ("orgId")
|
|
||||||
|
|
||||||
> Should I commit the ".vercel" folder?
|
|
||||||
No, you should not share the ".vercel" folder with anyone.
|
|
||||||
Upon creation, it will be automatically added to your ".gitignore" file.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"//": "This file was generated by the `vercel build` command. It is not part of the Build Output API.",
|
|
||||||
"target": "preview",
|
|
||||||
"argv": [
|
|
||||||
"C:\\Program Files\\nodejs\\node.exe",
|
|
||||||
"C:\\Users\\alex\\.bun\\install\\global\\node_modules\\vercel\\dist\\vc.js",
|
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"builds": [
|
|
||||||
{
|
|
||||||
"require": "@vercel/static-build",
|
|
||||||
"requirePath": "C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\static-build\\dist\\index",
|
|
||||||
"apiVersion": 2,
|
|
||||||
"src": "package.json",
|
|
||||||
"use": "@vercel/static-build",
|
|
||||||
"config": {
|
|
||||||
"zeroConfig": true,
|
|
||||||
"framework": "vite"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"name": "Error",
|
|
||||||
"stack": "Error: Command \"npm run build\" exited with 2\n at ChildProcess.<anonymous> (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:23221:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)",
|
|
||||||
"message": "Command \"npm run build\" exited with 2",
|
|
||||||
"hideStackTrace": true,
|
|
||||||
"code": "BUILD_UTILS_SPAWN_2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"error": {
|
|
||||||
"name": "Error",
|
|
||||||
"stack": "Error: Command \"npm run build\" exited with 2\n at ChildProcess.<anonymous> (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:23221:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)",
|
|
||||||
"message": "Command \"npm run build\" exited with 2",
|
|
||||||
"hideStackTrace": true,
|
|
||||||
"code": "BUILD_UTILS_SPAWN_2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[{"name":"vc.builder","duration":11410816,"timestamp":1109155148251,"id":"883875fc-d8b5-40c7-8fdd-f96c27a63d1b","parentId":"3583d148-7b32-444b-813e-a786bebb1401","tags":{"name":"@vercel/static-build"},"startTime":1764002346775},{"name":"vc.builder.diagnostics","duration":17,"timestamp":1109166559117,"id":"c6ad9c3e-80a6-4b2c-9d0b-9e6999814dda","parentId":"883875fc-d8b5-40c7-8fdd-f96c27a63d1b","tags":{},"startTime":1764002358186},{"name":"vc.doBuild","duration":11600581,"timestamp":1109154959803,"id":"3583d148-7b32-444b-813e-a786bebb1401","parentId":"dad52c92-34d7-49a1-a8c7-c26d68888fb8","tags":{},"startTime":1764002346587},{"name":"vc","duration":11643513,"timestamp":1109154916896,"id":"dad52c92-34d7-49a1-a8c7-c26d68888fb8","tags":{},"startTime":1764002346544}]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"projectId": "prj_TQf0vM7v0Pz1NyDhm3Ab0Jp4zB2E",
|
|
||||||
"orgId": "team_dURDB79ODIkvcyPxn5ZVT7xr",
|
|
||||||
"projectName": "acad-ia",
|
|
||||||
"settings": {
|
|
||||||
"createdAt": 1764000675314,
|
|
||||||
"framework": "vite",
|
|
||||||
"devCommand": null,
|
|
||||||
"installCommand": null,
|
|
||||||
"buildCommand": null,
|
|
||||||
"outputDirectory": null,
|
|
||||||
"rootDirectory": null,
|
|
||||||
"directoryListing": false,
|
|
||||||
"nodeVersion": "22.x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
bun.lock
6
bun.lock
@@ -32,7 +32,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"jspdf": "^3.0.4",
|
"jspdf": "^3.0.3",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -752,7 +752,7 @@
|
|||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
"jspdf": ["jspdf@3.0.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ=="],
|
"jspdf": ["jspdf@3.0.3", "", { "dependencies": { "@babel/runtime": "^7.26.9", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ=="],
|
||||||
|
|
||||||
"jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="],
|
"jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="],
|
||||||
|
|
||||||
@@ -1122,8 +1122,6 @@
|
|||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"jspdf/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
|
||||||
|
|
||||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[phases.install]
|
|
||||||
cmds = ["bun install --frozen-lockfile"]
|
|
||||||
|
|
||||||
[phases.build]
|
|
||||||
cmds = ["bun run build"]
|
|
||||||
5833
package-lock.json
generated
Normal file
5833
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,11 +34,12 @@
|
|||||||
"@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",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"jspdf": "^3.0.4",
|
"jspdf": "^3.0.3",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { supabase } from "@/auth/supabase";
|
import { supabase } from "@/auth/supabase";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown"
|
||||||
|
|
||||||
/* ---------- UI Mocks (sin cambios) ---------- */
|
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||||
const Paperclip = (props) => (
|
const Paperclip = (props) => (
|
||||||
@@ -33,113 +33,153 @@ const CardContent = ({ className, children }) => <div className={`p-4 ${classNam
|
|||||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||||
|
|
||||||
/* ------------- COMPONENT ------------- */
|
/* ------------- COMPONENT ------------- */
|
||||||
export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) {
|
export default function AIChatModal({ open, onClose, context, onAccept }) {
|
||||||
|
|
||||||
const [vectorStores, setVectorStores] = useState([]);
|
const [vectorStores, setVectorStores] = useState([]);
|
||||||
const [vectorFiles, setVectorFiles] = useState([]);
|
const [vectorFiles, setVectorFiles] = useState([]);
|
||||||
const [selectedVector, setSelectedVector] = useState(null);
|
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
|
||||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
|
||||||
|
|
||||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||||
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||||
|
|
||||||
// chat
|
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
// loading states
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||||
const [loadingVectors, setLoadingVectors] = useState(false);
|
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||||
|
|
||||||
// conversation control
|
|
||||||
const [conversationId, setConversationId] = useState(null);
|
const [conversationId, setConversationId] = useState(null);
|
||||||
const [creatingConversation, setCreatingConversation] = useState(false);
|
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
|
||||||
|
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
useEffect(scrollToBottom, [messages]);
|
useEffect(scrollToBottom, [messages]);
|
||||||
|
|
||||||
const normalizeInvokeResponse = (resp) => {
|
const normalizeInvokeResponse = (resp) => {
|
||||||
if (!resp) return null;
|
if (!resp) return null;
|
||||||
const raw = resp.data;
|
|
||||||
if (typeof raw === "string") {
|
// cuando invocas funciones, Supabase siempre regresa:
|
||||||
try { return JSON.parse(raw); } catch (e) { console.warn("❗ No se pudo parsear resp.data:", raw); return null; }
|
// { 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 (typeof raw === "object" && raw !== null) return 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
|
// Al abrir: reset o crear conversación
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log(context.cont_conversation);
|
||||||
|
console.log(context);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
// si ya existe una conversación la eliminamos
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
deleteConversation(conversationId).catch((e) => console.error(e));
|
deleteConversation(conversationId).catch((e) => console.error(e));
|
||||||
}
|
}
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setSelectedFiles([]);
|
setSelectedVectorFile(null);
|
||||||
setAttachedFiles([]);
|
setAttachedFiles([]);
|
||||||
setAttachedPreviews([]);
|
setAttachedPreviews([]);
|
||||||
setConversationId(null);
|
setConversationId(null);
|
||||||
setSelectedVector(null);
|
|
||||||
setVectorFiles([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inyectar contexto como system message
|
||||||
if (context) {
|
if (context) {
|
||||||
setMessages([
|
setMessages([
|
||||||
{
|
{
|
||||||
role: "system",
|
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 || "—"}`
|
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 () => {
|
(async () => {
|
||||||
await createConversation();
|
await createConversation();
|
||||||
|
// tras crear podemos también cargar vector stores
|
||||||
fetchVectorStores();
|
fetchVectorStores();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// ---------- CREATE CONVERSATION ----------
|
// --------- CREATE CONVERSATION (robusto) ----------
|
||||||
const createConversation = async () => {
|
const createConversation = async () => {
|
||||||
try {
|
try {
|
||||||
setCreatingConversation(true);
|
setCreatingConversation(true);
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
const token = session?.access_token;
|
|
||||||
|
|
||||||
const resp = await supabase.functions.invoke("conversation-format", {
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
const token = session?.access_token;
|
||||||
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
|
|
||||||
});
|
|
||||||
|
|
||||||
let parsed = null;
|
// llamada
|
||||||
if (typeof resp?.data === "string") {
|
const resp = await supabase.functions.invoke("modal-conversation", {
|
||||||
try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
} else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
|
body: { action: "start" , role:"system", content:context.cont_conversation, }
|
||||||
else parsed = resp;
|
});
|
||||||
|
|
||||||
const convId =
|
console.log("createConversation -> raw resp:", resp);
|
||||||
parsed?.conversationId ||
|
|
||||||
parsed?.data?.conversationId ||
|
|
||||||
parsed?.data?.id ||
|
|
||||||
parsed?.id ||
|
|
||||||
parsed?.conversation_id ||
|
|
||||||
parsed?.data?.conversation_id;
|
|
||||||
|
|
||||||
if (!convId) { setCreatingConversation(false); return; }
|
// resp puede ser { data: "...json string..." } o { data: { ... } }
|
||||||
setConversationId(convId);
|
let parsed = null;
|
||||||
} catch (err) {
|
|
||||||
console.error("Error creando conversación:", err);
|
if (typeof resp?.data === "string") {
|
||||||
} finally {
|
try {
|
||||||
setCreatingConversation(false);
|
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;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- DELETE CONVERSATION ----------
|
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) => {
|
const deleteConversation = async (convIdParam) => {
|
||||||
try {
|
try {
|
||||||
const convIdToUse = convIdParam ?? conversationId;
|
const convIdToUse = convIdParam ?? conversationId;
|
||||||
@@ -147,11 +187,13 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
|
|||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
const token = session?.access_token;
|
const token = session?.access_token;
|
||||||
|
|
||||||
await supabase.functions.invoke("conversation-format", {
|
// algunas implementations esperan { action: "end", conversationId }, otras { action: "end", id }
|
||||||
|
const { data, error } = await supabase.functions.invoke("modal-conversation", {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: { action: "end", conversationId: convIdToUse }
|
body: { action: "end", conversationId: convIdToUse }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("deleteConversation -> response:", data);
|
||||||
setConversationId(null);
|
setConversationId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error eliminando conversación:", err);
|
console.error("Error eliminando conversación:", err);
|
||||||
@@ -167,13 +209,11 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- HANDLE CONVERSATION (envío) ----------
|
// ---------- SEND MESSAGE (usa conversationId) ----------
|
||||||
const handleConversation = async ({ text }) => {
|
const handleConversation = async ({ text }) => {
|
||||||
let contextText = "";
|
|
||||||
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
|
|
||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
console.warn("No hay conversación activa todavía. conversationId:", 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,48 +222,44 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
|
|||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
const token = session?.access_token;
|
const token = session?.access_token;
|
||||||
|
|
||||||
// archivos adjuntos (locales) -> base64
|
|
||||||
let filesInput = [];
|
let filesInput = [];
|
||||||
|
|
||||||
if (attachedFiles.length > 0) {
|
if (attachedFiles.length > 0) {
|
||||||
for (const file of attachedFiles) {
|
for (const file of attachedFiles) {
|
||||||
const base64 = await fileToBase64(file);
|
const base64 = await fileToBase64(file);
|
||||||
filesInput.push({
|
filesInput.push({
|
||||||
type: "input_file",
|
type: "input_file",
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
file_data: `data:${file.type};base64,${base64}`
|
file_data: `data:${file.type};base64,${base64}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivos seleccionados del vector (por id)
|
if (selectedVectorFile) {
|
||||||
if (selectedFiles.length > 0) {
|
// si el archivo del vector viene sólo con id
|
||||||
const filesFromVectors = selectedFiles.map(f => ({
|
filesInput.push({
|
||||||
type: "input_file",
|
type: "input_file",
|
||||||
file_id: f.id
|
file_id: selectedVectorFile.id
|
||||||
}));
|
});
|
||||||
filesInput = [...filesInput, ...filesFromVectors];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
action: "message",
|
action: "message",
|
||||||
format: plan_format,
|
|
||||||
conversationId,
|
conversationId,
|
||||||
vectorStoreId: selectedVector ?? null,
|
vectorStoreId: selectedVectorFile?.vector_store_id ?? null,
|
||||||
fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
|
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
|
||||||
input: [
|
input: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
{ type: "input_text", text: promptFinal },
|
{ type: "input_text", text },
|
||||||
...filesInput
|
...filesInput
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: invokeData, error } = await supabase.functions.invoke(
|
const { data: invokeData, error } = await supabase.functions.invoke(
|
||||||
"conversation-format",
|
"modal-conversation",
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: payload
|
body: payload
|
||||||
@@ -231,24 +267,40 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const parsed = normalizeInvokeResponse({ data: invokeData });
|
|
||||||
|
|
||||||
// Extraer texto del assistant (robusto)
|
console.log("handleConversation -> RAW invokeData:", invokeData);
|
||||||
|
|
||||||
|
const parsed = normalizeInvokeResponse({ data: invokeData });
|
||||||
|
console.log("handleConversation -> PARSED:", parsed);
|
||||||
|
|
||||||
|
// 🔥 EXTRACTOR DEFINITIVO
|
||||||
let assistantText = null;
|
let assistantText = null;
|
||||||
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
|
|
||||||
|
// 1) directo
|
||||||
|
if (parsed?.data?.output_text) {
|
||||||
|
assistantText = parsed.data.output_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) buscar el message
|
||||||
if (!assistantText && Array.isArray(parsed?.data?.output)) {
|
if (!assistantText && Array.isArray(parsed?.data?.output)) {
|
||||||
const msgBlock = parsed.data.output.find(o => o.type === "message");
|
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.";
|
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([]);
|
setAttachedFiles([]);
|
||||||
setAttachedPreviews([]);
|
setAttachedPreviews([]);
|
||||||
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
|
|
||||||
setSelectedFiles([]);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error en handleConversation:", err);
|
console.error("Error en handleConversation:", err);
|
||||||
@@ -306,52 +358,32 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------- UI helpers ----------
|
// ---------- UI helpers ----------
|
||||||
const handleAttach = (e) => {
|
const handleAttach = (e) => {
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
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
|
setAttachedFiles(prev => [...prev, ...files]);
|
||||||
const handleVectorClick = async (vector) => {
|
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
|
||||||
if (selectedVector === vector.id) {
|
};
|
||||||
// colapsar
|
|
||||||
setSelectedVector(null);
|
|
||||||
setVectorFiles([]);
|
|
||||||
setSelectedFiles([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedVector(vector.id);
|
|
||||||
setSelectedFiles([]);
|
|
||||||
await loadFilesForVector(vector.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle selección de archivo (checkbox)
|
const handleSelectVectorFile = (file) => {
|
||||||
const toggleFileSelection = (file) => {
|
setSelectedVectorFile(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 ----------
|
// ---------- Send flow ----------
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
// no permitir enviar si no hay nada
|
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
|
||||||
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
|
|
||||||
|
|
||||||
|
// esperar si aún se está creando la conversación
|
||||||
if (creatingConversation) {
|
if (creatingConversation) {
|
||||||
// no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
|
console.log("Esperando a que se cree la conversación...");
|
||||||
|
// opcional: podrías mostrar un toast; aquí simplemente retornamos
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
|
console.warn("No hay conversationId — intentaremos crear una ahora.");
|
||||||
await createConversation();
|
await createConversation();
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
|
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
|
||||||
@@ -359,225 +391,151 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userText = input.trim() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
|
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
|
||||||
setMessages(prev => [...prev, { role: "user", content: userText }]);
|
setMessages(prev => [...prev, { role: "user", content: userText }]);
|
||||||
setInput("");
|
setInput("");
|
||||||
|
|
||||||
await handleConversation({ text: userText });
|
await handleConversation({ text: userText });
|
||||||
};
|
};
|
||||||
|
|
||||||
function cleanAIResponse(text) {
|
const handleApply = () => {
|
||||||
if (!text) return text;
|
const last = [...messages].reverse().find(m => m.role === "assistant");
|
||||||
|
if (last && onAccept) {
|
||||||
let cleaned = text;
|
onAccept(last.content);
|
||||||
|
onClose();
|
||||||
// -------------------------
|
}
|
||||||
// 1. Eliminar emojis
|
};
|
||||||
// -------------------------
|
const cleanAssistantResponse = (text) => {
|
||||||
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;
|
if (!text) return text;
|
||||||
const patterns = [/^claro[, ]*/i, /^por supuesto[, ]*/i, /^aquí tienes[, ]*/i, /^con gusto[, ]*/i, /^hola[, ]*/i, /^perfecto[, ]*/i, /^entendido[, ]*/i, /^muy bien[, ]*/i, /^ok[, ]*/i];
|
|
||||||
|
// 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();
|
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 cleaned;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose} >
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative"
|
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative">
|
||||||
|
{/* Botón siempre visible */}
|
||||||
>
|
<button
|
||||||
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50">✕</button>
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 pt-4 min-h-0">
|
<div className="flex-1 pt-4 min-h-0">
|
||||||
<div className="flex gap-6 h-full min-h-0">
|
<div className="flex gap-6 h-full min-h-0">
|
||||||
|
|
||||||
{/* Left: vectors */}
|
{/* 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">
|
<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">
|
<CardContent className="flex flex-col flex-1 p-4">
|
||||||
<h3 className="font-semibold text-sm mb-3">Repositorio de archivos</h3>
|
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{loadingVectors ? (
|
{loadingVectors ? (
|
||||||
<p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
|
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
|
||||||
) : vectorStores.length === 0 ? (
|
) : vectorStores.length === 0 ? (
|
||||||
<p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
|
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<ul className="space-y-2">
|
||||||
{vectorStores.map((vector) => (
|
{vectorStores.map((vector) => (
|
||||||
<div key={vector.id}>
|
<li key={vector.id}
|
||||||
{/* VECTOR */}
|
onClick={() => loadFilesForVector(vector.id)}
|
||||||
<div
|
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
|
||||||
onClick={() => handleVectorClick(vector)}
|
>
|
||||||
className={`p-3 rounded-lg border cursor-pointer transition flex items-center justify-between
|
<strong className="truncate">{vector.name || vector.id}</strong>
|
||||||
${selectedVector === vector.id ? "bg-blue-50 border-blue-400 shadow" : "bg-white border-gray-300"}`}
|
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
|
||||||
>
|
|
||||||
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
{/* <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>
|
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
|
||||||
</div> */}
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right: Chat */}
|
{/* Right: Chat */}
|
||||||
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
<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">
|
<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>
|
<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">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
|
{/* 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">
|
<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 ? (
|
{messages.length === 0 ? (
|
||||||
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
||||||
) : (
|
) : (
|
||||||
messages.map((m, i) => (
|
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"}`}>
|
<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>{" "}
|
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||||
<ReactMarkdown>{m.content}</ReactMarkdown>
|
<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>
|
||||||
)}
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
{loading && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{attachedPreviews.length > 0 && (
|
{attachedPreviews.length > 0 && (
|
||||||
@@ -591,7 +549,7 @@ const handleApply = () => {
|
|||||||
<div className="flex gap-2 mt-4 items-end 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">
|
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||||
<Paperclip className="w-5 h-5" />
|
<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>
|
</label>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -609,7 +567,7 @@ const handleApply = () => {
|
|||||||
style={{ minHeight: "38px" }}
|
style={{ minHeight: "38px" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0)} className="shadow-md">
|
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
|
||||||
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
import { useSupabaseAuth } from "@/auth/supabase"
|
import { useSupabaseAuth } from "@/auth/supabase"
|
||||||
|
|
||||||
export function HistorialCambiosModal({
|
export function HistorialCambiosModal({
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
import confetti from "canvas-confetti"
|
import confetti from "canvas-confetti"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import { Field } from "./Field"
|
import { Field } from "./Field"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
|
import { asignaturaKeys } from "./planQueries"
|
||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router"
|
||||||
|
|
||||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const supabaseAuth = useSupabaseAuth()
|
const supabaseAuth = useSupabaseAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
console.log(plan);
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
const pageWidth = doc.internal.pageSize.getWidth()
|
};
|
||||||
const pageHeight = doc.internal.pageSize.getHeight()
|
|
||||||
const margin = 20
|
|
||||||
const maxWidth = pageWidth - margin * 2
|
|
||||||
|
|
||||||
// Parámetros de estilo institucional (basados en las capturas)
|
|
||||||
const lineHeight = 5.0 // mm por línea (ajustado para más texto por página)
|
|
||||||
const sectionGap = 10 // Espacio entre recuadros de sección
|
|
||||||
const bodyFontSize = 10.5
|
|
||||||
const headingFontSize = 12
|
|
||||||
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
|
|
||||||
const bulletIndent = 6 // Sangría para el texto de la lista
|
|
||||||
|
|
||||||
let cursorY = margin
|
const fetchBinaryFallback = async () => {
|
||||||
|
// Intenta construir la URL del runtime de Functions
|
||||||
// Variable para controlar si ya se dibujaron todas las secciones (para la última caja)
|
const anyClient = supabase as any;
|
||||||
let totalSections = 0;
|
const baseUrl =
|
||||||
let drawnSections = 0;
|
anyClient?.functions?.url ||
|
||||||
|
`${(anyClient?.supabaseUrl || "").replace(/\/$/, "")}/functions/v1`;
|
||||||
|
const { data: sess } = await supabase.auth.getSession();
|
||||||
|
const token = sess?.session?.access_token;
|
||||||
|
|
||||||
// --- Utilidades de Dibujo ---
|
console.log(JSON.stringify(planObj, null, 2));
|
||||||
|
console.log(planObj);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Dibuja el encabezado ("Anexo 1") y pie de página (Numeración) en todas las páginas
|
const resp = await fetch(`${baseUrl}/carbone-io-api`, {
|
||||||
const drawHeaderAndFooter = () => {
|
method: "POST",
|
||||||
// FIX: Usamos (doc as any) para acceder a getNumberOfPages() y evitar el error de TS
|
headers: {
|
||||||
const pageCount = (doc as any).internal.getNumberOfPages()
|
"Content-Type": "application/json",
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
Accept: "application/pdf",
|
||||||
doc.setPage(i)
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
// Encabezado (Anexo 1)
|
body: JSON.stringify({
|
||||||
doc.setFont("helvetica", "normal")
|
action: "downloadReport",
|
||||||
doc.setFontSize(10)
|
templateId: "1302213091201757023",
|
||||||
doc.text("Anexo 1", pageWidth - margin, margin - 5, { align: "right" })
|
fileName,
|
||||||
|
convertTo: "pdf",
|
||||||
|
data: planObj,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// Pie de página (Numeración)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
// Usamos el mismo tamaño y posición que en el ejemplo
|
const blob = await resp.blob();
|
||||||
doc.setFontSize(8)
|
triggerDownload(blob, fileName);
|
||||||
doc.text(
|
};
|
||||||
`Página ${i} de ${pageCount}`,
|
|
||||||
pageWidth - margin, // Posicionado a la derecha
|
|
||||||
pageHeight - 10,
|
|
||||||
{ align: "right" }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Regresar al último estado de la página para continuar dibujando
|
|
||||||
doc.setPage(pageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verifica si se necesita una nueva página antes de dibujar una línea o un elemento.
|
try {
|
||||||
const addPageIfNeeded = (neededHeight: number = lineHeight) => {
|
// const { data, error } = await supabase.functions.invoke(
|
||||||
// Aseguramos que haya espacio para la altura necesaria + un poco de margen de seguridad
|
// "carbone-io-api",
|
||||||
// El margen de seguridad ayuda a que la línea de pie de página no se solape
|
// {
|
||||||
if (cursorY + neededHeight > pageHeight - 15) {
|
// method: "POST",
|
||||||
doc.addPage()
|
// headers: { Accept: "application/octet-stream" }, // preferir binario
|
||||||
cursorY = margin
|
// body: {
|
||||||
// El encabezado "Anexo 1" se dibuja al final en drawHeaderAndFooter()
|
// action: "downloadReport",
|
||||||
}
|
// templateId: "1302213091201757023",
|
||||||
}
|
// fileName,
|
||||||
|
// convertTo: "pdf",
|
||||||
|
// data: planObj,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
/**
|
// if (error) throw error;
|
||||||
* Dibuja un título de sección con el estilo de recuadro gris (como en las capturas).
|
|
||||||
* Retorna la altura ocupada por el recuadro para el cálculo de la altura total de la sección.
|
|
||||||
*/
|
|
||||||
const drawHeadingBox = (text: string, marginTop: number = 0): number => {
|
|
||||||
doc.setFont("helvetica", "bold")
|
|
||||||
doc.setFontSize(headingFontSize)
|
|
||||||
|
|
||||||
// Espacio antes del título
|
|
||||||
cursorY += marginTop
|
|
||||||
|
|
||||||
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 ya viene binario, descargar directo
|
||||||
addPageIfNeeded(titleHeight + 5) // 5mm de margen de seguridad
|
// if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
// triggerDownload(data, fileName);
|
||||||
// 2. Dibujar Recuadro Gris (Relleno)
|
// return;
|
||||||
doc.setFillColor(230, 230, 230) // Gris claro
|
// }
|
||||||
doc.rect(margin, cursorY, maxWidth, titleHeight, "F")
|
// if (data instanceof ArrayBuffer) {
|
||||||
|
// triggerDownload(
|
||||||
// 3. Dibujar texto centrado
|
// new Blob([data], { type: "application/pdf" }),
|
||||||
const textX = pageWidth / 2
|
// fileName
|
||||||
const textY = cursorY + titleHeight / 2 + 0.8 // 0.8mm para centrado óptico
|
// );
|
||||||
doc.text(titleLines, textX, textY, { align: "center" })
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
cursorY += titleHeight // Avanzar el cursor justo después del recuadro
|
// // Si vino como string (ej. empieza con %PDF), usa el fallback binario
|
||||||
return titleHeight
|
// if (typeof data === "string") {
|
||||||
}
|
// await fetchBinaryFallback();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
/**
|
// // Si vino JSON con base64, decodificar y descargar
|
||||||
* Dibuja un bloque de texto (párrafo o lista) manejando el salto de página línea por línea,
|
// if (data && typeof data === "object") {
|
||||||
* y envuelto en un recuadro.
|
// const b64 =
|
||||||
*/
|
// (data as any).file || (data as any).buffer || (data as any).base64;
|
||||||
const drawContentBox = (text?: string | null, isList: boolean = false, isLastSection: boolean = false) => {
|
// if (typeof b64 === "string") {
|
||||||
// Manejamos 'text' que ahora puede ser string o number
|
// const clean = b64.replace(/^data:.*;base64,/, "");
|
||||||
const content = (text !== null && text !== undefined) ? String(text).trim() : "Sin información."
|
// const binary = atob(clean);
|
||||||
|
// const bytes = new Uint8Array(binary.length);
|
||||||
doc.setFont("helvetica", "normal")
|
// for (let i = 0; i < binary.length; i++)
|
||||||
doc.setFontSize(bodyFontSize)
|
// bytes[i] = binary.charCodeAt(i);
|
||||||
|
// triggerDownload(
|
||||||
let initialY = cursorY // Guardar Y inicial para dibujar el recuadro final
|
// new Blob([bytes], { type: "application/pdf" }),
|
||||||
|
// fileName
|
||||||
|
// );
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// El contenido se dibuja en un recuadro. Dejamos un padding interno.
|
// console.warn("Respuesta no reconocida para descarga de PDF.", {
|
||||||
const innerMargin = margin + 2
|
// type: typeof data,
|
||||||
const innerMaxWidth = maxWidth - 4
|
// });
|
||||||
let currentContentY = cursorY + 2 // Iniciar con 2mm de padding superior
|
|
||||||
|
|
||||||
// Dividir el contenido en bloques (párrafos o ítems de lista)
|
await fetchBinaryFallback();
|
||||||
const blocks = isList ?
|
return;
|
||||||
content.split('\n').filter(line => line.trim().length > 0) :
|
} catch (error) {
|
||||||
content.split('\n').filter(line => line.trim().length > 0)
|
console.error("Error al obtener PDF:", error);
|
||||||
|
|
||||||
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["nombre"] !== null && plan["nombre"] !== undefined ? String(plan["nombre"]) : "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;
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react";
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react";
|
||||||
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query"
|
import {
|
||||||
import { Button } from "@/components/ui/button"
|
useSuspenseQuery,
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
useMutation,
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
useQueryClient,
|
||||||
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
queryOptions,
|
||||||
import { toast } from "sonner"
|
} from "@tanstack/react-query";
|
||||||
import ReactMarkdown from 'react-markdown'
|
import { Button } from "@/components/ui/button";
|
||||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { supabase, useSupabaseAuth } from "@/auth/supabase";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import AIChatModal from "../ai/AIChatModal"
|
import AIChatModal from "../ai/AIChatModal";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Query keys & fetcher
|
Query keys & fetcher
|
||||||
@@ -19,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) =>
|
||||||
@@ -53,284 +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 }: { 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 [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
const auth = useSupabaseAuth();
|
||||||
const [draft, setDraft] = useState("")
|
const [openHistorial, setOpenHistorial] = useState(false);
|
||||||
|
const [openModalIa, setopenModalIa] = useState(false);
|
||||||
|
if (!planId) return <div>Cargando…</div>;
|
||||||
|
const { data: plan } = useSuspenseQuery(planTextOptions(planId));
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<null | {
|
||||||
|
key: keyof PlanTextFields;
|
||||||
|
title: string;
|
||||||
|
}>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
// --- 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>
|
||||||
|
</Dialog>
|
||||||
|
<HistorialCambiosModal
|
||||||
|
open={openHistorial}
|
||||||
|
onClose={() => setOpenHistorial(false)}
|
||||||
|
planId={planId}
|
||||||
|
onRestore={async (key, value) => {
|
||||||
|
updateField.mutate({ key, value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AIChatModal
|
||||||
</DialogFooter>
|
open={openModalIa}
|
||||||
</DialogContent>
|
onClose={() => setopenModalIa(false)}
|
||||||
</Dialog>
|
context={{
|
||||||
<HistorialCambiosModal
|
section: iaContext?.title,
|
||||||
open={openHistorial}
|
fieldKey: iaContext?.key,
|
||||||
onClose={() => setOpenHistorial(false)}
|
originalText: iaContext?.content,
|
||||||
planId={planId}
|
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.
|
||||||
onRestore={async (key, value) => {
|
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
|
||||||
updateField.mutate({ key: key as keyof PlanTextFields, value })
|
No uses introducciones, despedidas ni texto de relleno.
|
||||||
}}
|
Entrega solo el contenido útil.`,
|
||||||
/>
|
}}
|
||||||
|
onAccept={(newText: string) => {
|
||||||
<AIChatModal
|
if (iaContext) {
|
||||||
//plan_format={plan_format}
|
updateField.mutate({ key: iaContext.key, value: newText });
|
||||||
open={openModalIa}
|
setIaContext(null);
|
||||||
onClose={() => setopenModalIa(false)}
|
}
|
||||||
context={{
|
}}
|
||||||
section: null,//,iaContext?.title,
|
/>
|
||||||
fieldKey: null,//iaContext?.key,
|
|
||||||
originalText: iaContext?.content,
|
|
||||||
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`,
|
|
||||||
}}
|
|
||||||
onAccept={(newText: string) => {
|
|
||||||
if (iaContext) {
|
|
||||||
updateField.mutate({ key: iaContext.key, value: newText })
|
|
||||||
setIaContext(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"objetivo_general": "...",
|
|
||||||
"sistema_evaluacion": "...",
|
|
||||||
"perfil_ingreso": "...",
|
|
||||||
"perfil_egreso": "...",
|
|
||||||
"competencias_genericas": "...",
|
|
||||||
"competencias_especificas": "...",
|
|
||||||
"indicadores_desempeno": "...",
|
|
||||||
"pertinencia": "..."
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/auth/supabase";
|
import { supabase } from "@/auth/supabase";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +11,8 @@ export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
|
|||||||
tableName: string,
|
tableName: string,
|
||||||
idKey: keyof T = "id" as keyof T
|
idKey: keyof T = "id" as keyof T
|
||||||
) {
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// Generar diferencias tipo JSON Patch
|
// Generar diferencias tipo JSON Patch
|
||||||
function generateDiff(oldData: T, newData: Partial<T>) {
|
function generateDiff(oldData: T, newData: Partial<T>) {
|
||||||
const changes: any[] = [];
|
const changes: any[] = [];
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -149,6 +149,8 @@ function Layout() {
|
|||||||
|
|
||||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { claims } = useSupabaseAuth()
|
const { claims } = useSupabaseAuth()
|
||||||
|
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
|
||||||
|
|
||||||
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// routes/_authenticated/archivos.tsx
|
// routes/_authenticated/archivos.tsx
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { use, useMemo, useState } from "react"
|
||||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -9,204 +9,92 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
Select,
|
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
|
|
||||||
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
|
import type { RefRow } from "@/types/RefRow"
|
||||||
|
import { uuid } from "zod"
|
||||||
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")({
|
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
|
const { data, error } = await supabase
|
||||||
module: "vectorStores",
|
.from("documentos")
|
||||||
action: "list",
|
.select("*")
|
||||||
params: {
|
.order("fecha_subida", { ascending: false })
|
||||||
limit: 10,
|
.limit(200)
|
||||||
},
|
if (error) throw error
|
||||||
})
|
return (data ?? []) as RefRow[]
|
||||||
return stores ?? []
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ====== UI helpers ====== */
|
function chipTint(ok?: boolean | null) {
|
||||||
|
return ok
|
||||||
function StatusBadge({ status }: { status: string }) {
|
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||||
const label =
|
: "bg-amber-50 text-amber-800 border-amber-200"
|
||||||
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() {
|
function RouteComponent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const vectorStores = Route.useLoaderData() as VectorStore[]
|
const rows = Route.useLoaderData() as RefRow[]
|
||||||
|
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
|
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
|
||||||
const [selected, setSelected] = useState<VectorStore | null>(null)
|
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [viewing, setViewing] = useState<RefRow | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const term = q.trim().toLowerCase()
|
const t = q.trim().toLowerCase()
|
||||||
return vectorStores.filter((vs) => {
|
return rows.filter((r) => {
|
||||||
if (statusFilter !== "all" && vs.status !== statusFilter) return false
|
if (estado === "proc" && !r.procesado) return false
|
||||||
if (!term) return true
|
if (estado === "pend" && r.procesado) return false
|
||||||
return (
|
if (scope === "internos" && !r.interno) return false
|
||||||
(vs.name ?? "").toLowerCase().includes(term) ||
|
if (scope === "externos" && r.interno) return false
|
||||||
(vs.description ?? "").toLowerCase().includes(term)
|
|
||||||
)
|
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
|
||||||
})
|
})
|
||||||
}, [vectorStores, q, statusFilter])
|
}, [rows, q, estado, scope])
|
||||||
|
|
||||||
function openDetails(vs: VectorStore) {
|
async function remove(id: string) {
|
||||||
setSelected(vs)
|
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||||
setDialogOpen(true)
|
const { error } = await supabase
|
||||||
}
|
.from("documentos")
|
||||||
|
.delete()
|
||||||
|
.eq("documentos_id", id)
|
||||||
|
if (error) return alert(error.message)
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
|
|
||||||
setDeletingId(id)
|
|
||||||
try {
|
try {
|
||||||
await callFilesAndVectorStoresApi({
|
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
|
||||||
module: "vectorStores",
|
method: "DELETE",
|
||||||
action: "delete",
|
headers: { "Content-Type": "application/json" },
|
||||||
params: { vector_store_id: id },
|
body: JSON.stringify({ documentos_id: id }),
|
||||||
})
|
})
|
||||||
|
|
||||||
await supabase
|
if (!res.ok) {
|
||||||
.from("vector_store_files_meta")
|
throw new Error("Se falló al eliminar el documento")
|
||||||
.delete()
|
}
|
||||||
.eq("vector_store_id", id)
|
} catch (err) {
|
||||||
|
console.error("Error al eliminar el documento:", err)
|
||||||
router.invalidate()
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message ?? "Error al eliminar el repositorio")
|
|
||||||
} finally {
|
|
||||||
setDeletingId(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle className="font-mono">Repositorios de archivos</CardTitle>
|
<CardTitle className="font-mono">Archivos de referencia</CardTitle>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
<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">
|
<div className="relative w-full sm:w-80">
|
||||||
@@ -214,502 +102,240 @@ function RouteComponent() {
|
|||||||
<Input
|
<Input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
placeholder="Buscar por nombre o descripción…"
|
placeholder="Buscar por título, etiqueta, fuente…"
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
|
||||||
value={statusFilter}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setStatusFilter(v as "all" | "completed" | "in_progress")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="sm:w-[160px]">
|
<SelectTrigger className="sm:w-[160px]">
|
||||||
<SelectValue placeholder="Estado" />
|
<SelectValue placeholder="Estado" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Todos</SelectItem>
|
<SelectItem value="todos">Todos</SelectItem>
|
||||||
<SelectItem value="completed">Completados</SelectItem>
|
<SelectItem value="proc">Procesados</SelectItem>
|
||||||
<SelectItem value="in_progress">En proceso</SelectItem>
|
<SelectItem value="pend">Pendientes</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
|
||||||
<Icons.FolderPlus className="w-4 h-4 mr-2" />
|
<SelectTrigger className="sm:w-[160px]">
|
||||||
Nuevo repositorio
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{filtered.length ? (
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
{filtered.map((r) => (
|
||||||
{filtered.map((vs) => (
|
<article
|
||||||
<article
|
key={r.documentos_id}
|
||||||
key={vs.id}
|
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||||
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
>
|
||||||
>
|
<header className="min-w-0">
|
||||||
<header className="min-w-0 space-y-1">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
|
||||||
<h3 className="font-semibold truncate">
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
|
||||||
{vs.name || "(Sin nombre)"}
|
{r.procesado ? "Procesado" : "Pendiente"}
|
||||||
</h3>
|
</span>
|
||||||
<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>
|
||||||
</article>
|
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
|
||||||
))}
|
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
|
||||||
</div>
|
{r.interno != null && (
|
||||||
) : (
|
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
|
||||||
<div className="text-center text-sm text-neutral-500 py-10">
|
)}
|
||||||
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
|
{r.fecha_subida && (
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreateVectorStoreDialog
|
{/* Detalle */}
|
||||||
open={createOpen}
|
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
onCreated={() => router.invalidate()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VectorStoreDialog
|
{/* Subida */}
|
||||||
store={selected}
|
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
|
||||||
open={dialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setDialogOpen(open)
|
|
||||||
if (!open) setSelected(null)
|
|
||||||
}}
|
|
||||||
onUpdated={() => router.invalidate()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====== Crear repositorio ====== */
|
/* ========= 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)
|
||||||
|
|
||||||
function CreateVectorStoreDialog({
|
async function toBase64(f: File): Promise<string> {
|
||||||
open,
|
const buf = await f.arrayBuffer()
|
||||||
onOpenChange,
|
const bytes = new Uint8Array(buf)
|
||||||
onCreated,
|
let binary = ""
|
||||||
}: {
|
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||||
open: boolean
|
return btoa(binary)
|
||||||
onOpenChange: (open: boolean) => void
|
}
|
||||||
onCreated: () => void
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
|
|
||||||
async function handleCreate() {
|
async function upload() {
|
||||||
if (!name.trim()) {
|
if (!file) { alert("Selecciona un archivo"); return }
|
||||||
alert("Escribe un nombre para el repositorio")
|
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
|
||||||
return
|
|
||||||
}
|
setSubiendo(true)
|
||||||
setCreating(true)
|
|
||||||
try {
|
try {
|
||||||
await callFilesAndVectorStoresApi<VectorStore>({
|
const fileBase64 = await toBase64(file)
|
||||||
module: "vectorStores",
|
// Enviamos al motor (inserta en la tabla si insert=true)
|
||||||
action: "create",
|
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
|
||||||
params: { name: name.trim(), description: description.trim() || undefined },
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: instrucciones,
|
||||||
|
fileBase64,
|
||||||
|
insert: true,
|
||||||
|
uuid: supabaseAuth.user?.id ?? null,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
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)
|
onOpenChange(false)
|
||||||
setName("")
|
onDone()
|
||||||
setDescription("")
|
} catch (e: any) {
|
||||||
onCreated()
|
alert(e?.message ?? "Error al subir el documento")
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message ?? "Error al crear el repositorio")
|
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setSubiendo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
|
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Crea un Vector Store para agrupar archivos relacionados.
|
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.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Nombre</Label>
|
<Label>Archivo</Label>
|
||||||
<Input
|
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||||
value={name}
|
{file && (
|
||||||
onChange={(e) => setName(e.target.value)}
|
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
|
||||||
placeholder="Planeación curricular, Entrevistas…"
|
)}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Descripción (opcional)</Label>
|
<Label>Instrucciones</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={description}
|
value={instrucciones}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setInstrucciones(e.target.value)}
|
||||||
placeholder="Breve descripción del contenido de este repositorio."
|
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
|
||||||
className="min-h-[80px]"
|
className="min-h-[120px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||||
Cancelar
|
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
|
||||||
</Button>
|
{subiendo ? "Subiendo…" : "Subir"}
|
||||||
<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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
|||||||
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||||
import { supabase } from '@/auth/supabase'
|
import { supabase } from '@/auth/supabase'
|
||||||
import * as Icons from 'lucide-react'
|
import * as Icons from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
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)
|
const planIds = await fetchPlanIdsByScope(search)
|
||||||
if (planIds && planIds.length === 0) return []
|
if (planIds && planIds.length === 0) return []
|
||||||
console.log(AsignaturaCard);
|
console.log(AsignaturaCard);
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -168,25 +168,25 @@ function RouteComponent() {
|
|||||||
const [q, setQ] = useState(search.q ?? '')
|
const [q, setQ] = useState(search.q ?? '')
|
||||||
const [sem, setSem] = useState<string>('todos')
|
const [sem, setSem] = useState<string>('todos')
|
||||||
const [tipo, setTipo] = useState<string>('todos')
|
const [tipo, setTipo] = useState<string>('todos')
|
||||||
const [groupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
||||||
|
|
||||||
const [facultad, setFacultad] = useState("todas")
|
const [facultad, setFacultad] = useState("todas")
|
||||||
const [carrera, setCarrera] = useState("todas")
|
const [carrera, setCarrera] = useState("todas")
|
||||||
|
|
||||||
/* useEffect(() => {
|
/* useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
router.navigate({
|
router.navigate({
|
||||||
to: '/asignaturas',
|
to: '/asignaturas',
|
||||||
search: { ...search, q },
|
search: { ...search, q },
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
}, 400)
|
}, 400)
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [q]) */
|
}, [q]) */
|
||||||
|
|
||||||
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setQ(value)
|
setQ(value)
|
||||||
router.navigate({
|
router.navigate({
|
||||||
@@ -199,30 +199,30 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🟣 Lista única de facultades
|
// 🟣 Lista única de facultades
|
||||||
const facultadesList = useMemo(() => {
|
const facultadesList = useMemo(() => {
|
||||||
const unique = new Map<string, string>()
|
const unique = new Map<string, string>()
|
||||||
planes?.forEach((p) => {
|
planes?.forEach((p) => {
|
||||||
const fac = p.carrera?.facultad
|
const fac = p.carrera?.facultad
|
||||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||||
})
|
})
|
||||||
return Array.from(unique.entries())
|
return Array.from(unique.entries())
|
||||||
}, [planes])
|
}, [planes])
|
||||||
|
|
||||||
// 🎓 Lista de carreras según la facultad seleccionada
|
// 🎓 Lista de carreras según la facultad seleccionada
|
||||||
const carrerasList = useMemo(() => {
|
const carrerasList = useMemo(() => {
|
||||||
const unique = new Map<string, string>()
|
const unique = new Map<string, string>()
|
||||||
planes?.forEach((p) => {
|
planes?.forEach((p) => {
|
||||||
if (
|
if (
|
||||||
p.carrera?.id &&
|
p.carrera?.id &&
|
||||||
p.carrera?.nombre &&
|
p.carrera?.nombre &&
|
||||||
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
|
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
|
||||||
) {
|
) {
|
||||||
unique.set(p.carrera.id, p.carrera.nombre)
|
unique.set(p.carrera.id, p.carrera.nombre)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return Array.from(unique.entries())
|
return Array.from(unique.entries())
|
||||||
}, [planes, facultad])
|
}, [planes, facultad])
|
||||||
|
|
||||||
|
|
||||||
// NEW: Clonado individual
|
// NEW: Clonado individual
|
||||||
@@ -256,6 +256,12 @@ function RouteComponent() {
|
|||||||
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
|
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
|
||||||
}, [asignaturas])
|
}, [asignaturas])
|
||||||
|
|
||||||
|
const tipos = useMemo(() => {
|
||||||
|
const s = new Set<string>()
|
||||||
|
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
|
||||||
|
return Array.from(s).sort()
|
||||||
|
}, [asignaturas])
|
||||||
|
|
||||||
// Salud
|
// Salud
|
||||||
const salud = useMemo(() => {
|
const salud = useMemo(() => {
|
||||||
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
|
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
|
||||||
@@ -268,29 +274,29 @@ function RouteComponent() {
|
|||||||
}, [asignaturas])
|
}, [asignaturas])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const t = q.trim().toLowerCase()
|
const t = q.trim().toLowerCase()
|
||||||
return asignaturas.filter(a => {
|
return asignaturas.filter(a => {
|
||||||
const matchesQ =
|
const matchesQ =
|
||||||
!t ||
|
!t ||
|
||||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some(v => String(v).toLowerCase().includes(t))
|
.some(v => String(v).toLowerCase().includes(t))
|
||||||
|
|
||||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||||
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
|
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
|
||||||
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
|
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
|
||||||
const planOK = !search.planId || a.plan?.id === search.planId
|
const planOK = !search.planId || a.plan?.id === search.planId
|
||||||
|
|
||||||
const flagOK =
|
const flagOK =
|
||||||
!flag ||
|
!flag ||
|
||||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||||
|
|
||||||
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
|
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
|
||||||
})
|
})
|
||||||
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
||||||
|
|
||||||
|
|
||||||
// Agrupación
|
// Agrupación
|
||||||
@@ -310,19 +316,18 @@ function RouteComponent() {
|
|||||||
}, [filtered, groupBy])
|
}, [filtered, groupBy])
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
const clearFilters = () => {
|
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
|
||||||
setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas')
|
|
||||||
// Actualiza la URL limpiando todos los query params
|
// Actualiza la URL limpiando todos los query params
|
||||||
router.navigate({
|
router.navigate({
|
||||||
to: '/asignaturas',
|
to: '/asignaturas',
|
||||||
search: {
|
search: {
|
||||||
q: '',
|
q: '',
|
||||||
planId: '',
|
planId: '',
|
||||||
carreraId: '',
|
carreraId: '',
|
||||||
facultadId: '',
|
facultadId: '',
|
||||||
f: ''
|
f: ''
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: util para clonar 1 asignatura
|
// NEW: util para clonar 1 asignatura
|
||||||
@@ -358,7 +363,7 @@ function RouteComponent() {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// NEW: abrir modal clon individual
|
// NEW: abrir modal clon individual
|
||||||
function openClone(a: Asignatura) {
|
function openClone(a: Asignatura) {
|
||||||
@@ -545,12 +550,7 @@ function RouteComponent() {
|
|||||||
value={search.planId ?? "todos"}
|
value={search.planId ?? "todos"}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
router.navigate({
|
router.navigate({
|
||||||
to: '/asignaturas',
|
search: { ...search, planId: val === "todos" ? "" : val },
|
||||||
search: {
|
|
||||||
...search,
|
|
||||||
planId: val === 'todos' ? '' : val,
|
|
||||||
},
|
|
||||||
replace: true,
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -828,14 +828,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
|||||||
const horasT = a.horas_teoricas ?? 0
|
const horasT = a.horas_teoricas ?? 0
|
||||||
const horasP = a.horas_practicas ?? 0
|
const horasP = a.horas_practicas ?? 0
|
||||||
const meta = tipoMeta(a.tipo)
|
const meta = tipoMeta(a.tipo)
|
||||||
|
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
||||||
console.log(a);
|
console.log(a);
|
||||||
|
|
||||||
return (
|
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"
|
<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={{
|
style={{
|
||||||
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
||||||
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -889,15 +890,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
|||||||
{a.plan.carrera && (
|
{a.plan.carrera && (
|
||||||
<InfoChip
|
<InfoChip
|
||||||
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||||
label={a.plan.carrera.nombre}
|
label={a.plan.carrera.nombre}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{a.plan.carrera?.facultad && (
|
{a.plan.carrera?.facultad && (
|
||||||
<InfoChip
|
<InfoChip
|
||||||
icon={<Icons.Building2 className="h-3 w-3" />}
|
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||||
label={a.plan.carrera.facultad.nombre}
|
label={a.plan.carrera.facultad.nombre}
|
||||||
tint={a.plan.carrera.facultad.color}
|
tint={a.plan.carrera.facultad.color}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
|
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
|
||||||
import { GradientMesh } from "../../../components/planes/GradientMesh"
|
import { GradientMesh } from "../../../components/planes/GradientMesh"
|
||||||
import { asignaturaExtraOptions, asignaturaKeys, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
|
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
|
||||||
import { softAccentStyle } from "@/components/planes/planHelpers"
|
import { softAccentStyle } from "@/components/planes/planHelpers"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"
|
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* -------------------- Tipos -------------------- */
|
||||||
|
|
||||||
|
|
||||||
|
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -------------------- Query Keys & Fetcher -------------------- */
|
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||||
const usersKeys = {
|
const usersKeys = {
|
||||||
@@ -142,6 +149,35 @@ function RouteComponent() {
|
|||||||
carrera_id?: string | null
|
carrera_id?: string | null
|
||||||
}>({ email: "", password: "" })
|
}>({ 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 }) {
|
function RolePill({ role }: { role: Role }) {
|
||||||
const meta = ROLE_META[role]
|
const meta = ROLE_META[role]
|
||||||
@@ -161,6 +197,61 @@ function RouteComponent() {
|
|||||||
router.invalidate()
|
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({
|
const createUser = useMutation({
|
||||||
mutationFn: async (payload: typeof createForm) => {
|
mutationFn: async (payload: typeof createForm) => {
|
||||||
// Validaciones previas
|
// Validaciones previas
|
||||||
@@ -318,7 +409,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} 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"}
|
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
||||||
@@ -334,7 +425,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||||
<Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
|
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} 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>
|
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationFallback": {
|
|
||||||
"rewrite": "/index.html",
|
|
||||||
"exclude": [
|
|
||||||
"/assets/*",
|
|
||||||
"/*.css",
|
|
||||||
"/*.js",
|
|
||||||
"/*.ico",
|
|
||||||
"/*.png",
|
|
||||||
"/*.jpg",
|
|
||||||
"/*.svg"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user