43 Commits

Author SHA1 Message Date
1475a65938 genración del pdf de plan de estudios ya funciona 2025-12-04 14:01:09 -06:00
9a1d8279a1 los planes de estudios ya tienen la estructura de la plantilla de la sep. Ya se visualizan con esa estructura y se pueden crear planes con dicha estructura 2025-11-28 02:55:04 -06:00
0456a1063d wip 2025-11-27 19:41:44 -06:00
a41136a224 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-27 15:05:17 -06:00
102c21927e gitingore modificado 2025-11-27 15:04:47 -06:00
566e23ad34 Se quitan respuestas del asistente y se agrega boton de cerrar modal 2025-11-25 15:21:32 -06:00
872c495d30 Se agrega modelo de respuestas y conversaciones archivos multiples y contexto de id plan de estudios 2025-11-25 11:34:00 -06:00
7951f9d8c5 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-24 16:17:53 -06:00
4894543c57 Se agrega funcionalidades de crear conversacion, archivos y vectores ademas de MCP 2025-11-24 16:17:49 -06:00
efe7faa65f Cambios de Roberto 2025-11-21 17:05:16 -06:00
c9d66ce2e5 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-18 15:17:15 -06:00
f7a29ad510 Version estable conversacion normal 2025-11-18 15:17:11 -06:00
e7a47f56f8 Merge pull request '[#67] dummy' (!2) from task/67-dummy into main
Reviewed-on: #2
2025-11-13 21:32:48 +00:00
214d17cf98 [#67] dummy
https://proyectos.apps.lci.ulsa.mx/work_packages/67
2025-11-13 15:22:28 -06:00
8c890d76e0 Se agrega titulo a pdf 2025-11-13 10:23:04 -06:00
7105b286bf Se agrega componente de ia y pdf 2025-11-13 10:02:26 -06:00
0e884f20c5 Se agrega funcionalidad de historico de cambios 2025-11-07 07:23:02 -06:00
8bb8399ec5 Merge branch 'feature/PdfAndHistorico' 2025-11-05 15:20:52 -06:00
9462e25a20 Se crea funcionalidad de exportar pdf desde front y generar historial de version de cambios se agrego una libreri jspdf 2025-11-05 15:19:38 -06:00
daac6f3f6d Se agregan filtros de plan de estudios, carrera y se limpian filtros 2025-10-30 14:50:48 -06:00
6d264a8214 Merge branch 'master' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-10-30 14:38:56 -06:00
4cf93ff1f4 La pantalla se volvía negra al abrir el dialogo de eliminar carrera
La razón es que se rendereaba un dialogo de borrado por carrera, pero al abrir uno se abrian los demás también
2025-10-30 07:47:37 -06:00
d25b8b0441 Se corrigen bugs sobre crear carreras, filtrado y que aparezcan las materias cuando se crean 2025-10-30 07:46:40 -06:00
bec6405c54 Se agrgan filtros 2025-10-29 14:44:47 -06:00
53502d927b Se agregan filros por carrera y facultad 2025-10-29 14:43:19 -06:00
6e2b3d72f1 Se envían correctamente los ids de los archivos de referencia para su procesamiento en el backend 2025-10-27 17:14:50 -06:00
0c5c3f935b comm 2025-10-27 15:45:16 -06:00
8da08b6bf1 La pantalla se volvía negra al abrir el dialogo de eliminar carrera
La razón es que se rendereaba un dialogo de borrado por carrera, pero al abrir uno se abrian los demás también
2025-10-24 13:01:33 -06:00
1fe8f2b6a8 Se corrigen bugs sobre crear carreras, filtrado y que aparezcan las materias cuando se crean 2025-10-24 12:36:39 -06:00
78580df13b Markdown 2025-10-23 15:37:30 -06:00
ff82d0c364 Fix button archivos 2025-10-23 15:29:41 -06:00
14b188d3ca Merge branch 'master' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-10-23 13:02:40 -06:00
3fccdc0478 commit wip 2025-10-22 15:54:42 -06:00
d491100c73 Se añadió el botón de eliminar asignatura, y se borra adecuadamente 2025-10-21 17:05:45 -06:00
ce2cd6b397 Eliminada dependencia de llave de servicio para el manejo de usuarios y eliminado el hard-code de los roles
supabase.tsx
Se añadieron los roles al contexto de autenticación. Se modificó la interfaz de UserClaims que consiste en la información que se obtiene de los usuarios. Se obtienen los roles desde la base de datos.

_authenticated.tsx
Ya todos pueden ver el enlace a la página de facultades.

login.tsx
Se movió el enlace de '¿Olvidaste tu contraseña?' a después del input de la contraseña, para mejorar la usabilidad.

usuarios.tsx
- La obtención de los usuarios ahora se hace a con el cliente de llave anónima de supabase y se obtiene de tablas en el esquema public a través de una función de PostgreSQL.
- La información de los roles se obtiene del contexto de autenticación para mostrarla en la página.
- El RolePill se movió a dentro del componente para poder usar la información del contexto.
- Se añadieron validaciones para poder crear un usuario.
- Se muestra la información para editar los usuarios y se actualiza en la BDD con una función de PostgreSQL.
2025-10-20 17:09:14 -06:00
f2b3010ac9 Ahora se obtienen claims de las tablas en el esquema public, en vez de la información de sesion del usuario, que se obtiene de la tabla auth.users
En supabase.tsx se sustituyó la manera de obtener los claims del usuario, utilizando ahora un rpc de una función en supabase.
2025-10-10 17:23:37 -06:00
c49c0bbc0a Bugfix de botones anidados, facultad y carrera faltantes de la card de plan de estudios, campo opcional marcado como tal
CreatePlanDialog: el botón con el que selecciona al archivo de referencia se cambió a div para evitar posibles problemas.

planes: se limitó el número de caracteres del estado que se pueden mostrar para darle espacio al div de la facultad y la carrera.

usuarios: se añadió un texto small para indicar que el campo de título es opcional. Se puede hacer lo mismo con los demás en un futuro.
2025-10-06 16:34:19 -06:00
101758da24 Se quitó botón de editar prompt, se arregló el bug de no encontrar el plan de estudios por el uuid al estar idle la página, y se arregló el bug de visualización de archivos en el modal de crear plan de estudios
Academic-sections: ya se renderea condicionalmente el botón de editar prompt.

AddAsignaturaButton: se quitaron llamadas redundantes de invalidateQueries.

CreatePlanDialog: ya no se selecciona la columna de s3_file_path porque ya no existe.

$planId: el bug de no encontrar el plan de estudios por el uuid al estar idle la página probablemente era causado por llamar de manera redundante a planByIdOptions(), asignaturasCountOptions() y asignaturasPreviewOptions() en el componente. Ahora desde el loader se obtiene toda la información del plan de estudios y sus asignaturas.
2025-10-06 12:50:38 -06:00
e03d5f5e36 Parte del bugfix/feature para guardar los prompts en la BDD 2025-10-03 16:07:02 -06:00
b3ca317e5e Feature: al borrar un archivo, ya se borra de minio también
Se añadió la peticion DELETE para borrar un archivo de minio
2025-10-03 12:58:29 -06:00
e12d0ad8b1 Parte de la actualización de obtención de documentos a partir del documentos_id
Ahora se usa documentos_id para obtener los documentos de referencia de minio, por lo que se cambiaron los nombres de las variables utilizadas
2025-10-03 12:18:25 -06:00
4be34e8d6a bug de no cargar visualización de archivos de referencia con filenames con caracteres no permitidos 2025-10-02 11:35:02 -06:00
da4cf5a5e0 se envía uuid de usuario
Se envia el uuid del usuario para para ponerle dueño a las asignaturas y planes de estudio creadas, y a los documentos de referencia subidos
2025-10-01 19:20:24 -06:00
35 changed files with 9015 additions and 705 deletions

3
.gitignore vendored
View File

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

22
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "msedge",
"request": "launch",
"name": "Launch Edge against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}

205
bun.lock
View File

@@ -32,10 +32,13 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"gsap": "^3.13.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^3.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
@@ -465,22 +468,42 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@@ -519,6 +542,10 @@
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -531,8 +558,20 @@
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -545,10 +584,16 @@
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -583,6 +628,8 @@
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@@ -591,10 +638,14 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.204", "", {}, "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
@@ -611,14 +662,22 @@
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -637,8 +696,16 @@
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -647,16 +714,30 @@
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isbot": ["isbot@5.1.30", "", {}, "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA=="],
@@ -671,6 +752,10 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"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=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -693,6 +778,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -703,6 +790,64 @@
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
@@ -721,12 +866,18 @@
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -737,14 +888,20 @@
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -765,10 +922,18 @@
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rollup": ["rollup@4.46.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.3", "@rollup/rollup-android-arm64": "4.46.3", "@rollup/rollup-darwin-arm64": "4.46.3", "@rollup/rollup-darwin-x64": "4.46.3", "@rollup/rollup-freebsd-arm64": "4.46.3", "@rollup/rollup-freebsd-x64": "4.46.3", "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", "@rollup/rollup-linux-arm-musleabihf": "4.46.3", "@rollup/rollup-linux-arm64-gnu": "4.46.3", "@rollup/rollup-linux-arm64-musl": "4.46.3", "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", "@rollup/rollup-linux-ppc64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-musl": "4.46.3", "@rollup/rollup-linux-s390x-gnu": "4.46.3", "@rollup/rollup-linux-x64-gnu": "4.46.3", "@rollup/rollup-linux-x64-musl": "4.46.3", "@rollup/rollup-win32-arm64-msvc": "4.46.3", "@rollup/rollup-win32-ia32-msvc": "4.46.3", "@rollup/rollup-win32-x64-msvc": "4.46.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
@@ -795,12 +960,24 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
"style-to-js": ["style-to-js@1.1.18", "", { "dependencies": { "style-to-object": "1.0.11" } }, "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg=="],
"style-to-object": ["style-to-object@1.0.11", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow=="],
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
@@ -811,6 +988,8 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
@@ -837,6 +1016,10 @@
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.20.4", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg=="],
@@ -847,6 +1030,18 @@
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unplugin": ["unplugin@2.3.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-+/MdXl8bLTXI2lJF22gUBeCFqZruEpL/oM9f8wxCuKh9+Mw9qeul3gTqgbKpMeOFlusCzc0s7x2Kax2xKW+FQg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
@@ -857,8 +1052,14 @@
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
@@ -893,6 +1094,8 @@
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@supabase/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -921,6 +1124,8 @@
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

5833
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -34,14 +34,18 @@
"@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.3",
"carbone-sdk-js": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"gsap": "^3.13.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^3.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
@@ -62,4 +66,4 @@
"vitest": "^3.0.5",
"web-vitals": "^4.2.4"
}
}
}

View File

@@ -10,26 +10,29 @@ export interface SupabaseAuthState {
isAuthenticated: boolean
user: User | null
claims: UserClaims | null
roles: RolCatalogo[] | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
type Role =
| 'lci'
| 'vicerrectoria'
| 'director_facultad' // 👈 NEW
| 'secretario_academico'
| 'jefe_carrera'
| 'planeacion'
export interface RolCatalogo {
id: string
nombre: string
icono: string
nombre_clase: string
label: string
}
type UserClaims = {
claims_admin: boolean
clave: string
export type Role = string;
export type UserClaims = {
id: string | null
clave?: string
nombre: string
apellidos: string
title: string
avatar: string | null
title?: string
avatar?: string | null
carrera_id?: string | null
facultad_id?: string | null
facultad_color?: string | null // 🎨 NEW
@@ -41,28 +44,35 @@ const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefin
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [claims, setClaims] = useState<UserClaims | null>(null)
const [roles, setRoles] = useState<RolCatalogo[] | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Función para manejar la sesión
const handleSession = async (session: Session | null) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}
// Carga inicial
supabase.auth.getSession().then(async ({ data: { session } }) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
supabase.auth.getSession().then(({ data: { session } }) => {
handleSession(session)
})
// Carga roles catálogo
fetchRoles().then(fetchedRoles => {
setRoles(fetchedRoles);
});
// Suscripción a cambios de sesión
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
handleSession(session)
})
return () => subscription.unsubscribe()
}, [])
@@ -80,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
return (
<SupabaseAuthContext.Provider
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
value={{ isAuthenticated, user, claims, roles, login, logout, isLoading }}
>
{children}
</SupabaseAuthContext.Provider>
@@ -99,49 +109,54 @@ export function useSupabaseAuth() {
* Helpers
* ===================== */
// Unifica extracción de metadatos y resuelve facultad_color si hay facultad_id
// Obtiene los claims del usuario desde la base de datos a partir de una función en la BDD
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
const u = session?.user
if (!u) return null
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
// Mezcla segura: app_metadata > user_metadata (para campos de claims)
const base: Partial<UserClaims> = {
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin),
role: (app.role as Role | undefined) ?? ('lci' as Role),
facultad_id: app.facultad_id ?? meta.facultad_id ?? null,
carrera_id: app.carrera_id ?? meta.carrera_id ?? null,
clave: (meta.clave as string) ?? '',
nombre: (meta.nombre as string) ?? '',
apellidos: (meta.apellidos as string) ?? '',
title: (meta.title as string) ?? '',
avatar: (meta.avatar as string) ?? null,
// Validar sesión
if (!session || !session.user) {
console.warn('No session or user found');
return null;
}
const u = session.user;
let facultad_color: string | null = null
if (base.facultad_id) {
// Lee color desde public.facultades
const { data, error } = await supabase
.from('facultades')
.select('color')
.eq('id', base.facultad_id)
.maybeSingle()
if (!error && data) facultad_color = (data as any)?.color ?? null
}
try{
const result = await supabase.rpc('obtener_claims_usuario', {
p_user_id: u.id,
});
const data: UserClaims[] | null = result.data;
const error = result.error;
return {
claims_admin: !!base.claims_admin,
role: (base.role ?? 'lci') as Role,
clave: base.clave ?? '',
nombre: base.nombre ?? '',
apellidos: base.apellidos ?? '',
title: base.title ?? '',
avatar: base.avatar ?? null,
facultad_id: (base.facultad_id as string | null) ?? null,
carrera_id: (base.carrera_id as string | null) ?? null,
facultad_color, // 🎨
if (error) {
console.error('Error al obtener la información:', error);
throw new Error('Error al obtener la información del usuario');
}
console.log(data);
if (!data || data.length === 0) {
console.warn('No se encontró información para el usuario');
return null;
}
return {
...data[0],
id: null
};
} catch (e) {
console.error('Error inesperado:', e);
return null;
}
}
async function fetchRoles(): Promise<RolCatalogo[]> {
const { data, error } = await supabase
.from("roles_catalogo")
.select("id, nombre, icono, nombre_clase, label");
if (error) {
console.error("Error al obtener los roles:", error.message);
return [];
}
return data || [];
}

View File

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

View File

@@ -7,7 +7,7 @@ import * as Icons from "lucide-react"
import type { RefRow } from "@/types/RefRow"
// POST -> recibe blob PDF y (opcional) Content-Disposition
async function fetchPdfBlob(url: string, body: { s3_file_path: string }) {
async function fetchPdfBlob(url: string, body: { documentos_id: string }) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -35,6 +35,7 @@ export function DetailDialog({
onClose: () => void
pdfUrl?: string
}) {
console.log("DetailDialog render", { row })
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
const [filename, setFilename] = useState<string>("archivo.pdf")
@@ -48,13 +49,15 @@ export function DetailDialog({
const ctrl = new AbortController()
async function load() {
if (!row?.s3_file_path) {
console.log(row)
if (!row?.documentos_id) {
setViewerUrl(null)
setCurrentBlob(null)
console.warn("No hay documentos_id en el row")
return
}
try {
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { documentos_id: row.documentos_id })
if (ctrl.signal.aborted) return
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
setFilename(name)
@@ -94,8 +97,8 @@ export function DetailDialog({
}
// Si no, vuelve a pedirlo (p. ej., si el user abre y descarga sin render previo)
if (!row?.s3_file_path) throw new Error("No hay contenido para descargar.")
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
if (!row?.documentos_id) throw new Error("No hay contenido para descargar.")
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { documentos_id: row.documentos_id })
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
const link = document.createElement("a")
const href = URL.createObjectURL(blob)
@@ -111,7 +114,7 @@ export function DetailDialog({
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogContent className="max-w-fit">
<DialogHeader>
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
@@ -131,13 +134,13 @@ export function DetailDialog({
</span>
)}
</div>
{/* Tags
{row.tags?.length ? (
<div className="text-xs text-neutral-600">
<span className="font-medium">Tags: </span>
{row.tags.join(", ")}
</div>
) : null}
) : null} */}
<div>
<Label className="text-xs text-neutral-600">Instrucciones</Label>

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase"
import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { Button } from "../ui/button"
import {
Dialog,
@@ -35,11 +35,13 @@ export function EditBibliografiaButton({
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [text, setText] = useState("")
const auth = useSupabaseAuth()
const initialTextRef = useRef("")
const lines = useMemo(() => parseLines(text), [text])
const dirty = useMemo(() => initialTextRef.current !== text, [text])
// 🔹 Abre el editor y carga los valores actuales
function openEditor() {
const start = (value ?? []).join("\n")
setText(start)
@@ -47,52 +49,110 @@ export function EditBibliografiaButton({
setOpen(true)
}
// ✅ Función para generar diferencias tipo JSON Patch
function generateDiff(oldRefs: string[], newRefs: string[]) {
const changes: any[] = []
// Si son distintos en contenido o longitud
if (JSON.stringify(oldRefs) !== JSON.stringify(newRefs)) {
changes.push({
op: "replace",
path: "/bibliografia",
from: oldRefs,
value: newRefs,
})
}
return changes
}
async function save() {
setSaving(true)
try {
setSaving(true)
const refs = parseLines(text)
// 1⃣ Obtener bibliografía anterior
const { data: oldData, error: oldError } = await supabase
.from("asignaturas")
.select("bibliografia")
.eq("id", asignaturaId)
.maybeSingle()
if (oldError) throw oldError
const oldRefs = oldData?.bibliografia ?? []
const newRefs = parseLines(text)
// 2⃣ Generar diferencias
const diff = generateDiff(oldRefs, newRefs)
// 3⃣ Guardar respaldo si hay cambios
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // misma tabla de respaldo
.insert({
id_asignatura: asignaturaId,
json_cambios: diff, // jsonb
user_id: auth.user?.id,
created_at: new Date().toISOString(),
})
if (backupError) throw backupError
}
// 4⃣ Actualizar bibliografía en asignaturas
const { data, error } = await supabase
.from("asignaturas")
.update({ bibliografia: refs })
.update({ bibliografia: newRefs })
.eq("id", asignaturaId)
.select()
.maybeSingle()
if (error) throw error
onSaved((data as any)?.bibliografia ?? refs)
initialTextRef.current = refs.join("\n")
toast.success(`${refs.length} referencia(s) guardada(s).`)
// 5⃣ Refrescar estado local
onSaved((data as any)?.bibliografia ?? newRefs)
initialTextRef.current = newRefs.join("\n")
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "No se pudo guardar")
} catch (err: any) {
toast.error(err.message ?? "No se pudo guardar la bibliografía")
} finally {
setSaving(false)
}
}
// Acciones
// 🔧 Acciones extra
function actionTrim() {
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
setText(next.join("\n"))
}
function actionDedupe() {
const seen = new Set<string>()
const next: string[] = []
for (const l of parseLines(text)) {
const k = l.toLowerCase()
if (!seen.has(k)) { seen.add(k); next.push(l) }
if (!seen.has(k)) {
seen.add(k)
next.push(l)
}
}
setText(next.join("\n"))
}
function actionSort() {
const next = [...parseLines(text)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
const next = [...parseLines(text)].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }),
)
setText(next.join("\n"))
}
async function actionImportClipboard() {
try {
const clip = await navigator.clipboard.readText()
if (!clip) { toast("Portapapeles vacío"); return }
if (!clip) {
toast("Portapapeles vacío")
return
}
const next = [...parseLines(text), ...parseLines(clip)]
setText(next.join("\n"))
toast.success("Texto importado")
@@ -100,6 +160,7 @@ export function EditBibliografiaButton({
toast.error(e?.message ?? "No se pudo leer el portapapeles")
}
}
async function actionExportClipboard() {
try {
await navigator.clipboard.writeText(parseLines(text).join("\n"))
@@ -109,7 +170,7 @@ export function EditBibliografiaButton({
}
}
// Atajo guardar
// ⌨️ Atajo Ctrl+S
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!open) return
@@ -120,7 +181,6 @@ export function EditBibliografiaButton({
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, saving, dirty, text])
return (

View File

@@ -30,7 +30,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
const dialog = (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogContent className="bg-white">
<DialogHeader>
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
<DialogDescription>

View File

@@ -0,0 +1,92 @@
import { useQuery } from "@tanstack/react-query"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { supabase } from "@/auth/supabase"
import ReactMarkdown from "react-markdown"
import { useSupabaseAuth } from "@/auth/supabase"
export function HistorialCambiosModal({
open,
onClose,
planId,
onRestore, // 🔥 recibiremos una función del padre para restaurar
}: {
open: boolean
onClose: () => void
planId: string
onRestore: (key: string, value: string) => void
}) {
const auth = useSupabaseAuth()
const { data, isLoading, error } = useQuery({
queryKey: ["historico_cambios", planId, auth.user?.id],
queryFn: async () => {
const { data, error } = await supabase
.from("historico_cambios")
.select("id, json_cambios, user_id, created_at")
.eq("id_plan_estudios", planId)
.eq("user_id", auth.user?.id) // ✅ filtro por usuario actual
.order("created_at", { ascending: false })
if (error) throw error
return data
},
enabled: !!auth.user?.id, // ✅ solo corre si hay usuario autenticado
})
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Histórico de cambios</DialogTitle>
</DialogHeader>
{isLoading && <p className="text-sm text-gray-500">Cargando historial...</p>}
{error && <p className="text-red-500 text-sm">Error al cargar: {String(error)}</p>}
{!isLoading && !error && (!data || data.length === 0) && (
<p className="text-gray-500 text-sm">No hay cambios registrados.</p>
)}
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
{data?.map((item) => {
const diff = item.json_cambios?.[0]
const key = diff?.path?.replace("/", "")
return (
<div
key={item.id}
className="rounded-lg border p-3 bg-white/70 dark:bg-neutral-900/50"
>
<div className="flex justify-between text-xs text-neutral-500 mb-2">
<span>Usuario: {item.user_id || "Desconocido"}</span>
<span>{new Date(item.created_at).toLocaleString()}</span>
</div>
<div className="text-xs text-gray-700 font-mono whitespace-pre-wrap">
<p><strong>Campo:</strong> {key}</p>
<p><strong>Antes:</strong> {diff?.from || "—"}</p>
<p><strong>Después:</strong> {diff?.value || "—"}</p>
</div>
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => onRestore(key, diff.from)}
>
Restaurar
</Button>
</div>
)
})}
</div>
<div className="mt-4 text-right">
<Button variant="outline" onClick={onClose}>
Cerrar
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -7,13 +7,16 @@ import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from "canvas-confetti"
import { useQueryClient } from "@tanstack/react-query"
import { supabase } from "@/auth/supabase"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries"
import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const router = useRouter()
const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [mode, setMode] = useState<"manual" | "ia">("manual")
@@ -42,32 +45,47 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
horas_teoricas: toNum(f.horas_teoricas),
horas_practicas: toNum(f.horas_practicas),
objetivos: toNull(f.objetivos),
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
contenidos: [], bibliografia: [], criterios_evaluacion: null,
}
const { error } = await supabase.from("asignaturas").insert([payload])
const { error,data } = await supabase.from("asignaturas").insert([payload]).select().single()
console.log(data);
router.invalidate()
router.navigate({
to: "/asignatura/$asignaturaId",
params: { asignaturaId: data.id },
})
setSaving(false)
if (error) { alert(error.message); return }
setOpen(false)
onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
}
async function createWithAI() {
if (!canIA) return
setSaving(true)
// inserte la asignatura generada directamente
// obtengas el uuid que se insertó
try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
console.log("Asignatura generada:", data)
const asignaturaId = data.asignaturaId || data.insertResult?.id
if (!asignaturaId) throw new Error("No se recibió el ID de la asignatura generada")
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false)
router.invalidate()
router.navigate({
to: "/asignatura/$asignaturaId",
params: { asignaturaId },
})
onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} catch (e: any) {
alert(e?.message ?? "Error al generar la asignatura")
} finally { setSaving(false) }

View File

@@ -1,15 +1,24 @@
import { useRouter } from "@tanstack/react-router"
import { useSupabaseAuth } from "@/auth/supabase"
import { useState, useEffect, useCallback } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
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 { useRouter } from "@tanstack/react-router";
import { useSupabaseAuth } from "@/auth/supabase";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
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";
// ————————————————————————————————————————————————————————————————
@@ -50,62 +59,78 @@ function extIcon(ext: string) {
// ————————————————————————————————————————————————————————————————
// Componente principal
// ————————————————————————————————————————————————————————————————
export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const router = useRouter()
const auth = useSupabaseAuth()
const role = auth.claims?.role
export function CreatePlanDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const router = useRouter();
const auth = useSupabaseAuth();
const role = auth.claims?.role;
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
const [nivel, setNivel] = useState("")
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "");
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "");
const [nivel, setNivel] = useState("");
const [prompt, setPrompt] = useState(
"Genera un plan de estudios claro y realista: "
)
);
const [dbFiles, setDbFiles] = useState<{
id: string;
titulo: string;
s3_file_path: string;
fecha_subida?: string;
tags?: string[];
}[]>([])
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null)
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const debouncedSearchTerm = useDebounce(searchTerm, 300)
const [dbFiles, setDbFiles] = useState<
{
id: string;
titulo: string;
s3_file_path: string;
fecha_subida?: string;
tags?: string[];
}[]
>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(
null
);
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 [previewRow, setPreviewRow] = useState<RefRow | null>(null);
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
const lockCarrera = role === "jefe_carrera"
const lockFacultad =
role === "secretario_academico" || role === "jefe_carrera";
const lockCarrera = role === "jefe_carrera";
useEffect(() => {
async function fetchDbFiles() {
try {
const { data, error } = await supabase
.from("fine_tuning_referencias")
.select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags")
.from("documentos")
.select("documentos_id, titulo_archivo, fecha_subida, tags")
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
.range(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage - 1
);
if (error) {
console.error("Error fetching files from database:", error);
return;
}
setDbFiles((data || []).map((file: any) => ({
id: file.fine_tuning_referencias_id,
titulo: file.titulo_archivo,
s3_file_path: file.s3_file_path,
fecha_subida: file.fecha_subida,
tags: file.tags || [],
})));
setDbFiles(
(data || []).map((file: any) => ({
id: file.documentos_id,
titulo: file.titulo_archivo,
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
fecha_subida: file.fecha_subida,
tags: file.tags || [],
}))
);
} catch (err) {
console.error("Unexpected error fetching files:", err);
}
@@ -114,41 +139,59 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
if (open) fetchDbFiles();
}, [open, debouncedSearchTerm, currentPage]);
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
const isSelected = useCallback(
(path: string) => selectedFiles.includes(path),
[selectedFiles]
);
const toggleSelected = useCallback((path: string) => {
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]);
const toggleSelected = useCallback((id: string) => {
setSelectedFiles((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
);
}, []);
const replaceSelection = useCallback((path: string) => {
setSelectedFiles([path]);
const replaceSelection = useCallback((id: string) => {
setSelectedFiles([id]);
}, []);
const rangeSelect = useCallback((start: number, end: number) => {
const [s, e] = start < end ? [start, end] : [end, start];
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path);
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths])));
}, [dbFiles]);
const rangeSelect = useCallback(
(start: number, end: number) => {
const [s, e] = start < end ? [start, end] : [end, start];
const ids = dbFiles.slice(s, e + 1).map((f) => f.id);
setSelectedFiles((prev) => Array.from(new Set([...prev, ...ids])));
},
[dbFiles]
);
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => {
const path = file.s3_file_path;
const handleCardClick = useCallback(
(e: React.MouseEvent, index: number, file: { id: string }) => {
const id = file.id;
if (e.shiftKey && lastSelectedIndex !== null) {
rangeSelect(lastSelectedIndex, index);
} else if (e.metaKey || e.ctrlKey) {
toggleSelected(path);
setLastSelectedIndex(index);
} else {
if (isSelected(path) && selectedFiles.length === 1) {
// si ya es el único seleccionado, des-selecciona
setSelectedFiles([]);
setLastSelectedIndex(null);
} else {
replaceSelection(path);
if (e.shiftKey && lastSelectedIndex !== null) {
rangeSelect(lastSelectedIndex, index);
} else if (e.metaKey || e.ctrlKey) {
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);
}
}
}
}, [isSelected, lastSelectedIndex, rangeSelect, replaceSelection, selectedFiles.length, toggleSelected]);
},
[
isSelected,
lastSelectedIndex,
rangeSelect,
replaceSelection,
selectedFiles.length,
toggleSelected,
]
);
const clearSelection = () => {
setSelectedFiles([]);
@@ -156,29 +199,51 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
};
async function crearConIA() {
setErr(null)
if (!carreraId) { setErr("Selecciona una carrera."); return }
setSaving(true)
setErr(null);
if (!carreraId) {
setErr("Selecciona una carrera.");
return;
}
setSaving(true);
try {
const res = await postAPI("/api/generar/plan", {
carreraId,
prompt,
insert: true,
files: selectedFiles,
})
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const { data, error } = await supabase.functions.invoke(
"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) {
onOpenChange(false)
router.invalidate()
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
onOpenChange(false);
router.invalidate();
router.navigate({ to: "/plan/$planId", params: { planId: newId } });
} else {
onOpenChange(false)
router.invalidate()
onOpenChange(false);
router.invalidate();
}
} 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 {
setSaving(false)
setSaving(false);
}
}
@@ -189,7 +254,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-[65vw] max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-mono">Nuevo plan de estudios (IA)</DialogTitle>
<DialogTitle className="font-mono">
Nuevo plan de estudios (IA)
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
@@ -212,7 +279,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<Label>Facultad</Label>
<FacultadCombobox
value={facultadId}
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
onChange={(id) => {
setFacultadId(id);
setCarreraId("");
}}
disabled={lockFacultad}
placeholder="Elige una facultad…"
/>
@@ -225,7 +295,11 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
value={carreraId}
onChange={setCarreraId}
disabled={!facultadId || lockCarrera}
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
placeholder={
facultadId
? "Elige una carrera…"
: "Selecciona una facultad primero"
}
/>
</div>
@@ -243,11 +317,19 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<div className="text-sm text-neutral-600">
{selectedFiles.length > 0 ? (
<span>
{selectedFiles.length} seleccionado{selectedFiles.length > 1 ? 's' : ''}
<button className="ml-3 underline hover:no-underline" onClick={clearSelection}>Limpiar</button>
{selectedFiles.length} seleccionado
{selectedFiles.length > 1 ? "s" : ""}
<button
className="ml-3 underline hover:no-underline"
onClick={clearSelection}
>
Limpiar
</button>
</span>
) : (
<span>Tip: para seleccionar rango, /Ctrl para múltiples.</span>
<span>
Tip: para seleccionar rango, /Ctrl para múltiples.
</span>
)}
</div>
</div>
@@ -255,13 +337,17 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
{/* Grid de archivos con selección tipo file manager */}
<div className="md:col-span-2 space-y-1">
<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) => {
const ext = fileExt(file.titulo);
const selected = isSelected(file.s3_file_path);
const selected = isSelected(file.id);
console.log(file);
return (
<button
type="button"
<div
key={file.id}
role="gridcell"
aria-selected={selected}
@@ -269,7 +355,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
onDoubleClick={(e) => {
e.stopPropagation();
setPreviewRow({
fine_tuning_referencias_id: file.id,
documentos_id: file.id,
created_by: "unknown",
s3_file_path: file.s3_file_path,
titulo_archivo: file.titulo,
@@ -281,10 +367,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
fecha_subida: file.fecha_subida ?? null,
tags: file.tags ?? null,
instrucciones: "",
})
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCardClick(e as any, index, file);
}
@@ -292,30 +378,51 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
className={[
"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",
selected ? "border-blue-500 ring-2 ring-blue-500 shadow-md" : "border-neutral-200 hover:border-neutral-300",
].join(' ')}
selected
? "border-blue-500 ring-2 ring-blue-500 shadow-md"
: "border-neutral-200 hover:border-neutral-300",
].join(" ")}
>
{/* Outline animado tipo file manager */}
<span className={[
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
].join(' ')} />
<span
className={[
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
].join(" ")}
/>
<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">
<span className="text-lg" aria-hidden>{extIcon(ext)}</span>
<span className="text-lg" aria-hidden>
{extIcon(ext)}
</span>
</div>
<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 ? (
<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 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{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>
)}
@@ -330,7 +437,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
onClick={(e) => {
e.stopPropagation();
setPreviewRow({
fine_tuning_referencias_id: file.id,
documentos_id: file.id,
created_by: "unknown",
s3_file_path: file.s3_file_path,
titulo_archivo: file.titulo,
@@ -342,50 +449,69 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
fecha_subida: file.fecha_subida ?? null,
tags: file.tags ?? null,
instrucciones: "",
})
});
}}
>Previsualizar</Button>
>
Previsualizar
</Button>
</div>
{/* Footer compacto */}
<div className="mt-4 flex items-center justify-between text-xs text-neutral-600">
<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>
</button>
)
</div>
);
})}
{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>
{/* Paginación mejorada */}
{dbFiles.length > itemsPerPage && (
<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">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
>Anterior</Button>
>
Anterior
</Button>
<Input
className="h-8 w-16 text-center"
value={currentPage}
onChange={(e) => {
const v = parseInt(e.target.value || '1', 10);
if (!isNaN(v)) setCurrentPage(Math.min(Math.max(v, 1), totalPages));
const v = parseInt(e.target.value || "1", 10);
if (!isNaN(v))
setCurrentPage(Math.min(Math.max(v, 1), totalPages));
}}
/>
<Button
variant="outline"
size="sm"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
>Siguiente</Button>
onClick={() =>
setCurrentPage((p) => Math.min(p + 1, totalPages))
}
>
Siguiente
</Button>
</div>
</div>
)}
@@ -395,19 +521,26 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
{err && <div className="text-sm text-red-600">{err}</div>}
<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 className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
<Button
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"}
</Button>
</DialogFooter>
</DialogContent>
{previewRow && (
<DetailDialog
row={previewRow}
onClose={() => setPreviewRow(null)}
/>
<DetailDialog row={previewRow} onClose={() => setPreviewRow(null)} />
)}
</Dialog>
)
);
}

View File

@@ -28,10 +28,10 @@ export function DeletePlanButton({ planId, onDeleted }: { planId: string; onDele
return confirm ? (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
{loading ? "Eliminando…" : "Confirmar eliminación"}
</Button>
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
</div>
) : (
<Button variant="outline" onClick={() => setConfirm(true)}>

View File

@@ -0,0 +1,145 @@
import { supabase } from "@/auth/supabase";
import { Button } from "../ui/button";
import { Download } from "lucide-react";
export type PlanLike = Record<
string,
string | number | object | null | undefined
>;
export function DownloadPlanPDF({ plan }: { plan: Record<string, any> }) {
async function fetchPDF() {
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);
const triggerDownload = (blob: Blob, name: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.setAttribute("download", name);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const fetchBinaryFallback = async () => {
// Intenta construir la URL del runtime de Functions
const anyClient = supabase as any;
const baseUrl =
anyClient?.functions?.url ||
`${(anyClient?.supabaseUrl || "").replace(/\/$/, "")}/functions/v1`;
const { data: sess } = await supabase.auth.getSession();
const token = sess?.session?.access_token;
console.log(JSON.stringify(planObj, null, 2));
console.log(planObj);
const resp = await fetch(`${baseUrl}/carbone-io-api`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/pdf",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
action: "downloadReport",
templateId: "1302213091201757023",
fileName,
convertTo: "pdf",
data: planObj,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
triggerDownload(blob, fileName);
};
try {
// const { data, error } = await supabase.functions.invoke(
// "carbone-io-api",
// {
// method: "POST",
// headers: { Accept: "application/octet-stream" }, // preferir binario
// body: {
// action: "downloadReport",
// templateId: "1302213091201757023",
// fileName,
// convertTo: "pdf",
// data: planObj,
// },
// }
// );
// if (error) throw error;
// // Si ya viene binario, descargar directo
// if (typeof Blob !== "undefined" && data instanceof Blob) {
// triggerDownload(data, fileName);
// return;
// }
// if (data instanceof ArrayBuffer) {
// triggerDownload(
// new Blob([data], { type: "application/pdf" }),
// fileName
// );
// return;
// }
// // Si vino como string (ej. empieza con %PDF), usa el fallback binario
// if (typeof data === "string") {
// await fetchBinaryFallback();
// return;
// }
// // Si vino JSON con base64, decodificar y descargar
// if (data && typeof data === "object") {
// const b64 =
// (data as any).file || (data as any).buffer || (data as any).base64;
// if (typeof b64 === "string") {
// const clean = b64.replace(/^data:.*;base64,/, "");
// const binary = atob(clean);
// const bytes = new Uint8Array(binary.length);
// for (let i = 0; i < binary.length; i++)
// bytes[i] = binary.charCodeAt(i);
// triggerDownload(
// new Blob([bytes], { type: "application/pdf" }),
// fileName
// );
// return;
// }
// }
// console.warn("Respuesta no reconocida para descarga de PDF.", {
// type: typeof data,
// });
await fetchBinaryFallback();
return;
} catch (error) {
console.error("Error al obtener PDF:", error);
}
}
return (
<Button
variant="outline"
className="flex items-center gap-2"
onClick={() => void fetchPDF()}
>
Descargar PDF
<Download className="w-4 h-4" />
</Button>
);
}
export default DownloadPlanPDF;

View File

@@ -0,0 +1,33 @@
import { Download } from "lucide-react";
import { Button } from "../ui/button";
export function DescargarPdfButton({planId, opcion}: {planId: string, opcion: "plan" | "asignaturas"}) {
return (
<Button variant="outline" className="flex items-center gap-2 " onClick={() => descargarPdf(planId, opcion)}>
Descargar {opcion === "plan" ? "Plan" : "Asignaturas"} PDF
<Download className="w-4 h-4" />
</Button>
)
}
function descargarPdf(planId: string, opcion: "plan" | "asignaturas") {
// Lógica para generar y descargar el PDF del plan de estudios
try {
// Usa la variable de entorno para construir la URL completa
const pdfUrl = opcion === "plan"
? `${import.meta.env.VITE_BACK_ORIGIN}/api/planes/${planId}/descargar-pdf-plan`
: `${import.meta.env.VITE_BACK_ORIGIN}/api/planes/${planId}/descargar-pdf-asignaturas`;
const link = document.createElement('a');
link.href = pdfUrl;
link.download = opcion === "plan"
? `plan_estudios_${planId}.pdf`
: `asignaturas_plan_estudios_${planId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Error al descargar el PDF:", error);
alert("Hubo un error al descargar el PDF. Por favor, inténtalo de nuevo.");
}
}

View File

@@ -1,11 +1,26 @@
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase"
import { toast } from "sonner"
import * as Icons from "lucide-react";
import { useMemo, useState } from "react";
import {
useSuspenseQuery,
useMutation,
useQueryClient,
queryOptions,
} from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { supabase, useSupabaseAuth } from "@/auth/supabase";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal";
// @ts-ignore
import AIChatModal from "../ai/AIChatModal";
/* =====================================================
Query keys & fetcher
@@ -13,32 +28,29 @@ import { toast } from "sonner"
export const planKeys = {
root: ["plan"] as const,
byId: (id: string) => [...planKeys.root, id] as const,
}
};
export type PlanTextFields = {
objetivo_general?: string | string[] | null
sistema_evaluacion?: string | string[] | null
perfil_ingreso?: string | string[] | null
perfil_egreso?: string | string[] | null
competencias_genericas?: string | string[] | null
competencias_especificas?: string | string[] | null
indicadores_desempeno?: string | string[] | null
pertinencia?: string | string[] | null
prompt?: string | null
}
objetivo_general?: string | string[] | null;
sistema_evaluacion?: string | string[] | null;
perfil_ingreso?: string | string[] | null;
perfil_egreso?: string | string[] | null;
competencias_genericas?: string | string[] | null;
competencias_especificas?: string | string[] | null;
indicadores_desempeno?: string | string[] | null;
pertinencia?: string | string[] | null;
prompt?: string | null;
historico?: string | null;
};
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
const { data, error } = await supabase
.from("plan_estudios")
.select(
`objetivo_general, sistema_evaluacion, perfil_ingreso, perfil_egreso,
competencias_genericas, competencias_especificas, indicadores_desempeno,
pertinencia, prompt`
)
.select(`*`)
.eq("id", planId)
.single()
if (error) throw error
return (data ?? {}) as PlanTextFields
.single();
if (error) throw error;
return (data ?? {}) as PlanTextFields;
}
export const planTextOptions = (planId: string) =>
@@ -46,174 +58,503 @@ export const planTextOptions = (planId: string) =>
queryKey: planKeys.byId(planId),
queryFn: () => fetchPlanText(planId),
staleTime: 60_000,
})
});
/* =====================================================
Color helpers
===================================================== */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace("#", "")
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
if (!hex) return [37, 99, 235];
const h = hex.replace("#", "");
const v =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const n = parseInt(v, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
const rgba = (rgb: [number, number, number], a: number) =>
`rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
/* =====================================================
Expandable text
===================================================== */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
function ExpandableText({
text,
mono = false,
}: {
text?: string | string[] | null;
mono?: boolean;
}) {
const [open, setOpen] = useState(false);
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>
return <span className="text-neutral-400"></span>;
}
const content = Array.isArray(text) ? text.join("\n• ") : text
const rendered = Array.isArray(text) ? `${content}` : content
const content = Array.isArray(text) ? text.join("\n• ") : text;
const rendered = Array.isArray(text) ? `${content}` : content;
return (
<div>
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div>
<ReactMarkdown>{rendered}</ReactMarkdown>
{String(rendered).length > 220 && (
<button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
<button
onClick={() => setOpen((v) => !v)}
className="mt-2 text-xs font-medium text-neutral-600 hover:underline"
>
{open ? "Ver menos" : "Ver más"}
</button>
)}
</div>
)
);
}
/* =====================================================
Section panel
===================================================== */
function SectionPanel({ title, icon: Icon, color, children, id }: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
const rgb = hexToRgb(color)
function SectionPanel({
title,
icon: Icon,
color,
children,
id,
}: {
title: string;
icon: any;
color?: string | null;
children: React.ReactNode;
id: string;
}) {
const rgb = hexToRgb(color);
return (
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
<section
id={id}
className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60"
>
<div className="pointer-events-none absolute inset-0 -z-0">
<div className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)` }} />
<div className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.14)}, transparent 60%)` }} />
<div
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
style={{
background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)`,
}}
/>
<div
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
style={{
background: `radial-gradient(circle, ${rgba(rgb, 0.14)}, transparent 60%)`,
}}
/>
</div>
<div className="relative z-10 px-4 py-3 flex items-center gap-2 border-b" style={{ background: `linear-gradient(180deg, ${rgba(rgb, 0.1)}, transparent)` }}>
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80" style={{ borderColor: rgba(rgb, 0.25) }}>
<div
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
style={{
background: `linear-gradient(180deg, ${rgba(rgb, 0.1)}, transparent)`,
}}
>
<span
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
style={{ borderColor: rgba(rgb, 0.25) }}
>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="relative z-10 p-5">{children}</div>
</section>
)
);
}
/* =====================================================
AcademicSections (con React Query)
===================================================== */
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
const qc = useQueryClient()
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
export function AcademicSections({
planId,
color,
}: {
planId: string;
color?: string | null;
}) {
const qc = useQueryClient();
const auth = useSupabaseAuth();
const [openHistorial, setOpenHistorial] = useState(false);
const [openModalIa, setopenModalIa] = useState(false);
if (!planId) return <div>Cargando</div>;
const { data: plan } = useSuspenseQuery(planTextOptions(planId));
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("")
const [editing, setEditing] = useState<null | {
key: keyof PlanTextFields;
title: string;
}>(null);
const [draft, setDraft] = useState("");
// --- mutation con actualización optimista ---
const updateField = useMutation({
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
const payload: Record<string, any> = { [key]: value }
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
if (error) throw error
return payload
mutationFn: async ({
key,
value,
}: {
key: keyof PlanTextFields;
value: string | string[] | null;
}) => {
const payload: Record<string, any> = { [key]: value };
const { error } = await supabase
.from("plan_estudios")
.update(payload)
.eq("id", planId);
if (error) throw error;
return payload;
},
onMutate: async ({ key, value }) => {
await qc.cancelQueries({ queryKey: planKeys.byId(planId) })
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId))
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({ ...(old ?? {}), [key]: value }))
return { prev }
await qc.cancelQueries({ queryKey: planKeys.byId(planId) });
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId));
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({
...(old ?? {}),
[key]: value,
}));
return { prev };
},
onError: (e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev)
toast.error((e as any)?.message || "No se pudo guardar 😓")
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev);
toast.error((e as any)?.message || "No se pudo guardar 😓");
},
onSuccess: () => {
toast.success("Guardado ✅")
toast.success("Guardado ✅");
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) })
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) });
},
})
});
const sections = useMemo(
() => [
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
{
id: "sec-clave",
title: "Clave del plan",
icon: Icons.Key,
key: "clave_del_plan_de_estudios" as const,
mono: true,
},
{
id: "sec-area",
title: "Área de estudio",
icon: Icons.Library,
key: "area_de_estudio" as const,
mono: false,
},
// --- Estructura Temporal ---
{
id: "sec-ciclos",
title: "Total de ciclos",
icon: Icons.CalendarRange,
key: "total_de_ciclos_del_plan_de_estudios" as const,
mono: false,
},
{
id: "sec-duracion-ciclo",
title: "Duración del ciclo (semanas)",
icon: Icons.CalendarDays,
key: "duracion_del_ciclo_escolar" as const,
mono: false,
},
{
id: "sec-carga",
title: "Carga horaria semanal",
icon: Icons.Clock,
key: "carga_horaria_a_la_semana" as const,
mono: false,
},
// --- Perfiles y Fines ---
{
id: "sec-antecedente",
title: "Antecedente académico",
icon: Icons.BookOpen,
key: "antecedente_academico" as const,
mono: false,
},
{
id: "sec-ingreso",
title: "Perfil de ingreso",
icon: Icons.UserPlus,
key: "perfil_de_ingreso" as const,
mono: false,
},
{
id: "sec-fines",
title: "Fines de aprendizaje",
icon: Icons.Target,
key: "fines_de_aprendizaje_o_formacion" as const,
mono: false,
},
{
id: "sec-egreso",
title: "Perfil de egreso",
icon: Icons.UserCheck,
key: "perfil_de_egreso" as const,
mono: false,
},
// --- Operatividad y Modelo ---
{
id: "sec-admin",
title: "Administración y operatividad",
icon: Icons.Briefcase,
key: "administracion_y_operatividad_del_plan_de_estudios" as const,
mono: false,
},
{
id: "sec-sustento",
title: "Sustento teórico",
icon: Icons.Book,
key: "sustento_teorico_del_modelo_curricular" as const,
mono: false,
},
{
id: "sec-justificacion",
title: "Justificación curricular",
icon: Icons.MessageSquareText,
key: "justificacion_de_la_propuesta_curricular" as const,
mono: false,
},
{
id: "sec-evaluacion",
title: "Evaluación periódica",
icon: Icons.CheckCircle2,
key: "propuesta_de_evaluacion_periodica_del_plan_de_estudios" as const,
mono: false,
},
// --- Específicos / Opcionales ---
{
id: "sec-investigacion",
title: "Programa de investigación",
icon: Icons.Microscope,
key: "programa_de_investigacion" as const,
mono: false,
},
{
id: "sec-propedeutico",
title: "Curso propedéutico",
icon: Icons.School,
key: "curso_propedeutico" as const,
mono: false,
},
// --- Meta / Sistema ---
{
id: "sec-prm",
title: "Prompt (origen)",
icon: Icons.Code2,
key: "prompt" as const,
mono: true,
},
{
id: "sec-hist",
title: "Histórico de cambios",
icon: Icons.History,
key: "historico" as const,
mono: false,
},
],
[]
)
);
const [iaContext, setIaContext] = useState<{
key: keyof PlanTextFields;
title: string;
content: string;
} | null>(null);
return (
<>
<div className="grid gap-5 md:grid-cols-2">
{sections.map((s) => {
const text = plan[s.key] ?? null
const text = String(plan[s.key]) ?? null;
return (
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!text || (Array.isArray(text) && text.length === 0)}
onClick={() => {
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
if (toCopy) navigator.clipboard.writeText(toCopy)
}}
>
Copiar
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
setEditing({ key: s.key, title: s.title })
setDraft(current)
}}
>
Editar
</Button>
</div>
<SectionPanel
key={s.id}
id={s.id}
title={s.title}
icon={s.icon}
color={color}
>
{s.key === "historico" ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setOpenHistorial(true)}
>
Ver historial
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setopenModalIa(true)}
>
Promt
</Button>
</>
) : (
<>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={
!text || (Array.isArray(text) && text.length === 0)
}
onClick={() => {
const toCopy = Array.isArray(text)
? text.join("\n")
: (text ?? "");
if (toCopy) navigator.clipboard.writeText(toCopy);
}}
>
Copiar
</Button>
{s.key !== "prompt" && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const current = Array.isArray(text)
? text.join("\n")
: (text ?? "");
setEditing({ key: s.key, title: s.title });
setDraft(current);
}}
>
Editar
</Button>
)}
</div>
</>
)}
</SectionPanel>
)
);
})}
</div>
{/* Diálogo de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<Dialog
open={!!editing}
onOpenChange={(o) => {
if (!o) setEditing(null);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
<DialogTitle className="font-mono">
{editing
? `Editar: ${sections.find((x) => x.key === editing.key)?.title}`
: ""}
</DialogTitle>
</DialogHeader>
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
placeholder="Escribe aquí…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancelar
</Button>
<Button
onClick={() => {
if (!editing) return
updateField.mutate({ key: editing.key, value: draft })
setEditing(null)
onClick={async () => {
if (!editing) return;
// 1⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
const oldValue = (plan as any)[editing.key];
// 2⃣ Crear un diff tipo JSON Patch
const diff = [
{
op: "replace",
path: `/${editing.key}`,
from: oldValue,
value: draft,
},
];
// 3⃣ Guardar respaldo antes de actualizar
const { error: backupError } = await supabase
.from("historico_cambios")
.insert({
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
json_cambios: diff,
user_id: auth.user?.id,
created_at: new Date().toISOString(),
});
if (backupError) {
console.error("Error al guardar respaldo:", backupError);
alert("No se pudo guardar el respaldo de los cambios");
return;
}
// 4⃣ Ejecutar la mutación original
updateField.mutate({ key: editing.key, value: draft });
// 5⃣ Cerrar el diálogo
setEditing(null);
}}
disabled={updateField.isPending}
>
{updateField.isPending ? "Guardando…" : "Guardar"}
</Button>
<Button
variant="secondary"
onClick={() => {
if (!editing) return;
const current = draft;
setIaContext({
key: editing.key,
title: editing.title,
content: current,
});
setopenModalIa(true);
setEditing(null); // 🔹 Cierra el modal de edición
}}
>
Mejorar con IA
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<HistorialCambiosModal
open={openHistorial}
onClose={() => setOpenHistorial(false)}
planId={planId}
onRestore={async (key, value) => {
updateField.mutate({ key, value });
}}
/>
<AIChatModal
open={openModalIa}
onClose={() => setopenModalIa(false)}
context={{
section: iaContext?.title,
fieldKey: iaContext?.key,
originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
No uses introducciones, despedidas ni texto de relleno.
Entrega solo el contenido útil.`,
}}
onAccept={(newText: string) => {
if (iaContext) {
updateField.mutate({ key: iaContext.key, value: newText });
setIaContext(null);
}
}}
/>
</>
)
);
}

View File

@@ -26,15 +26,17 @@ export function planByIdOptions(planId: string) {
queryKey: planKeys.byId(planId),
queryFn: async (): Promise<PlanFull> => {
const { data, error } = await supabase
.from("plan_estudios")
.select(`
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
pertinencia, prompt, estado, fecha_creacion,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq("id", planId)
.maybeSingle()
.from("plan_estudios")
.select(`
*,
carreras (
id,
nombre,
facultades ( id, nombre, color, icon )
)
`)
.eq("id", planId)
.maybeSingle();
if (error || !data) throw error ?? new Error("Plan no encontrado")
return data as unknown as PlanFull
},

View File

@@ -0,0 +1,200 @@
"use client"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react"
type PromptInputContextType = {
isLoading: boolean
value: string
setValue: (value: string) => void
maxHeight: number | string
onSubmit?: () => void
disabled?: boolean
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}
const PromptInputContext = createContext<PromptInputContextType>({
isLoading: false,
value: "",
setValue: () => {},
maxHeight: 240,
onSubmit: undefined,
disabled: false,
textareaRef: React.createRef<HTMLTextAreaElement>(),
})
function usePromptInput() {
const context = useContext(PromptInputContext)
if (!context) {
throw new Error("usePromptInput must be used within a PromptInput")
}
return context
}
type PromptInputProps = {
isLoading?: boolean
value?: string
onValueChange?: (value: string) => void
maxHeight?: number | string
onSubmit?: () => void
children: React.ReactNode
className?: string
}
function PromptInput({
className,
isLoading = false,
maxHeight = 240,
value,
onValueChange,
onSubmit,
children,
}: PromptInputProps) {
const [internalValue, setInternalValue] = useState(value || "")
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = (newValue: string) => {
setInternalValue(newValue)
onValueChange?.(newValue)
}
return (
<TooltipProvider>
<PromptInputContext.Provider
value={{
isLoading,
value: value ?? internalValue,
setValue: onValueChange ?? handleChange,
maxHeight,
onSubmit,
textareaRef,
}}
>
<div
className={cn(
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
className
)}
onClick={() => textareaRef.current?.focus()}
>
{children}
</div>
</PromptInputContext.Provider>
</TooltipProvider>
)
}
export type PromptInputTextareaProps = {
disableAutosize?: boolean
} & React.ComponentProps<typeof Textarea>
function PromptInputTextarea({
className,
onKeyDown,
disableAutosize = false,
...props
}: PromptInputTextareaProps) {
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
usePromptInput()
useEffect(() => {
if (disableAutosize) return
if (!textareaRef.current) return
if (textareaRef.current.scrollTop === 0) {
textareaRef.current.style.height = "auto"
}
textareaRef.current.style.height =
typeof maxHeight === "number"
? `${Math.min(textareaRef.current.scrollHeight, maxHeight)}px`
: `min(${textareaRef.current.scrollHeight}px, ${maxHeight})`
}, [value, maxHeight, disableAutosize])
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit?.()
}
onKeyDown?.(e)
}
return (
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className={cn(
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
className
)}
rows={1}
disabled={disabled}
{...props}
/>
)
}
type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
function PromptInputActions({
children,
className,
...props
}: PromptInputActionsProps) {
return (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
)
}
type PromptInputActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: "top" | "bottom" | "left" | "right"
} & React.ComponentProps<typeof Tooltip>
function PromptInputAction({
tooltip,
children,
className,
side = "top",
...props
}: PromptInputActionProps) {
const { disabled } = usePromptInput()
return (
<Tooltip {...props}>
<TooltipTrigger asChild disabled={disabled} onClick={event => event.stopPropagation()}>
{children}
</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputAction,
}

View File

@@ -44,13 +44,13 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)

View File

@@ -0,0 +1,67 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/auth/supabase";
/**
* Hook genérico para actualizar una tabla y guardar respaldo en historico_cambios
*
* @param tableName Nombre de la tabla a actualizar
* @param idKey Campo que se usa para hacer eq (por defecto 'id')
*/
export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
tableName: string,
idKey: keyof T = "id" as keyof T
) {
const qc = useQueryClient();
// Generar diferencias tipo JSON Patch
function generateDiff(oldData: T, newData: Partial<T>) {
const changes: any[] = [];
for (const key of Object.keys(newData)) {
const oldValue = (oldData as any)[key];
const newValue = (newData as any)[key];
if (newValue !== undefined && newValue !== oldValue) {
changes.push({
op: "replace",
path: `/${key}`,
from: oldValue,
value: newValue,
});
}
}
return changes;
}
const mutation = useMutation({
mutationFn: async ({
oldData,
newData,
}: {
oldData: T;
newData: Partial<T>;
}) => {
const diff = generateDiff(oldData, newData);
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios")
.insert({
id_plan_estudios: oldData.id ?? null, // ajusta si es otra tabla
tabla_afectada: tableName,
json_cambios: diff,
created_at: new Date().toISOString(),
});
if (backupError) throw backupError;
}
const { error } = await supabase
.from(tableName)
.update(newData)
.eq(idKey as string, oldData[idKey]);
if (error) throw error;
},
});
return { mutation };
}

View File

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

View File

@@ -73,7 +73,6 @@ function useUserDisplay() {
avatar: claims?.avatar ?? null,
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
role,
isAdmin: Boolean(claims?.claims_admin),
}
}
@@ -150,7 +149,7 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth()
const isAdmin = Boolean(claims?.claims_admin)
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
@@ -189,18 +188,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</Link>
)}
{isAdmin && (
<Link
to="/facultades"
key='facultades'
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span>
</Link>
)}
<Link
to="/facultades"
key='facultades'
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span>
</Link>
</nav>
</ScrollArea>
<Separator className="mt-auto" />

View File

@@ -1,7 +1,7 @@
// routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { supabase } from "@/auth/supabase"
import { use, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@@ -16,12 +16,13 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@
import { DetailDialog } from "@/components/archivos/DetailDialog"
import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent,
loader: async () => {
const { data, error } = await supabase
.from("fine_tuning_referencias")
.from("documentos")
.select("*")
.order("fecha_subida", { ascending: false })
.limit(200)
@@ -67,10 +68,25 @@ function RouteComponent() {
async function remove(id: string) {
if (!confirm("¿Eliminar archivo de referencia?")) return
const { error } = await supabase
.from("fine_tuning_referencias")
.from("documentos")
.delete()
.eq("fine_tuning_referencias_id", id)
.eq("documentos_id", id)
if (error) return alert(error.message)
try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentos_id: id }),
})
if (!res.ok) {
throw new Error("Se falló al eliminar el documento")
}
} catch (err) {
console.error("Error al eliminar el documento:", err)
}
router.invalidate()
}
@@ -123,7 +139,7 @@ function RouteComponent() {
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((r) => (
<article
key={r.fine_tuning_referencias_id}
key={r.documentos_id}
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
>
<header className="min-w-0">
@@ -151,6 +167,7 @@ function RouteComponent() {
<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) => (
@@ -159,13 +176,13 @@ function RouteComponent() {
</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.fine_tuning_referencias_id)}>
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
</Button>
</div>
@@ -192,6 +209,7 @@ function RouteComponent() {
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("")
@@ -222,6 +240,7 @@ function UploadDialog({
prompt: instrucciones,
fileBase64,
insert: true,
uuid: supabaseAuth.user?.id ?? null,
}),
})
if (!res.ok) {
@@ -234,21 +253,21 @@ function UploadDialog({
try {
const payload = await res.json()
createdId =
payload?.fine_tuning_referencias_id ||
payload?.documentos_id ||
payload?.id ||
payload?.data?.fine_tuning_referencias_id ||
payload?.data?.documentos_id ||
null
} catch { /* noop */ }
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
await supabase
.from("fine_tuning_referencias")
.from("documentos")
.update({
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
fuente_autoridad: fuente.trim() || undefined,
interno,
})
.eq("fine_tuning_referencias_id", createdId)
.eq("documentos_id", createdId)
}
onOpenChange(false)

View File

@@ -1,8 +1,9 @@
// routes/_authenticated/asignatura/$asignaturaId.tsx
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
@@ -134,7 +135,7 @@ function Page() {
{/* ===== Hero ===== */}
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
<div className={`absolute inset-0 bg-gradient-to-br ${style.halo} via-white to-transparent`} />
<div className="relative p-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative p-6 flex flex-col grid grid-cols-1 gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
<Icons.BookOpen className="h-4 w-4" /> Asignatura
@@ -165,6 +166,7 @@ function Page() {
</Button>
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
<BorrarAsignaturaButton asignatura_id={a.id} />
</div>
</div>
@@ -191,7 +193,7 @@ function Page() {
)}
{/* Syllabus */}
{unidades.length > 0 && (
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
@@ -285,7 +287,7 @@ function Page() {
)
})()}
</Section>
)}
{/* Bibliografía */}
@@ -401,29 +403,79 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<Partial<Asignatura>>({})
const auth = useSupabaseAuth()
const openAndFill = () => { setForm(asignatura); setOpen(true) }
// ✅ Función que genera las diferencias entre los datos anteriores y los nuevos
function generateDiff(oldData: Asignatura, newData: Partial<Asignatura>) {
const changes: any[] = []
for (const key of Object.keys(newData)) {
const oldValue = (oldData as any)[key]
const newValue = (newData as any)[key]
if (newValue !== undefined && newValue !== oldValue) {
changes.push({
op: "replace",
path: `/${key}`,
from: oldValue,
value: newValue
})
}
}
return changes
}
async function save() {
setSaving(true)
const payload = {
nombre: form.nombre ?? asignatura.nombre,
clave: form.clave ?? asignatura.clave,
tipo: form.tipo ?? asignatura.tipo,
semestre: form.semestre ?? asignatura.semestre,
creditos: form.creditos ?? asignatura.creditos,
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
try {
// 1⃣ Preparar el payload final
const payload = {
nombre: form.nombre ?? asignatura.nombre,
clave: form.clave ?? asignatura.clave,
tipo: form.tipo ?? asignatura.tipo,
semestre: form.semestre ?? asignatura.semestre,
creditos: form.creditos ?? asignatura.creditos,
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
}
// 2⃣ Detectar cambios
const diff = generateDiff(asignatura, payload)
// 3⃣ Guardar respaldo si hubo cambios
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // 👈 usa el nombre real de tu tabla
.insert({
id_asignatura: asignatura.id,
json_cambios: diff, // jsonb
user_id: auth.user?.id,
created_at: new Date().toISOString()
})
if (backupError) throw backupError
}
// 4⃣ Actualizar el registro principal
const { data, error } = await supabase
.from("asignaturas")
.update(payload)
.eq("id", asignatura.id)
.select()
.maybeSingle()
if (error) throw error
// 5⃣ Actualizar vista local
if (data) {
onUpdate(data as Asignatura)
setOpen(false)
}
} catch (err: any) {
alert(err.message ?? "Error al guardar")
} finally {
setSaving(false)
}
const { data, error } = await supabase
.from("asignaturas")
.update(payload)
.eq("id", asignatura.id)
.select()
.maybeSingle()
setSaving(false)
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
else alert(error?.message ?? "Error al guardar")
}
return (
@@ -578,6 +630,51 @@ function MejorarAIButton({ asignaturaId, onApply }: {
)
}
function BorrarAsignaturaButton({ asignatura_id, onDeleted }: { asignatura_id: string; onDeleted?: () => void }) {
const [confirm, setConfirm] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const queryClient = useQueryClient()
async function handleDelete() {
setLoading(true)
try {
const { error, status, statusText } = await supabase.from("asignaturas").delete().eq("id", asignatura_id)
console.log({ status, statusText });
if (error) throw error
setConfirm(false)
queryClient.invalidateQueries({ queryKey: ["asignaturas"] })
if (onDeleted) onDeleted()
router.navigate({ to: "/asignaturas", search: {
q: "", // Término de búsqueda vacío
planId: "", // ID del plan (vacío si no aplica)
carreraId: "", // ID de la carrera (vacío si no aplica)
facultadId: "", // ID de la facultad (vacío si no aplica)
f: "", // Filtro vacío
}})
} catch (e: any) {
alert(e?.message || "Error al eliminar la asignatura")
} finally {
setLoading(false)
}
}
return confirm ? (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
{loading ? "Eliminando…" : "Confirmar eliminación"}
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setConfirm(true)}>
Eliminar asignatura
</Button>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
@@ -603,6 +700,7 @@ export function EditContenidosButton({
const [saving, setSaving] = useState(false)
const [units, setUnits] = useState<UnitDraft[]>([])
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
// --- Normaliza entrada flexible a estructura estable
const normalize = useCallback((v: any): UnitDraft[] => {
@@ -632,13 +730,13 @@ export function EditContenidosButton({
}
return { title, temas }
})
return entries.length ? entries : [{ title: "Unidad 1", temas: [] }]
return entries.length ? entries : [{ title: "", temas: [] }]
} catch {
return [{ title: "Unidad 1", temas: [] }]
return [{ title: "", temas: [] }]
}
}, [])
// --- Construye payload consistente { "1": { titulo, subtemas:{ "1": "t1" } } }
// --- Construye payload consistente
const buildPayload = useCallback((us: UnitDraft[]) => {
const out: Record<string, any> = {}
us.forEach((u, idx) => {
@@ -650,14 +748,14 @@ export function EditContenidosButton({
.forEach((t, i) => {
sub[String(i + 1)] = t
})
out[k] = { titulo: (u.title || "").trim() || `Unidad ${k}`, subtemas: sub }
out[k] = { titulo: (u.title || "").trim(), subtemas: sub }
})
return out
}, [])
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive)
// --- Limpia UI
const cleanUnits = useCallback((us: UnitDraft[]) => {
return us.map((u, idx) => {
return us.map((u) => {
const seen = new Set<string>()
const temas = u.temas
.map((t) => t.trim())
@@ -668,10 +766,7 @@ export function EditContenidosButton({
seen.add(key)
return true
})
return {
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
temas,
}
return { title: (u.title || "").trim(), temas }
})
}, [])
@@ -687,7 +782,7 @@ export function EditContenidosButton({
[units, initialUnits, cleanUnits],
)
// --- Atajos: Guardar con Ctrl/Cmd + Enter
// --- Atajos: Ctrl/Cmd + Enter
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
@@ -699,7 +794,6 @@ export function EditContenidosButton({
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, units, saving])
// --- Acciones por unidad
@@ -707,15 +801,17 @@ export function EditContenidosButton({
if (!confirm("¿Eliminar esta unidad?")) return
setUnits((prev) => prev.filter((_, i) => i !== idx))
}
const moveUnit = (idx: number, dir: -1 | 1) => {
setUnits((prev) => {
const next = [...prev]
const j = idx + dir
if (j < 0 || j >= next.length) return prev
;[next[idx], next[j]] = [next[j], next[idx]]
;[next[idx], next[j]] = [next[j], next[idx]]
return next
})
}
const duplicateUnit = (idx: number) => {
setUnits((prev) => {
const next = [...prev]
@@ -727,24 +823,54 @@ export function EditContenidosButton({
})
}
// ✅ Función para guardar con respaldo histórico
async function save() {
setSaving(true)
const cleaned = cleanUnits(units)
const contenidos = buildPayload(cleaned)
const { data, error } = await supabase
.from("asignaturas")
.update({ contenidos })
.eq("id", asignaturaId)
.select()
.maybeSingle()
setSaving(false)
if (error) {
alert(error.message || "No se pudo guardar")
return
try {
const cleaned = cleanUnits(units)
const contenidos = buildPayload(cleaned)
// 1⃣ Generar diff entre valor anterior y nuevo
const diff = [
{
op: "replace",
path: "/contenidos",
from: value,
value: contenidos,
},
]
// 2⃣ Guardar respaldo en tabla de histórico (solo si hay cambios)
if (JSON.stringify(value) !== JSON.stringify(contenidos)) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // 👈 nombre de tu tabla de respaldo
.insert({
id_asignatura: asignaturaId,
json_cambios: diff,
user_id: auth.user?.id,
created_at: new Date().toISOString(),
})
if (backupError) throw backupError
}
// 3⃣ Actualizar campo contenidos
const { data, error } = await supabase
.from("asignaturas")
.update({ contenidos })
.eq("id", asignaturaId)
.select()
.maybeSingle()
if (error) throw error
setInitialUnits(cleaned)
onSaved((data as any)?.contenidos ?? contenidos)
setOpen(false)
} catch (err: any) {
alert(err.message || "Error al guardar contenidos")
} finally {
setSaving(false)
}
setInitialUnits(cleaned)
onSaved((data as any)?.contenidos ?? contenidos)
setOpen(false)
}
const cancel = () => {
@@ -839,7 +965,7 @@ export function EditContenidosButton({
<Button
variant="secondary"
onClick={() =>
setUnits((prev) => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])
setUnits((prev) => [...prev, { title: "", temas: [] }])
}
>
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
@@ -850,7 +976,7 @@ export function EditContenidosButton({
<DialogFooter className="px-6 pb-5">
<Button variant="outline" onClick={cancel}>Cancelar</Button>
<Button onClick={save} disabled={saving}>
<Button onClick={save} disabled={saving || !hasChanges || units.some(u => !u.title.trim())}>
{saving ? (
<span className="inline-flex items-center gap-2">
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando

View File

@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
@@ -10,6 +10,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { InfoChip } from '@/components/planes/InfoChip'
/* ================== Tipos ================== */
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
@@ -79,7 +80,8 @@ async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId' | 'carrera
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
const planIds = await fetchPlanIdsByScope(search)
if (planIds && planIds.length === 0) return []
console.log(AsignaturaCard);
let query = supabase
.from('asignaturas')
.select(`
@@ -169,6 +171,60 @@ function RouteComponent() {
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
/* useEffect(() => {
const timeout = setTimeout(() => {
router.navigate({
to: '/asignaturas',
search: { ...search, q },
replace: true,
})
}, 400)
return () => clearTimeout(timeout)
}, [q]) */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setQ(value)
router.navigate({
to: '/asignaturas',
search: {
...search,
q: value,
},
replace: true, // evita recargar o empujar al historial
})
}
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [planes])
// 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
if (
p.carrera?.id &&
p.carrera?.nombre &&
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
) {
unique.set(p.carrera.id, p.carrera.nombre)
}
})
return Array.from(unique.entries())
}, [planes, facultad])
// NEW: Clonado individual
const [cloneOpen, setCloneOpen] = useState(false)
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
@@ -217,28 +273,31 @@ function RouteComponent() {
return { sinBibliografia, sinCriterios, sinContenidos }
}, [asignaturas])
// Filtrado
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const planOK = !search.planId || a.plan?.id === search.planId
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
})
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
return matchesQ && semOK && tipoOK && flagOK
})
}, [q, sem, tipo, flag, asignaturas])
// Agrupación
const groups = useMemo(() => {
@@ -257,7 +316,19 @@ function RouteComponent() {
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
// Actualiza la URL limpiando todos los query params
router.navigate({
to: '/asignaturas',
search: {
q: '',
planId: '',
carreraId: '',
facultadId: '',
f: ''
},
})
}
// NEW: util para clonar 1 asignatura
async function cloneOne(src: Asignatura, overrides: {
@@ -292,6 +363,8 @@ function RouteComponent() {
if (error) throw error
}
// NEW: abrir modal clon individual
function openClone(a: Asignatura) {
setCloneTarget(a)
@@ -320,6 +393,8 @@ function RouteComponent() {
setCart([])
}
// NEW: clonado en lote
async function cloneBulk() {
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
@@ -394,51 +469,111 @@ function RouteComponent() {
</div>
{/* Filtros */}
<div className="grid gap-4 sm:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-5 items-end">
{/* 🔍 Búsqueda */}
<div>
<Label>Búsqueda</Label>
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={handleChange}
placeholder="Nombre, clave, plan, carrera, facultad…"
/>
</div>
{/* 📘 Semestre */}
<div>
<Label>Semestre</Label>
<Select value={sem} onValueChange={setSem}>
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
{semestres.map((s) => (
<SelectItem key={s} value={s}>
Semestre {s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 🏛️ Facultad */}
<div>
<Label>Tipo</Label>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="todos">Todos</SelectItem>
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
<Label>Facultad</Label>
<Select
value={facultad ?? "todas"}
onValueChange={(val) => {
setFacultad(val)
setCarrera("todas")
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por facultad" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem>
{facultadesList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Agrupación</Label>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
{/* 🎓 Carrera */}
<div className={!facultad || facultad === "todas" ? "invisible" : ""}>
<Label>Carrera</Label>
<Select
value={carrera ?? "todas"}
onValueChange={(val) => setCarrera(val)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por carrera" />
</SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Por semestre</SelectItem>
<SelectItem value="ninguno">Sin agrupación</SelectItem>
<SelectItem value="todas">Todas las carreras</SelectItem>
{carrerasList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 📜 Plan */}
<div className={!carrera || carrera === "todas" ? "invisible" : ""}>
<Label>Plan</Label>
<Select
value={search.planId ?? "todos"}
onValueChange={(val) => {
router.navigate({
search: { ...search, planId: val === "todos" ? "" : val },
})
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por plan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos los planes</SelectItem>
{planes
.filter((p) => p.carrera?.id === carrera)
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Chips de salud */}
<div className="flex flex-wrap items-center gap-2">
<HealthChip
@@ -462,7 +597,7 @@ function RouteComponent() {
label="Sin contenidos"
value={salud.sinContenidos}
/>
{(q || sem !== 'todos' || tipo !== 'todos' || flag) && (
{/*(q || sem !== 'todos' || tipo !== 'todos' || flag || carrera=='todos')*/ true && (
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
Limpiar filtros
</Button>
@@ -694,9 +829,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
console.log(a);
return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all">
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
style={{
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
}}
>
<div className="p-3">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80">
@@ -747,14 +888,17 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
<Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre}
</span>
{a.plan.carrera && (
<span className="inline-flex items-center gap-1">
<Icons.GraduationCap className="w-3.5 h-3.5" /> <strong>Carrera:</strong> {a.plan.carrera.nombre}
</span>
<InfoChip
icon={<Icons.GraduationCap className="h-3 w-3" />}
label={a.plan.carrera.nombre}
/>
)}
{a.plan.carrera?.facultad && (
<span className="inline-flex items-center gap-1">
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
</span>
<InfoChip
icon={<Icons.Building2 className="h-3 w-3" />}
label={a.plan.carrera.facultad.nombre}
tint={a.plan.carrera.facultad.color}
/>
)}
</div>
)}

View File

@@ -126,6 +126,17 @@ function RouteComponent() {
const [detail, setDetail] = useState<CarreraRow | null>(null)
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<CarreraRow | null>(null)
// ✅ Se declara UNA SOLA VEZ
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(
deleteTarget?.id ?? "",
async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
// setDeleteTarget(null)
}
)
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
@@ -198,10 +209,7 @@ function RouteComponent() {
const border = tint(fac?.color, 0.28)
const chip = tint(fac?.color, 0.1)
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(c.id, async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
})
return (
<ContextMenu key={c.id}>
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
@@ -233,11 +241,14 @@ function RouteComponent() {
<ContextMenuItem onClick={() => setEditCarrera(c)}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</ContextMenuItem>
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
<ContextMenuItem onClick={() => {
setDeleteTarget(c)
setDeleteOpen(true)
}}>
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
</ContextMenuItem>
</ContextMenuContent>
{deleteDialog}
</ContextMenu>
)
})}
@@ -247,6 +258,8 @@ function RouteComponent() {
</CardContent>
</Card>
{deleteDialog}
{/* Crear / Editar */}
<CarreraFormDialog
open={createOpen}

View File

@@ -49,7 +49,7 @@ async function fetchDashboard(): Promise<LoaderData> {
supabase
.from('plan_estudios')
.select(
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
'*'
),
supabase
.from('asignaturas')
@@ -175,7 +175,7 @@ function RouteComponent() {
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
const isAdmin = !!auth.claims?.claims_admin
const isAdmin = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
const navigate = useNavigate({ from: Route.fullPath })

View File

@@ -19,8 +19,10 @@ import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button"
import { DeletePlanButton } from "@/components/planes/DeletePlan"
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
import { DescargarPdfButton } from "@/components/planes/GenerarPdfButton"
import { DownloadPlanPDF } from "@/components/planes/DownloadPlanPDF"
type LoaderData = { planId: string }
type LoaderData = { plan: PlanFull; asignaturas: AsignaturaLite[] }
export const Route = createFileRoute("/_authenticated/plan/$planId")({
component: RouteComponent,
@@ -33,24 +35,27 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
const { planId } = params
await Promise.all([
if (!planId) throw new Error("planId is required")
console.log("Cargando planId", planId)
const [plan, asignaturas] = await Promise.all([
queryClient.ensureQueryData(planByIdOptions(planId)),
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
// queryClient.ensureQueryData(asignaturasCountOptions(planId)),
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
])
return { planId }
return { plan, asignaturas }
},
})
// ...existing code...
function RouteComponent() {
const qc = useQueryClient()
const { planId } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
//const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
const { plan } = Route.useLoaderData() as LoaderData
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
const auth = useSupabaseAuth()
const asignaturasCount = asignaturasPreview.length
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico'
@@ -78,7 +83,7 @@ function RouteComponent() {
</nav>
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
<div className="absolute inset-0 -z-0" style={accent} />
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardHeader className="relative z-10 flex flex-col grid grid-cols-1 gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<span className="hdr-icon inline-flex items-center justify-center rounded-2xl border px-3 py-2 bg-white/70"
style={{ borderColor: accent.borderColor as string }}>
@@ -98,18 +103,22 @@ function RouteComponent() {
{plan.estado}
</Badge>
)}
<div className='flex gap-2'>
{/* <div className='flex gap-2'> */}
<EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} />
{/* <DescargarPdfButton planId={plan.id} opcion="plan" /> */}
<DownloadPlanPDF plan={plan} />
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
<DeletePlanButton planId={plan.id} />
</div>
{/* </div> */}
</div>
</CardHeader>
<CardContent ref={statsRef}>
<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="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
<StatCard label="Duración" value={plan.total_de_ciclos_del_plan_de_estudios ?? "—"} Icon={Icons.Clock} 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} />
</div>
</CardContent>
@@ -197,33 +206,77 @@ function StatCard({ label, value = "—", Icon = Icons.Info, accent, className =
/* ===== Editar ===== */
function EditPlanButton({ plan }: { plan: PlanFull }) {
const auth = useSupabaseAuth()
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false)
const qc = useQueryClient()
// Función para comparar valores y generar diffs tipo JSON Patch
function generateDiff(oldData: PlanFull, newData: Partial<PlanFull>) {
const changes: any[] = []
for (const key of Object.keys(newData)) {
const oldValue = (oldData as any)[key]
const newValue = (newData as any)[key]
if (newValue !== undefined && newValue !== oldValue) {
changes.push({
op: "replace",
path: `/${key}`,
from: oldValue,
value: newValue
})
}
}
return changes
}
const mutation = useMutation({
mutationFn: async (payload: Partial<PlanFull>) => {
const { error } = await supabase.from('plan_estudios').update({
nombre: payload.nombre ?? plan.nombre,
nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion,
total_creditos: payload.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
// 1⃣ Generar las diferencias antes del update
const diff = generateDiff(plan, payload)
// 2⃣ Guardar respaldo (solo si hay cambios)
if (diff.length > 0) {
const { error: backupError } = await supabase.from("historico_cambios").insert({
id_plan_estudios: plan.id,
json_cambios: diff, // jsonb
user_id:auth.user?.id,
created_at: new Date().toISOString()
})
if (backupError) throw backupError
}
// 3⃣ Actualizar el plan principal
const { error } = await supabase
.from("plan_estudios")
.update({
nombre: payload.nombre ?? plan.nombre,
nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion,
total_creditos: payload.total_creditos ?? plan.total_creditos,
})
.eq("id", plan.id)
if (error) throw error
},
onMutate: async (payload) => {
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
qc.setQueryData<PlanFull>(
planKeys.byId(plan.id),
(old) => (old ? { ...old, ...payload } as PlanFull : old as any)
)
return { prev }
},
onError: (_e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
}
},
})
async function save() {

View File

@@ -10,26 +10,44 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { InfoChip } from "@/components/planes/InfoChip"
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
import { chipTint } from "@/components/planes/chipTint"
import { z } from 'zod'
import { z } from "zod"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type PlanDeEstudios = {
id: string; nombre: string; nivel: string | null; duracion: string | null;
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
id: string
nombre: string
nivel: string | null
duracion: string | null
total_creditos: number | null
estado: string | null
fecha_creacion: string | null
carrera_id: string | null
}
type PlanRow = PlanDeEstudios & {
carreras: {
id: string; nombre: string;
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
id: string
nombre: string
facultades?: {
id: string
nombre: string
color?: string | null
icon?: string | null
} | null
} | null
}
const planSearchSchema = z.object({
plan: z.string().nullable()
plan: z.string().nullable(),
facultad: z.string().nullable().optional(),
carrera: z.string().nullable().optional(),
})
export const Route = createFileRoute("/_authenticated/planes")({
component: RouteComponent,
loader: async () => {
@@ -51,86 +69,185 @@ export const Route = createFileRoute("/_authenticated/planes")({
validateSearch: planSearchSchema,
})
function RouteComponent() {
const auth = useSupabaseAuth()
const { plan } = Route.useSearch()
const { plan, facultad, carrera } = Route.useSearch()
const [openCreate, setOpenCreate] = useState(false)
const data = Route.useLoaderData() as PlanRow[]
const router = useRouter()
const navigate = useNavigate({ from: Route.fullPath })
const showFacultad =
auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
const showCarrera =
showFacultad || auth.claims?.role === "secretario_academico"
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
data?.forEach((p) => {
const fac = p.carreras?.facultades
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [data])
// 🎓 Lista de carreras según facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
data?.forEach((p) => {
if (
p.carreras?.id &&
p.carreras?.nombre &&
(!facultad || p.carreras?.facultades?.id === facultad)
) {
unique.set(p.carreras.id, p.carreras.nombre)
}
})
return Array.from(unique.entries())
}, [data, facultad])
// 🧩 Filtrado general
const filtered = useMemo(() => {
const term = plan?.trim().toLowerCase()
if (!term || !data) return data
return data.filter((p) =>
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term))
)
}, [plan, data])
let results = data ?? []
if (term) {
results = results.filter((p) =>
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term))
)
}
if (facultad && facultad !== "todas") {
results = results.filter((p) => p.carreras?.facultades?.id === facultad)
}
if (carrera && carrera !== "todas") {
results = results.filter((p) => p.carreras?.id === carrera)
}
return results
}, [plan, facultad, carrera, data])
return (
<div className="space-y-4">
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
<div className="flex w-full items-center gap-2 md:w-auto">
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
{/* 🔍 Buscador */}
<div className="relative w-full md:w-80">
<Input
value={plan ?? ''}
onChange={e => navigate({ search: { plan: e.target.value } })}
value={plan ?? ""}
onChange={(e) =>
navigate({ search: { plan: e.target.value, facultad, carrera } })
}
placeholder="Buscar por nombre, nivel, estado…"
/>
</div>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
{/* 🏛️ Filtro por facultad */}
<Select
value={facultad ?? "todas"}
onValueChange={(val) =>
navigate({ search: { plan, facultad: val, carrera: "todas" } })
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por facultad" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem>
{facultadesList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 🎓 Filtro por carrera (según facultad) */}
{facultad && facultad !== "todas" && (
<Select
value={carrera ?? "todas"}
onValueChange={(val) =>
navigate({ search: { plan, facultad, carrera: val } })
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por carrera" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las carreras</SelectItem>
{carrerasList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 🔁 Recargar */}
<Button
variant="outline"
size="icon"
onClick={() => router.invalidate()}
title="Recargar"
>
<RefreshCcw className="h-4 w-4" />
</Button>
{/* Nuevo plan */}
<Button onClick={() => setOpenCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
</Button>
</div>
</CardHeader>
{/* GRID de tarjetas con estilo suave por facultad */}
{/* GRID de tarjetas */}
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered?.map((p) => {
const fac = p.carreras?.facultades
const styles = chipTint(fac?.color)
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
const IconComp =
(fac?.icon && (Icons as any)[fac.icon]) || Building2
return (
<Link
key={p.id}
to="/plan/$planId"
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
mask={{ to: "/plan/$planId", params: { planId: p.id } }}
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
params={{ planId: p.id }}
style={styles}
>
<div className="relative p-5 h-40 flex flex-col justify-between">
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
<span
className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
style={{
borderColor: styles.borderColor as string,
background: "rgba(255,255,255,.6)",
}}
>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<div className="font-semibold truncate">{p.nombre}</div>
<div className="text-xs text-neutral-600 truncate">
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
{p.nivel ?? "—"}{" "}
{p.duracion ? `· ${p.duracion}` : ""}
</div>
</div>
</div>
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
<div className="mt-3 flex items-center gap-2">
{/* grupo izquierdo: chips (wrap si no caben) */}
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
{showCarrera && p.carreras?.nombre && (
<InfoChip
@@ -147,18 +264,21 @@ function RouteComponent() {
)}
</div>
{/* derecha: estado */}
{p.estado && (
<Badge
variant="outline"
className="bg-white/60"
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
style={{
borderColor:
chipTint(fac?.color).borderColor as string,
}}
>
{p.estado}
{p.estado.length > 10
? `${p.estado.slice(0, 10)}`
: p.estado}
</Badge>
)}
</div>
</div>
</Link>
)
@@ -166,16 +286,14 @@ function RouteComponent() {
</div>
{!filtered?.length && (
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
<div className="text-center text-sm text-muted-foreground py-10">
Sin resultados
</div>
)}
</CardContent>
</Card>
<CreatePlanDialog
open={openCreate}
onOpenChange={setOpenCreate}
/>
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import type { Role, UserClaims } from "@/auth/supabase"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
@@ -10,58 +11,19 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import {
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail,
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff,
Ban as BanIcon, Check
} from "lucide-react"
import { SupabaseClient } from "@supabase/supabase-js"
import * as Icons from "lucide-react"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { toast } from "sonner"
/* -------------------- Tipos -------------------- */
type AdminUser = {
id: string
email: string | null
created_at: string
last_sign_in_at: string | null
user_metadata: any
app_metadata: any
banned_until?: string | null
}
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
const ROLES = [
"lci",
"vicerrectoria",
"director_facultad",
"secretario_academico",
"jefe_carrera",
"planeacion",
] as const
export type Role = typeof ROLES[number]
const ROLE_META: Record<Role, { label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }> = {
lci: { label: "Laboratorio de Cómputo de Ingeniería", Icon: Cpu, className: "bg-neutral-900 text-white" },
vicerrectoria: { label: "Vicerrectoría Académica", Icon: Building2, className: "bg-indigo-600 text-white" },
director_facultad: { label: "Director(a) de Facultad", Icon: Building2, className: "bg-purple-600 text-white" },
secretario_academico: { label: "Secretario Académico", Icon: ScrollText, className: "bg-emerald-600 text-white" },
jefe_carrera: { label: "Jefe de Carrera", Icon: GraduationCap, className: "bg-orange-600 text-white" },
planeacion: { label: "Planeación Curricular", Icon: GanttChart, className: "bg-sky-600 text-white" },
}
function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role]
if (!meta) return null
const { Icon, className, label } = meta
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
/* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = {
@@ -69,13 +31,58 @@ const usersKeys = {
list: () => [...usersKeys.root, "list"] as const,
}
async function fetchUsers(): Promise<AdminUser[]> {
// ⚠️ Dev only: service role en cliente
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const { data } = await admin.auth.admin.listUsers()
return (data?.users ?? []) as AdminUser[]
async function fetchUsers(): Promise<UserClaims[]> {
try {
const { data: perfiles, error } = await supabase.from("perfiles").select("id");
if (error) {
console.error("Error al obtener usuarios:", error.message);
return []; // Devuelve un arreglo vacío en caso de error
}
if (!perfiles || perfiles.length === 0) {
console.log("No se encontraron perfiles.");
return []; // Devuelve un arreglo vacío si no hay datos
}
// Llama a `obtener_claims_usuario` para cada perfil
const usuarios = await Promise.all(
perfiles.map(async (perfil) => {
const { data: claims, error: rpcError } = await supabase.rpc("obtener_claims_usuario", {
p_user_id: perfil.id, // Pasa el ID del perfil como parámetro
});
console.log("Claims para perfil", perfil.id, claims[0]);
if (rpcError) {
console.error(`Error al obtener claims para el perfil ${perfil.id}:`, rpcError.message);
return null; // Devuelve null si hay un error
}
return {
id: perfil.id,
role: claims[0]?.role,
title: claims[0]?.title,
facultad_id: claims[0]?.facultad_id,
carrera_id: claims[0]?.carrera_id,
facultad_color: claims[0]?.facultad_color,
clave: claims[0]?.clave,
nombre: claims[0]?.nombre,
apellidos: claims[0]?.apellidos,
avatar: claims[0]?.avatar,
};
})
);
// Filtra los resultados nulos (errores en las llamadas RPC)
return usuarios.filter((u) => u !== null) as UserClaims[];
} catch (err) {
console.error("Error inesperado:", err);
return []; // Devuelve un arreglo vacío en caso de error inesperado
}
}
const usersOptions = () =>
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
@@ -91,12 +98,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({
/* -------------------- Página -------------------- */
function RouteComponent() {
const auth = useSupabaseAuth()
if (auth.claims?.role !== "lci" && auth.claims?.role !== "vicerrectoria") {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
}
const { ROLES, ROLE_META } = useMemo(() => {
if (!auth.roles) return { ROLES: [], ROLE_META: {} };
// Construir ROLES como un arreglo de strings
const rolesArray = auth.roles.map((role) => role.nombre);
// Construir ROLE_META como un objeto basado en ROLES
const rolesMeta = auth.roles.reduce((acc, role) => {
acc[role.nombre] = {
id: role.id,
label: role.label,
Icon: (Icons as any)[role.icono] || Icons.Cpu, // Icono por defecto si no está definido
className: /* role.nombre_clase || */ "bg-gray-500 text-white", // Clase por defecto si no está definida
};
return acc;
}, {} as Record<string, { id: string; label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }>);
return { ROLES: rolesArray, ROLE_META: rolesMeta };
}, [auth.roles]);
const router = useRouter()
const qc = useQueryClient()
const { data } = useSuspenseQuery(usersOptions())
const [q, setQ] = useState("")
const [editing, setEditing] = useState<AdminUser | null>(null)
const [editing, setEditing] = useState<UserClaims | null>(null)
const [form, setForm] = useState<{
role?: Role
claims_admin?: boolean
@@ -118,10 +150,47 @@ function RouteComponent() {
}>({ email: "", password: "" })
function genPassword() {
/*
Supabase requiere que las contraseñas tengan las siguientes características:
- Mínimo de 6 caracteres
- Debe contener al menos una letra minúscula
- Debe contener al menos una letra mayúscula
- Debe contener al menos un número
- Debe contener al menos un carácter especial
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
*/
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
const positions = Array.from({ length: 12 }, (_, i) => i);
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
// 2. Las correspondencias son las siguientes:
// - El primer número indica la posición de la letra minúscula
// - El segundo número indica la posición de la letra mayúscula
// - El tercer número indica la posición del número
// - El cuarto número indica la posición del carácter especial
// - En las demás posiciones puede haber cualquier caracter alfanumérico
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
return s.slice(0, 14)
}
function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role]
if (!meta) return null
const { Icon, className, label } = meta
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
/* ---------- Mutations ---------- */
const invalidateAll = async () => {
await qc.invalidateQueries({ queryKey: usersKeys.root })
@@ -167,11 +236,13 @@ function RouteComponent() {
})
const toggleBan = useMutation({
mutationFn: async (u: AdminUser) => {
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
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)
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
// if (error) throw new Error(error.message)
return !banned
},
onSuccess: async (isBanned) => {
@@ -183,39 +254,42 @@ function RouteComponent() {
const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => {
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const password = payload.password?.trim() || genPassword()
const { error, data } = await admin.auth.admin.createUser({
// Validaciones previas
if (!payload.role) {
throw new Error("Selecciona un rol para el usuario.");
}
if ((payload.role === "secretario_academico" || payload.role === "director_facultad") && !payload.facultad_id) {
throw new Error("Selecciona una facultad para este rol.");
}
if (payload.role === "jefe_carrera" && (!payload.facultad_id || !payload.carrera_id)) {
throw new Error("Selecciona una facultad y una carrera para este rol.");
}
const password = payload.password?.trim()
const { data, error } = await supabase.auth.signUp({
email: payload.email.trim(),
password,
email_confirm: true,
user_metadata: {
nombre: payload.nombre ?? "",
apellidos: payload.apellidos ?? "",
title: payload.title ?? "",
clave: payload.clave ?? "",
avatar: payload.avatar ?? "",
},
app_metadata: {
role: payload.role,
claims_admin: !!payload.claims_admin,
facultad_id: payload.facultad_id ?? null,
carrera_id: payload.carrera_id ?? null,
},
})
if (error) throw new Error(error.message)
const uid = data.user?.id
if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) {
if (payload.role === "director_facultad") {
if (!payload.facultad_id) throw new Error("Selecciona facultad")
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id })
} else if (payload.role === "secretario_academico") {
if (!payload.facultad_id) throw new Error("Selecciona facultad")
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id })
} else if (payload.role === "jefe_carrera") {
if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera")
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id })
options: {
data: {
nombre: payload.nombre ?? "",
apellidos: payload.apellidos ?? "",
title: payload.title ?? "",
clave: payload.clave ?? "",
avatar: payload.avatar ?? "",
role: payload.role,
role_id: payload.role ? ROLE_META[payload.role]?.id : null,
facultad_id: payload.facultad_id ?? null,
carrera_id: payload.carrera_id ?? null,
}
}
});
if (error) throw new Error(error.message)
const uid = data.user?.id
if(!uid) {
throw new Error("No se pudo obtener el ID del usuario creado.");
}
},
onSuccess: async () => {
@@ -228,19 +302,23 @@ function RouteComponent() {
})
const saveUser = useMutation({
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
// Simula éxito:
// 2) Nombramiento si aplica
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
if (f.role === "director_facultad") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
} else if (f.role === "secretario_academico") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
} else if (f.role === "jefe_carrera") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
mutationFn: async ({ u, f }: { u: UserClaims; f: typeof form }) => {
const { error } = await supabase.rpc('actualizar_perfil_y_rol', {
datos: {
user_id: u.id,
rol_nombre: f.role,
titulo: f.title,
facultad_id: f.facultad_id,
carrera_id: f.carrera_id,
nombre: f.nombre,
apellidos: f.apellidos,
avatar: f.avatar,
}
});
if (error) {
throw new Error(error.message);
}
},
onSuccess: async () => {
@@ -251,34 +329,29 @@ function RouteComponent() {
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
})
if (!auth.claims?.claims_admin) {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
}
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return data
return data.filter((u) => {
const role: Role | undefined = u.app_metadata?.role
const role: Role | undefined = u.role
const label = role ? ROLE_META[role]?.label : ""
return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
return [u.nombre, u.apellidos, label]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(t))
})
}, [q, data])
function openEdit(u: AdminUser) {
function openEdit(u: UserClaims) {
setEditing(u)
setForm({
role: u.app_metadata?.role,
claims_admin: !!u.app_metadata?.claims_admin,
nombre: u.user_metadata?.nombre ?? "",
apellidos: u.user_metadata?.apellidos ?? "",
title: u.user_metadata?.title ?? "",
clave: u.user_metadata?.clave ?? "",
avatar: u.user_metadata?.avatar ?? "",
facultad_id: u.app_metadata?.facultad_id ?? null,
carrera_id: u.app_metadata?.carrera_id ?? null,
role: u.role,
nombre: u.nombre ?? "",
apellidos: u.apellidos ?? "",
title: u.title ?? "",
clave: u.clave ?? "",
avatar: u.avatar ?? "",
facultad_id: u.facultad_id ?? null,
carrera_id: u.carrera_id ?? null,
})
}
@@ -301,10 +374,10 @@ function RouteComponent() {
<div className="flex items-center gap-2">
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
<RefreshCcw className="w-4 h-4" />
<Icons.RefreshCcw className="w-4 h-4" />
</Button>
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
<Icons.Plus className="w-4 h-4 mr-1" /> Nuevo usuario
</Button>
</div>
</CardHeader>
@@ -312,48 +385,48 @@ function RouteComponent() {
<CardContent>
<div className="grid gap-3">
{filtered.map((u) => {
const m = u.user_metadata || {}
const a = u.app_metadata || {}
const roleCode: Role | undefined = a.role
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
const roleCode: Role | undefined = u.role
const banned = false // cuando se tenga acceso a ese campo
// const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
return (
<div key={u.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
<div className="flex items-start gap-3 sm:gap-4">
<img src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
<img src={u.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(u.nombre || /* u.email || */ "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
<div className="font-medium truncate">{u.title ? `${u.title} ` : ""}{u.nombre ? `${u.nombre} ${u.apellidos ?? ""}` : /* (u.email ?? "—") */ "—"}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
{roleCode && <RolePill role={roleCode} />}
{a.claims_admin ? (
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
{u.role === "lci" || u.role === "vicerrectoria" ? (
<Badge className="gap-1" variant="secondary"><Icons.ShieldCheck className="w-3 h-3" /> Admin</Badge>
) : (
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
<Badge className="gap-1" variant="outline"><Icons.ShieldAlert className="w-3 h-3" /> Usuario</Badge>
)}
<Badge variant={banned ? ("destructive" as any) : "secondary"} className="gap-1">
{banned ? <BanIcon className="w-3 h-3" /> : <Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
{banned ? <Icons.BanIcon className="w-3 h-3" /> : <Icons.Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
</Badge>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<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 variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
<Pencil className="w-4 h-4 mr-1" /> Editar
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
{/* Cuando se tenga acceso a esta info, se mostrará
<span className="inline-flex items-center gap-1"><Icons.Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
<span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
<span className="hidden md:inline"ltimo acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span> */}
</div>
</div>
<div className="sm:hidden self-start shrink-0 flex gap-1">
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil 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>
</div>
</div>
</div>
@@ -371,7 +444,7 @@ function RouteComponent() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título</Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título <small>(opcional)</small></Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Clave</Label><Input value={form.clave ?? ""} onChange={(e) => setForm((s) => ({ ...s, clave: e.target.value }))} /></div>
<div className="space-y-1"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
<div className="space-y-1">
@@ -422,6 +495,7 @@ function RouteComponent() {
</div>
)}
{/* Probablemente ya no sea necesario
<div className="space-y-1">
<Label>Permisos</Label>
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
@@ -431,7 +505,7 @@ function RouteComponent() {
<SelectItem value="false">Usuario</SelectItem>
</SelectContent>
</Select>
</div>
</div> */}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
@@ -463,16 +537,16 @@ function RouteComponent() {
<div className="space-y-1 md:col-span-2">
<Label>Contraseña temporal</Label>
<div className="flex gap-2">
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />
<Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</Button>
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="abCD12&;" />
{/* <Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button> */}
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <Icons.EyeOff className="w-4 h-4" /> : <Icons.Eye className="w-4 h-4" />}</Button>
</div>
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
</div>
<div className="space-y-1"><Label>Nombre</Label><Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<div className="space-y-1"><Label>Apellidos</Label><Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título</Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título <small>(opcional)</small></Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Clave</Label><Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, clave: e.target.value }))} /></div>
<div className="space-y-1 md:col-span-2"><Label>Avatar (URL)</Label><Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
@@ -483,6 +557,7 @@ function RouteComponent() {
onValueChange={(v) => {
setCreateForm((s) => {
const role = v as Role
console.log("Rol seleccionado: ", role, ROLE_META[role]);
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
return { ...s, role, facultad_id: null, carrera_id: null }
@@ -523,6 +598,7 @@ function RouteComponent() {
</div>
)}
{/* Probablemente ya no sea necesario
<div className="space-y-1 md:col-span-2">
<Label>Permisos</Label>
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
@@ -532,12 +608,12 @@ function RouteComponent() {
<SelectItem value="false">Usuario</SelectItem>
</SelectContent>
</Select>
</div>
</div> */}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || !createForm.password || createUser.isPending}>
{createUser.isPending ? "Creando…" : "Crear usuario"}
</Button>
</DialogFooter>

View File

@@ -1,4 +1,4 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -27,6 +27,7 @@ function LoginComponent() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [showPassword, setShowPassword] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -35,7 +36,7 @@ function LoginComponent() {
try {
await auth.login(email, password)
window.location.href = redirect
router.navigate({ to: redirect})
} catch (err: any) {
setError(err.message || "No fue posible iniciar sesión")
} finally {
@@ -95,12 +96,6 @@ function LoginComponent() {
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Contraseña</Label>
<a
href="/reset-password"
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
>
¿Olvidaste tu contraseña?
</a>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center">
@@ -124,6 +119,14 @@ function LoginComponent() {
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div>
<a
href="/reset-password"
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
>
¿Olvidaste tu contraseña?
</a>
</div>
</div>
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">

View File

@@ -138,7 +138,6 @@
}
}
.animate-aurora {
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),

View File

@@ -1,5 +1,5 @@
export type RefRow = {
fine_tuning_referencias_id: string
documentos_id: string
titulo_archivo: string | null
descripcion: string | null
s3_file_path: string | null // Added this property to match the API requirements.

View File

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