58 Commits

Author SHA1 Message Date
ff17f7a615 Añadir nixpacks.toml
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m56s
2026-01-06 15:49:44 +00:00
dab7a867eb Eliminar package-lock.json
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Has been cancelled
2026-01-06 15:48:36 +00:00
87458ccdad Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/React-Autenticado
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m49s
2025-12-05 08:43:35 -06:00
a1ea8973a7 Add Vercel configuration files and update dependencies 2025-12-05 08:43:32 -06:00
b08d918f84 Refactor deployment workflow to improve Bun setup and streamline Azure Static Web Apps deployment
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m42s
2025-11-28 10:40:34 -06:00
f1591bb9b9 Update deployment workflow to use Bun instead of Node.js
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m47s
2025-11-28 10:27:49 -06:00
965d0198a0 Merge branch 'fix/typos' 2025-11-28 10:27:27 -06:00
ba6f59c4c8 Refactor deployment workflow to use Bun instead of Node.js
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 1m18s
2025-11-28 10:22:33 -06:00
8546b99035 Add staticwebapp.config.json for navigation fallback configuration
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 2m36s
2025-11-28 10:17:15 -06:00
458c4b7973 Add deployment workflow for Azure Static Web Apps
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Has been cancelled
2025-11-28 10:16:41 -06:00
e3c1a0ce2b Add staticwebapp.config.json for navigation fallback configuration 2025-11-28 10:09:28 -06:00
76170421aa Refactor components by removing unused imports and optimizing state management; add configuration for Azure Static Web Apps 2025-11-28 09:52:53 -06:00
2db3a0570a Update API parameters and refactor file upload handling in VectorStoreDialog 2025-11-27 16:41:42 -06:00
d8ade3da75 Merge branch 'formatChatjson' 2025-11-27 16:10:41 -06:00
6a28af26b5 Update card title to simplify the label for file repositories 2025-11-27 16:09:49 -06:00
9d9fb3d8a8 Se corrigen errores de contexto y limpiar mensajes de ia, se actualiza la forma de mostrar archivos y vectores y se permite seleccionar varios archivos 2025-11-27 16:08:02 -06:00
a2dddae5f3 Remove local environment file and implement API calls for managing vector stores and their files 2025-11-27 16:08:00 -06:00
a6efb496db Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-27 15:05:17 -06:00
ef6cc7b96d gitingore modificado 2025-11-27 15:04:47 -06:00
a6f0010a53 Se agrga crear formatos 2025-11-26 19:44:45 -06:00
29231206c0 Se quitan respuestas del asistente y se agrega boton de cerrar modal 2025-11-25 15:21:32 -06:00
93c79eee77 Se agrega modelo de respuestas y conversaciones archivos multiples y contexto de id plan de estudios 2025-11-25 11:34:00 -06:00
6f97a83eb0 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-24 16:17:53 -06:00
4ec2c2d533 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
44 changed files with 3691 additions and 785 deletions

View File

@@ -0,0 +1,38 @@
name: Deploy to Azure Static Web Apps
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build
env:
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
VITE_BACK_ORIGIN: ${{ vars.VITE_BACK_ORIGIN }}
run: bun run build
# No hace falta instalar el CLI globalmente, usamos bunx
- name: Deploy to Azure Static Web Apps
env:
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
run: |
bunx @azure/static-web-apps-cli deploy ./dist \
--env production \
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"

3
.gitignore vendored
View File

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

11
.vercel/README.txt Normal file
View File

@@ -0,0 +1,11 @@
> Why do I have a folder named ".vercel" in my project?
The ".vercel" folder is created when you link a directory to a Vercel project.
> What does the "project.json" file contain?
The "project.json" file contains:
- The ID of the Vercel project that you linked ("projectId")
- The ID of the user or team your Vercel project is owned by ("orgId")
> Should I commit the ".vercel" folder?
No, you should not share the ".vercel" folder with anyone.
Upon creation, it will be automatically added to your ".gitignore" file.

View File

@@ -0,0 +1,36 @@
{
"//": "This file was generated by the `vercel build` command. It is not part of the Build Output API.",
"target": "preview",
"argv": [
"C:\\Program Files\\nodejs\\node.exe",
"C:\\Users\\alex\\.bun\\install\\global\\node_modules\\vercel\\dist\\vc.js",
"build"
],
"builds": [
{
"require": "@vercel/static-build",
"requirePath": "C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\static-build\\dist\\index",
"apiVersion": 2,
"src": "package.json",
"use": "@vercel/static-build",
"config": {
"zeroConfig": true,
"framework": "vite"
},
"error": {
"name": "Error",
"stack": "Error: Command \"npm run build\" exited with 2\n at ChildProcess.<anonymous> (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:23221:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)",
"message": "Command \"npm run build\" exited with 2",
"hideStackTrace": true,
"code": "BUILD_UTILS_SPAWN_2"
}
}
],
"error": {
"name": "Error",
"stack": "Error: Command \"npm run build\" exited with 2\n at ChildProcess.<anonymous> (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:23221:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)",
"message": "Command \"npm run build\" exited with 2",
"hideStackTrace": true,
"code": "BUILD_UTILS_SPAWN_2"
}
}

View File

@@ -0,0 +1,3 @@
{
"version": 3
}

View File

@@ -0,0 +1 @@
[{"name":"vc.builder","duration":11410816,"timestamp":1109155148251,"id":"883875fc-d8b5-40c7-8fdd-f96c27a63d1b","parentId":"3583d148-7b32-444b-813e-a786bebb1401","tags":{"name":"@vercel/static-build"},"startTime":1764002346775},{"name":"vc.builder.diagnostics","duration":17,"timestamp":1109166559117,"id":"c6ad9c3e-80a6-4b2c-9d0b-9e6999814dda","parentId":"883875fc-d8b5-40c7-8fdd-f96c27a63d1b","tags":{},"startTime":1764002358186},{"name":"vc.doBuild","duration":11600581,"timestamp":1109154959803,"id":"3583d148-7b32-444b-813e-a786bebb1401","parentId":"dad52c92-34d7-49a1-a8c7-c26d68888fb8","tags":{},"startTime":1764002346587},{"name":"vc","duration":11643513,"timestamp":1109154916896,"id":"dad52c92-34d7-49a1-a8c7-c26d68888fb8","tags":{},"startTime":1764002346544}]

16
.vercel/project.json Normal file
View File

@@ -0,0 +1,16 @@
{
"projectId": "prj_TQf0vM7v0Pz1NyDhm3Ab0Jp4zB2E",
"orgId": "team_dURDB79ODIkvcyPxn5ZVT7xr",
"projectName": "acad-ia",
"settings": {
"createdAt": 1764000675314,
"framework": "vite",
"devCommand": null,
"installCommand": null,
"buildCommand": null,
"outputDirectory": null,
"rootDirectory": null,
"directoryListing": false,
"nodeVersion": "22.x"
}
}

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}"
}
]
}

207
bun.lock
View File

@@ -32,10 +32,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -465,22 +468,42 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@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/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@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/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/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": ["@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/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/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=="], "@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=="], "@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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "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=="], "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=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "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=="], "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=="], "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=="], "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=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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-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-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-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-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=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isbot": ["isbot@5.1.30", "", {}, "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA=="], "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=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jspdf": ["jspdf@3.0.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ=="],
"jspdf-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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], "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=="], "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=="], "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=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], "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=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "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=="], "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=="], "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": ["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-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-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-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=="], "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=="], "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=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "@supabase/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -919,8 +1122,12 @@
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jspdf/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

5
nixpacks.toml Normal file
View File

@@ -0,0 +1,5 @@
[phases.install]
cmds = ["bun install --frozen-lockfile"]
[phases.build]
cmds = ["bun run build"]

View File

@@ -38,10 +38,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",

View File

@@ -10,26 +10,29 @@ export interface SupabaseAuthState {
isAuthenticated: boolean isAuthenticated: boolean
user: User | null user: User | null
claims: UserClaims | null claims: UserClaims | null
roles: RolCatalogo[] | null
login: (email: string, password: string) => Promise<void> login: (email: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
isLoading: boolean isLoading: boolean
} }
type Role = export interface RolCatalogo {
| 'lci' id: string
| 'vicerrectoria' nombre: string
| 'director_facultad' // 👈 NEW icono: string
| 'secretario_academico' nombre_clase: string
| 'jefe_carrera' label: string
| 'planeacion' }
type UserClaims = { export type Role = string;
claims_admin: boolean
clave: string export type UserClaims = {
id: string | null
clave?: string
nombre: string nombre: string
apellidos: string apellidos: string
title: string title?: string
avatar: string | null avatar?: string | null
carrera_id?: string | null carrera_id?: string | null
facultad_id?: string | null facultad_id?: string | null
facultad_color?: string | null // 🎨 NEW facultad_color?: string | null // 🎨 NEW
@@ -41,26 +44,33 @@ const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefin
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) { export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [claims, setClaims] = useState<UserClaims | null>(null) const [claims, setClaims] = useState<UserClaims | null>(null)
const [roles, setRoles] = useState<RolCatalogo[] | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
// Carga inicial // Función para manejar la sesión
supabase.auth.getSession().then(async ({ data: { session } }) => { const handleSession = async (session: Session | null) => {
const u = session?.user ?? null const u = session?.user ?? null
setUser(u) setUser(u)
setIsAuthenticated(!!u) setIsAuthenticated(!!u)
setClaims(await buildClaims(session)) setClaims(await buildClaims(session))
setIsLoading(false) setIsLoading(false)
}
// Carga inicial
supabase.auth.getSession().then(({ data: { session } }) => {
handleSession(session)
}) })
// Carga roles catálogo
fetchRoles().then(fetchedRoles => {
setRoles(fetchedRoles);
});
// Suscripción a cambios de sesión // Suscripción a cambios de sesión
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => { const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
const u = session?.user ?? null handleSession(session)
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}) })
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
@@ -80,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
return ( return (
<SupabaseAuthContext.Provider <SupabaseAuthContext.Provider
value={{ isAuthenticated, user, claims, login, logout, isLoading }} value={{ isAuthenticated, user, claims, roles, login, logout, isLoading }}
> >
{children} {children}
</SupabaseAuthContext.Provider> </SupabaseAuthContext.Provider>
@@ -99,49 +109,54 @@ export function useSupabaseAuth() {
* Helpers * 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> { async function buildClaims(session: Session | null): Promise<UserClaims | null> {
const u = session?.user // Validar sesión
if (!u) return null if (!session || !session.user) {
console.warn('No session or user found');
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role } return null;
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,
} }
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,
});
return { const data: UserClaims[] | null = result.data;
claims_admin: !!base.claims_admin, const error = result.error;
role: (base.role ?? 'lci') as Role,
clave: base.clave ?? '', if (error) {
nombre: base.nombre ?? '', console.error('Error al obtener la información:', error);
apellidos: base.apellidos ?? '', throw new Error('Error al obtener la información del usuario');
title: base.title ?? '', }
avatar: base.avatar ?? null,
facultad_id: (base.facultad_id as string | null) ?? null, console.log(data);
carrera_id: (base.carrera_id as string | null) ?? null, if (!data || data.length === 0) {
facultad_color, // 🎨 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,627 @@
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, plan_format }) {
const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVector, setSelectedVector] = useState(null);
const [selectedFiles, setSelectedFiles] = useState([]);
const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreviews, setAttachedPreviews] = useState([]);
// chat
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
// loading states
const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [loadingVectors, setLoadingVectors] = useState(false);
// conversation control
const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false);
const messagesEndRef = useRef(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
useEffect(scrollToBottom, [messages]);
const normalizeInvokeResponse = (resp) => {
if (!resp) return null;
const raw = resp.data;
if (typeof raw === "string") {
try { return JSON.parse(raw); } catch (e) { console.warn("❗ No se pudo parsear resp.data:", raw); return null; }
}
if (typeof raw === "object" && raw !== null) return raw;
return null;
};
// Al abrir: reset o crear conversación
useEffect(() => {
if (!open) {
if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e));
}
setMessages([]);
setInput("");
setSelectedFiles([]);
setAttachedFiles([]);
setAttachedPreviews([]);
setConversationId(null);
setSelectedVector(null);
setVectorFiles([]);
return;
}
if (context) {
setMessages([
{
role: "system",
//content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
}
]);
}
(async () => {
await createConversation();
fetchVectorStores();
})();
}, [open]);
// ---------- CREATE CONVERSATION ----------
const createConversation = async () => {
try {
setCreatingConversation(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const resp = await supabase.functions.invoke("conversation-format", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
});
let parsed = null;
if (typeof resp?.data === "string") {
try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
} else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
else parsed = resp;
const convId =
parsed?.conversationId ||
parsed?.data?.conversationId ||
parsed?.data?.id ||
parsed?.id ||
parsed?.conversation_id ||
parsed?.data?.conversation_id;
if (!convId) { setCreatingConversation(false); return; }
setConversationId(convId);
} catch (err) {
console.error("Error creando conversación:", err);
} finally {
setCreatingConversation(false);
}
};
// ---------- DELETE CONVERSATION ----------
const deleteConversation = async (convIdParam) => {
try {
const convIdToUse = convIdParam ?? conversationId;
if (!convIdToUse) return;
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
await supabase.functions.invoke("conversation-format", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse }
});
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);
});
// ---------- HANDLE CONVERSATION (envío) ----------
const handleConversation = async ({ text }) => {
let contextText = "";
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
if (!conversationId) {
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
return;
}
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// archivos adjuntos (locales) -> base64
let filesInput = [];
if (attachedFiles.length > 0) {
for (const file of attachedFiles) {
const base64 = await fileToBase64(file);
filesInput.push({
type: "input_file",
filename: file.name,
file_data: `data:${file.type};base64,${base64}`
});
}
}
// archivos seleccionados del vector (por id)
if (selectedFiles.length > 0) {
const filesFromVectors = selectedFiles.map(f => ({
type: "input_file",
file_id: f.id
}));
filesInput = [...filesInput, ...filesFromVectors];
}
const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`;
const payload = {
action: "message",
format: plan_format,
conversationId,
vectorStoreId: selectedVector ?? null,
fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
input: [
{
role: "user",
content: [
{ type: "input_text", text: promptFinal },
...filesInput
]
}
]
};
const { data: invokeData, error } = await supabase.functions.invoke(
"conversation-format",
{
headers: { Authorization: `Bearer ${token}` },
body: payload
}
);
if (error) throw error;
const parsed = normalizeInvokeResponse({ data: invokeData });
// Extraer texto del assistant (robusto)
let assistantText = null;
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
if (!assistantText && Array.isArray(parsed?.data?.output)) {
const msgBlock = parsed.data.output.find(o => o.type === "message");
if (msgBlock?.content?.[0]?.text) assistantText = msgBlock.content[0].text;
}
assistantText = assistantText || "Sin respuesta del modelo.";
setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]);
// limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos)
setAttachedFiles([]);
setAttachedPreviews([]);
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
setSelectedFiles([]);
} catch (err) {
console.error("Error en handleConversation:", err);
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)]);
};
// Al hacer click en un vector: expandir (solo uno a la vez) y cargar sus archivos
const handleVectorClick = async (vector) => {
if (selectedVector === vector.id) {
// colapsar
setSelectedVector(null);
setVectorFiles([]);
setSelectedFiles([]);
return;
}
setSelectedVector(vector.id);
setSelectedFiles([]);
await loadFilesForVector(vector.id);
};
// Toggle selección de archivo (checkbox)
const toggleFileSelection = (file) => {
if (selectedFiles.some(f => f.id === file.id)) {
setSelectedFiles(prev => prev.filter(f => f.id !== file.id));
} else {
setSelectedFiles(prev => [...prev, file]);
}
};
const removeSelectedFile = (fileId) => {
setSelectedFiles(prev => prev.filter(f => f.id !== fileId));
};
// ---------- Send flow ----------
const handleSend = async () => {
// no permitir enviar si no hay nada
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
if (creatingConversation) {
// no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
return;
}
if (!conversationId) {
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() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
setMessages(prev => [...prev, { role: "user", content: userText }]);
setInput("");
await handleConversation({ text: userText });
};
function cleanAIResponse(text) {
if (!text) return text;
let cleaned = text;
// -------------------------
// 1. Eliminar emojis
// -------------------------
cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, "");
// -------------------------
// 2. Eliminar separadores tipo ---
// -------------------------
cleaned = cleaned.replace(/^---+$/gm, "");
// -------------------------
// 3. Eliminar saludos y frases meta
// -------------------------
const metaPatterns = [
/^hola[!¡., ]*/i,
/^buen(os|as) (días|tardes|noches)[!¡., ]*/i,
/estoy aquí para ayudarte[.! ]*/gi,
/aquí tienes[,:]*/gi,
/claro[,:]*/gi,
/como pediste[,:]*/gi,
/como solicitaste[,:]*/gi,
/el texto íntegro que compartiste.*$/gi,
/te lo dejo a continuación.*$/gi,
/¿te gustaría.*$/gi,
/¿en qué más puedo.*$/gi,
/si necesitas algo más.*$/gi,
/con gusto.*$/gi,
];
metaPatterns.forEach(p => {
cleaned = cleaned.replace(p, "").trim();
});
// -------------------------
// 4. Extraer solo contenido útil
// -------------------------
const startMarker = "CONTEXTO DEL CAMPO";
const startIndex = cleaned.indexOf(startMarker);
if (startIndex !== -1) {
cleaned = cleaned.substring(startIndex).trim();
}
// -------------------------
// 5. Eliminar líneas vacías múltiples
// -------------------------
cleaned = cleaned.replace(/\n{2,}/g, "\n\n");
// -------------------------
// 6. Quitar numeraciones de cortesía (opcional)
// Ejemplo: “1. ” al inicio de líneas
// -------------------------
cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, "");
return cleaned.trim();
}
const handleApply = () => {
const last = [...messages].reverse().find(m => m.role === "assistant");
if (last && onAccept) {
const cleaned = cleanAIResponse(last.content);
onAccept(cleaned);
onClose();
}
};
const cleanAssistantResponse = (text) => {
if (!text) return text;
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"
>
<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">Repositorio de archivos</h3>
<ScrollArea className="flex-1">
{loadingVectors ? (
<p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
) : vectorStores.length === 0 ? (
<p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
) : (
<div className="space-y-3">
{vectorStores.map((vector) => (
<div key={vector.id}>
{/* VECTOR */}
<div
onClick={() => handleVectorClick(vector)}
className={`p-3 rounded-lg border cursor-pointer transition flex items-center justify-between
${selectedVector === vector.id ? "bg-blue-50 border-blue-400 shadow" : "bg-white border-gray-300"}`}
>
<div className="truncate">
<strong className="block truncate">{vector.name || vector.id}</strong>
<p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
</div>
<div className="text-xs text-gray-500">{selectedVector === vector.id ? "▼" : "▶"}</div>
</div>
{/* ARCHIVOS cuando está expandido */}
{selectedVector === vector.id && (
<div className="ml-4 mt-2 mb-2 space-y-2">
{loadingFiles ? (
<p className="text-gray-400 text-sm">Cargando archivos...</p>
) : vectorFiles.length === 0 ? (
<p className="text-gray-400 text-sm">No hay archivos en este repositorio</p>
) : (
vectorFiles.map((file) => (
<label key={file.id} className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selectedFiles.some(f => f.id === file.id)}
onChange={() => toggleFileSelection(file)}
/>
<div className="text-sm">
<div className="font-medium">{file.filename ?? file.name ?? file.id}</div>
<div className="text-xs text-gray-400">{file.id}</div>
</div>
</label>
))
)}
</div>
)}
</div>
))}
</div>
)}
</ScrollArea>
{/* Resumen de archivos seleccionados (de vectores) */}
<div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Archivos seleccionados</h4>
{selectedFiles.length === 0 ? (
<p className="text-sm text-gray-500">No has seleccionado archivos del repositorio</p>
) : (
<ul className="space-y-2 max-h-40 overflow-auto">
{selectedFiles.map((f) => (
<li key={f.id} className="flex items-center justify-between p-2 rounded-md border bg-white">
<div className="text-sm">
<div className="font-medium">{f.filename ?? f.name ?? f.id}</div>
<div className="text-xs text-gray-400 truncate">{f.id}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{/* optionally show vector id */}</span>
<button onClick={() => removeSelectedFile(f.id)} className="text-sm text-red-500 hover:underline">Quitar</button>
</div>
</li>
))}
</ul>
)}
</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 && selectedFiles.length === 0)} 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" import type { RefRow } from "@/types/RefRow"
// POST -> recibe blob PDF y (opcional) Content-Disposition // 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, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -35,6 +35,7 @@ export function DetailDialog({
onClose: () => void onClose: () => void
pdfUrl?: string pdfUrl?: string
}) { }) {
console.log("DetailDialog render", { row })
const [viewerUrl, setViewerUrl] = useState<string | null>(null) const [viewerUrl, setViewerUrl] = useState<string | null>(null)
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null) const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
const [filename, setFilename] = useState<string>("archivo.pdf") const [filename, setFilename] = useState<string>("archivo.pdf")
@@ -48,13 +49,15 @@ export function DetailDialog({
const ctrl = new AbortController() const ctrl = new AbortController()
async function load() { async function load() {
if (!row?.s3_file_path) { console.log(row)
if (!row?.documentos_id) {
setViewerUrl(null) setViewerUrl(null)
setCurrentBlob(null) setCurrentBlob(null)
console.warn("No hay documentos_id en el row")
return return
} }
try { 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 if (ctrl.signal.aborted) return
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition) const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
setFilename(name) 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) // 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.") 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`, { 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 })
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition) const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
const link = document.createElement("a") const link = document.createElement("a")
const href = URL.createObjectURL(blob) const href = URL.createObjectURL(blob)
@@ -111,7 +114,7 @@ export function DetailDialog({
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-fit">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle> <DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription> <DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
@@ -131,13 +134,13 @@ export function DetailDialog({
</span> </span>
)} )}
</div> </div>
{/* Tags
{row.tags?.length ? ( {row.tags?.length ? (
<div className="text-xs text-neutral-600"> <div className="text-xs text-neutral-600">
<span className="font-medium">Tags: </span> <span className="font-medium">Tags: </span>
{row.tags.join(", ")} {row.tags.join(", ")}
</div> </div>
) : null} ) : null} */}
<div> <div>
<Label className="text-xs text-neutral-600">Instrucciones</Label> <Label className="text-xs text-neutral-600">Instrucciones</Label>

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase" import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { import {
Dialog, Dialog,
@@ -35,11 +35,13 @@ export function EditBibliografiaButton({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [text, setText] = useState("") const [text, setText] = useState("")
const auth = useSupabaseAuth()
const initialTextRef = useRef("") const initialTextRef = useRef("")
const lines = useMemo(() => parseLines(text), [text]) const lines = useMemo(() => parseLines(text), [text])
const dirty = useMemo(() => initialTextRef.current !== text, [text]) const dirty = useMemo(() => initialTextRef.current !== text, [text])
// 🔹 Abre el editor y carga los valores actuales
function openEditor() { function openEditor() {
const start = (value ?? []).join("\n") const start = (value ?? []).join("\n")
setText(start) setText(start)
@@ -47,52 +49,110 @@ export function EditBibliografiaButton({
setOpen(true) 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() { async function save() {
setSaving(true)
try { try {
setSaving(true) // 1⃣ Obtener bibliografía anterior
const refs = parseLines(text) 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 const { data, error } = await supabase
.from("asignaturas") .from("asignaturas")
.update({ bibliografia: refs }) .update({ bibliografia: newRefs })
.eq("id", asignaturaId) .eq("id", asignaturaId)
.select() .select()
.maybeSingle() .maybeSingle()
if (error) throw error if (error) throw error
onSaved((data as any)?.bibliografia ?? refs) // 5⃣ Refrescar estado local
initialTextRef.current = refs.join("\n") onSaved((data as any)?.bibliografia ?? newRefs)
toast.success(`${refs.length} referencia(s) guardada(s).`) initialTextRef.current = newRefs.join("\n")
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
setOpen(false) setOpen(false)
} catch (e: any) { } catch (err: any) {
toast.error(e?.message ?? "No se pudo guardar") toast.error(err.message ?? "No se pudo guardar la bibliografía")
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
// Acciones // 🔧 Acciones extra
function actionTrim() { function actionTrim() {
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim()) const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
setText(next.join("\n")) setText(next.join("\n"))
} }
function actionDedupe() { function actionDedupe() {
const seen = new Set<string>() const seen = new Set<string>()
const next: string[] = [] const next: string[] = []
for (const l of parseLines(text)) { for (const l of parseLines(text)) {
const k = l.toLowerCase() 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")) setText(next.join("\n"))
} }
function actionSort() { 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")) setText(next.join("\n"))
} }
async function actionImportClipboard() { async function actionImportClipboard() {
try { try {
const clip = await navigator.clipboard.readText() 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)] const next = [...parseLines(text), ...parseLines(clip)]
setText(next.join("\n")) setText(next.join("\n"))
toast.success("Texto importado") toast.success("Texto importado")
@@ -100,6 +160,7 @@ export function EditBibliografiaButton({
toast.error(e?.message ?? "No se pudo leer el portapapeles") toast.error(e?.message ?? "No se pudo leer el portapapeles")
} }
} }
async function actionExportClipboard() { async function actionExportClipboard() {
try { try {
await navigator.clipboard.writeText(parseLines(text).join("\n")) await navigator.clipboard.writeText(parseLines(text).join("\n"))
@@ -109,7 +170,7 @@ export function EditBibliografiaButton({
} }
} }
// Atajo guardar // ⌨️ Atajo Ctrl+S
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (!open) return if (!open) return
@@ -120,7 +181,6 @@ export function EditBibliografiaButton({
} }
window.addEventListener("keydown", onKey) window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, saving, dirty, text]) }, [open, saving, dirty, text])
return ( return (

View File

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

View File

@@ -0,0 +1,91 @@
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 { 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

@@ -6,14 +6,14 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button" import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from "canvas-confetti" import confetti from "canvas-confetti"
import { useQueryClient } from "@tanstack/react-query" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { supabase } from "@/auth/supabase"
import { Field } from "./Field" import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries" import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) { export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient() const router = useRouter()
const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [mode, setMode] = useState<"manual" | "ia">("manual") const [mode, setMode] = useState<"manual" | "ia">("manual")
@@ -42,32 +42,47 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
horas_teoricas: toNum(f.horas_teoricas), horas_teoricas: toNum(f.horas_teoricas),
horas_practicas: toNum(f.horas_practicas), horas_practicas: toNum(f.horas_practicas),
objetivos: toNull(f.objetivos), 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) setSaving(false)
if (error) { alert(error.message); return } if (error) { alert(error.message); return }
setOpen(false) setOpen(false)
onAdded?.() onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} }
async function createWithAI() { async function createWithAI() {
if (!canIA) return if (!canIA) return
setSaving(true) setSaving(true)
// inserte la asignatura generada directamente
// obtengas el uuid que se insertó
try { try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, { const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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()) 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 } }) confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false) setOpen(false)
router.invalidate()
router.navigate({
to: "/asignatura/$asignaturaId",
params: { asignaturaId },
})
onAdded?.() onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) }) // qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) }) // qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Error al generar la asignatura") alert(e?.message ?? "Error al generar la asignatura")
} finally { setSaving(false) } } finally { setSaving(false) }

View File

@@ -85,12 +85,14 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera" const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
const lockCarrera = role === "jefe_carrera" const lockCarrera = role === "jefe_carrera"
useEffect(() => { useEffect(() => {
async function fetchDbFiles() { async function fetchDbFiles() {
try { try {
const { data, error } = await supabase const { data, error } = await supabase
.from("fine_tuning_referencias") .from("documentos")
.select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags") .select("documentos_id, titulo_archivo, fecha_subida, tags")
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`) .ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1); .range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
@@ -100,9 +102,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
} }
setDbFiles((data || []).map((file: any) => ({ setDbFiles((data || []).map((file: any) => ({
id: file.fine_tuning_referencias_id, id: file.documentos_id,
titulo: file.titulo_archivo, titulo: file.titulo_archivo,
s3_file_path: file.s3_file_path, s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
fecha_subida: file.fecha_subida, fecha_subida: file.fecha_subida,
tags: file.tags || [], tags: file.tags || [],
}))); })));
@@ -116,35 +118,35 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]); const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
const toggleSelected = useCallback((path: string) => { const toggleSelected = useCallback((id: string) => {
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]); setSelectedFiles(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
}, []); }, []);
const replaceSelection = useCallback((path: string) => { const replaceSelection = useCallback((id: string) => {
setSelectedFiles([path]); setSelectedFiles([id]);
}, []); }, []);
const rangeSelect = useCallback((start: number, end: number) => { const rangeSelect = useCallback((start: number, end: number) => {
const [s, e] = start < end ? [start, end] : [end, start]; const [s, e] = start < end ? [start, end] : [end, start];
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path); const ids = dbFiles.slice(s, e + 1).map(f => f.id);
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths]))); setSelectedFiles(prev => Array.from(new Set([...prev, ...ids])));
}, [dbFiles]); }, [dbFiles]);
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => { const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { id: string }) => {
const path = file.s3_file_path; const id = file.id;
if (e.shiftKey && lastSelectedIndex !== null) { if (e.shiftKey && lastSelectedIndex !== null) {
rangeSelect(lastSelectedIndex, index); rangeSelect(lastSelectedIndex, index);
} else if (e.metaKey || e.ctrlKey) { } else if (e.metaKey || e.ctrlKey) {
toggleSelected(path); toggleSelected(id);
setLastSelectedIndex(index); setLastSelectedIndex(index);
} else { } else {
if (isSelected(path) && selectedFiles.length === 1) { if (isSelected(id) && selectedFiles.length === 1) {
// si ya es el único seleccionado, des-selecciona // si ya es el único seleccionado, des-selecciona
setSelectedFiles([]); setSelectedFiles([]);
setLastSelectedIndex(null); setLastSelectedIndex(null);
} else { } else {
replaceSelection(path); replaceSelection(id);
setLastSelectedIndex(index); setLastSelectedIndex(index);
} }
} }
@@ -162,9 +164,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
try { try {
const res = await postAPI("/api/generar/plan", { const res = await postAPI("/api/generar/plan", {
carreraId, carreraId,
prompt, prompt: prompt,
insert: true, insert: true,
files: selectedFiles, files: selectedFiles,
created_by: auth.user?.id,
}) })
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
if (newId) { if (newId) {
@@ -258,10 +261,11 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{dbFiles.map((file, index) => { {dbFiles.map((file, index) => {
const ext = fileExt(file.titulo); const ext = fileExt(file.titulo);
const selected = isSelected(file.s3_file_path); const selected = isSelected(file.id);
console.log(file);
return ( return (
<button <div
type="button"
key={file.id} key={file.id}
role="gridcell" role="gridcell"
aria-selected={selected} aria-selected={selected}
@@ -269,7 +273,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPreviewRow({ setPreviewRow({
fine_tuning_referencias_id: file.id, documentos_id: file.id,
created_by: "unknown", created_by: "unknown",
s3_file_path: file.s3_file_path, s3_file_path: file.s3_file_path,
titulo_archivo: file.titulo, titulo_archivo: file.titulo,
@@ -312,6 +316,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
) : ( ) : (
<p className="text-xs text-neutral-500">Fecha desconocida</p> <p className="text-xs text-neutral-500">Fecha desconocida</p>
)} )}
{file.tags && file.tags.length > 0 && ( {file.tags && file.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{file.tags.map((tag, i) => ( {file.tags.map((tag, i) => (
@@ -330,7 +335,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPreviewRow({ setPreviewRow({
fine_tuning_referencias_id: file.id, documentos_id: file.id,
created_by: "unknown", created_by: "unknown",
s3_file_path: file.s3_file_path, s3_file_path: file.s3_file_path,
titulo_archivo: file.titulo, titulo_archivo: file.titulo,
@@ -352,7 +357,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<span className="truncate">{ext.toUpperCase()}</span> <span className="truncate">{ext.toUpperCase()}</span>
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>} {selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
</div> </div>
</button> </div>
) )
})} })}

View File

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

View File

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

@@ -4,8 +4,14 @@ import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@ta
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase" import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { toast } from "sonner" import { toast } from "sonner"
import ReactMarkdown from 'react-markdown'
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
// @ts-ignore
import AIChatModal from "../ai/AIChatModal"
/* ===================================================== /* =====================================================
Query keys & fetcher Query keys & fetcher
@@ -25,6 +31,7 @@ export type PlanTextFields = {
indicadores_desempeno?: string | string[] | null indicadores_desempeno?: string | string[] | null
pertinencia?: string | string[] | null pertinencia?: string | string[] | null
prompt?: string | null prompt?: string | null
historico?: string | null
} }
async function fetchPlanText(planId: string): Promise<PlanTextFields> { async function fetchPlanText(planId: string): Promise<PlanTextFields> {
@@ -48,6 +55,8 @@ export const planTextOptions = (planId: string) =>
staleTime: 60_000, staleTime: 60_000,
}) })
/* ===================================================== /* =====================================================
Color helpers Color helpers
===================================================== */ ===================================================== */
@@ -63,7 +72,7 @@ const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb
/* ===================================================== /* =====================================================
Expandable text Expandable text
===================================================== */ ===================================================== */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) { function ExpandableText({ text }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) { if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span> return <span className="text-neutral-400"></span>
@@ -72,7 +81,7 @@ function ExpandableText({ text, mono = false }: { text?: string | string[] | nul
const rendered = Array.isArray(text) ? `${content}` : content const rendered = Array.isArray(text) ? `${content}` : content
return ( return (
<div> <div>
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div> <ReactMarkdown>{rendered}</ReactMarkdown>
{String(rendered).length > 220 && ( {String(rendered).length > 220 && (
<button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline"> <button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
{open ? "Ver menos" : "Ver más"} {open ? "Ver menos" : "Ver más"}
@@ -109,11 +118,16 @@ function SectionPanel({ title, icon: Icon, color, children, id }: { title: strin
===================================================== */ ===================================================== */
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) { export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
const qc = useQueryClient() 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 { data: plan } = useSuspenseQuery(planTextOptions(planId))
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null) const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
// --- mutation con actualización optimista --- // --- mutation con actualización optimista ---
const updateField = useMutation({ const updateField = useMutation({
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => { mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
@@ -151,69 +165,172 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" 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-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-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 ( return (
<> <>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
{sections.map((s) => { {sections.map((s) => {
const text = plan[s.key] ?? null const text = plan[s.key] ?? null
return ( return (
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}> <SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} /> {s.key === "historico" ? (
<div className="mt-4 flex flex-wrap gap-2"> <>
<Button <Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
variant="outline" Ver historial
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>
<Button <Button variant="outline" size="sm" onClick={() => setopenModalIa(true)}>
variant="ghost" Promt
size="sm"
onClick={() => {
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
setEditing({ key: s.key, title: s.title })
setDraft(current)
}}
>
Editar
</Button> </Button>
</div> </>
</SectionPanel> ) : (
) <>
})} <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> </div>
{/* Diálogo de edición */} {/* 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"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle> <DialogTitle className="font-mono">
</DialogHeader> {editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" /> </DialogTitle>
<DialogFooter> </DialogHeader>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button <Textarea
onClick={() => { value={draft}
if (!editing) return onChange={(e) => setDraft(e.target.value)}
updateField.mutate({ key: editing.key, value: draft }) className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
setEditing(null) placeholder="Escribe aquí…"
}} />
disabled={updateField.isPending}
> <DialogFooter>
{updateField.isPending ? "Guardando…" : "Guardar"} <Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
</Button> <Button
</DialogFooter> onClick={async () => {
</DialogContent> if (!editing) return
</Dialog>
// 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: key as keyof PlanTextFields, value })
}}
/>
<AIChatModal
//plan_format={plan_format}
open={openModalIa}
onClose={() => setopenModalIa(false)}
context={{
section: null,//,iaContext?.title,
fieldKey: null,//iaContext?.key,
originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`,
}}
onAccept={(newText: string) => {
if (iaContext) {
updateField.mutate({ key: iaContext.key, value: newText })
setIaContext(null)
}
}}
/>
</> </>
) )
} }

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" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}
> >
{children} {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.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )

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

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

View File

@@ -0,0 +1,65 @@
import { useMutation } 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
) {
// 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

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

View File

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

View File

@@ -73,7 +73,6 @@ function useUserDisplay() {
avatar: claims?.avatar ?? null, avatar: claims?.avatar ?? null,
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")), initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
role, role,
isAdmin: Boolean(claims?.claims_admin),
} }
} }
@@ -150,8 +149,6 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) { function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth() const { claims } = useSupabaseAuth()
const isAdmin = Boolean(claims?.claims_admin)
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '') const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
@@ -189,18 +186,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</Link> </Link>
)} )}
{isAdmin && (
<Link <Link
to="/facultades" to="/facultades"
key='facultades' key='facultades'
activeOptions={{ exact: true }} activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }} 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" 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" /> <KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span> <span className="truncate">Facultades</span>
</Link> </Link>
)}
</nav> </nav>
</ScrollArea> </ScrollArea>
<Separator className="mt-auto" /> <Separator className="mt-auto" />

View File

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

View File

@@ -1,8 +1,9 @@
// routes/_authenticated/asignatura/$asignaturaId.tsx // routes/_authenticated/asignatura/$asignaturaId.tsx
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useRouter } from "@tanstack/react-router" import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -134,7 +135,7 @@ function Page() {
{/* ===== Hero ===== */} {/* ===== Hero ===== */}
<div className="relative overflow-hidden rounded-3xl border shadow-sm"> <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={`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="min-w-0">
<div className="inline-flex items-center gap-2 text-xs text-neutral-600"> <div className="inline-flex items-center gap-2 text-xs text-neutral-600">
<Icons.BookOpen className="h-4 w-4" /> Asignatura <Icons.BookOpen className="h-4 w-4" /> Asignatura
@@ -165,6 +166,7 @@ function Page() {
</Button> </Button>
<EditAsignaturaButton asignatura={a} onUpdate={setA} /> <EditAsignaturaButton asignatura={a} onUpdate={setA} />
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} /> <MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
<BorrarAsignaturaButton asignatura_id={a.id} />
</div> </div>
</div> </div>
@@ -191,7 +193,7 @@ function Page() {
)} )}
{/* Syllabus */} {/* Syllabus */}
{unidades.length > 0 && (
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}> <Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -285,7 +287,7 @@ function Page() {
) )
})()} })()}
</Section> </Section>
)}
{/* Bibliografía */} {/* Bibliografía */}
@@ -401,29 +403,79 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [form, setForm] = useState<Partial<Asignatura>>({}) const [form, setForm] = useState<Partial<Asignatura>>({})
const auth = useSupabaseAuth()
const openAndFill = () => { setForm(asignatura); setOpen(true) } 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() { async function save() {
setSaving(true) setSaving(true)
const payload = { try {
nombre: form.nombre ?? asignatura.nombre, // 1⃣ Preparar el payload final
clave: form.clave ?? asignatura.clave, const payload = {
tipo: form.tipo ?? asignatura.tipo, nombre: form.nombre ?? asignatura.nombre,
semestre: form.semestre ?? asignatura.semestre, clave: form.clave ?? asignatura.clave,
creditos: form.creditos ?? asignatura.creditos, tipo: form.tipo ?? asignatura.tipo,
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas, semestre: form.semestre ?? asignatura.semestre,
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas, 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 ( 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 }) { function Field({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@@ -603,6 +700,7 @@ export function EditContenidosButton({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [units, setUnits] = useState<UnitDraft[]>([]) const [units, setUnits] = useState<UnitDraft[]>([])
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([]) const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
// --- Normaliza entrada flexible a estructura estable // --- Normaliza entrada flexible a estructura estable
const normalize = useCallback((v: any): UnitDraft[] => { const normalize = useCallback((v: any): UnitDraft[] => {
@@ -632,13 +730,13 @@ export function EditContenidosButton({
} }
return { title, temas } return { title, temas }
}) })
return entries.length ? entries : [{ title: "Unidad 1", temas: [] }] return entries.length ? entries : [{ title: "", temas: [] }]
} catch { } 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 buildPayload = useCallback((us: UnitDraft[]) => {
const out: Record<string, any> = {} const out: Record<string, any> = {}
us.forEach((u, idx) => { us.forEach((u, idx) => {
@@ -650,14 +748,14 @@ export function EditContenidosButton({
.forEach((t, i) => { .forEach((t, i) => {
sub[String(i + 1)] = t 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 return out
}, []) }, [])
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive) // --- Limpia UI
const cleanUnits = useCallback((us: UnitDraft[]) => { const cleanUnits = useCallback((us: UnitDraft[]) => {
return us.map((u, idx) => { return us.map((u) => {
const seen = new Set<string>() const seen = new Set<string>()
const temas = u.temas const temas = u.temas
.map((t) => t.trim()) .map((t) => t.trim())
@@ -668,10 +766,7 @@ export function EditContenidosButton({
seen.add(key) seen.add(key)
return true return true
}) })
return { return { title: (u.title || "").trim(), temas }
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
temas,
}
}) })
}, []) }, [])
@@ -687,7 +782,7 @@ export function EditContenidosButton({
[units, initialUnits, cleanUnits], [units, initialUnits, cleanUnits],
) )
// --- Atajos: Guardar con Ctrl/Cmd + Enter // --- Atajos: Ctrl/Cmd + Enter
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@@ -699,7 +794,6 @@ export function EditContenidosButton({
} }
window.addEventListener("keydown", handler) window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, units, saving]) }, [open, units, saving])
// --- Acciones por unidad // --- Acciones por unidad
@@ -707,15 +801,17 @@ export function EditContenidosButton({
if (!confirm("¿Eliminar esta unidad?")) return if (!confirm("¿Eliminar esta unidad?")) return
setUnits((prev) => prev.filter((_, i) => i !== idx)) setUnits((prev) => prev.filter((_, i) => i !== idx))
} }
const moveUnit = (idx: number, dir: -1 | 1) => { const moveUnit = (idx: number, dir: -1 | 1) => {
setUnits((prev) => { setUnits((prev) => {
const next = [...prev] const next = [...prev]
const j = idx + dir const j = idx + dir
if (j < 0 || j >= next.length) return prev 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 return next
}) })
} }
const duplicateUnit = (idx: number) => { const duplicateUnit = (idx: number) => {
setUnits((prev) => { setUnits((prev) => {
const next = [...prev] const next = [...prev]
@@ -727,24 +823,54 @@ export function EditContenidosButton({
}) })
} }
// ✅ Función para guardar con respaldo histórico
async function save() { async function save() {
setSaving(true) setSaving(true)
const cleaned = cleanUnits(units) try {
const contenidos = buildPayload(cleaned) const cleaned = cleanUnits(units)
const { data, error } = await supabase const contenidos = buildPayload(cleaned)
.from("asignaturas")
.update({ contenidos }) // 1⃣ Generar diff entre valor anterior y nuevo
.eq("id", asignaturaId) const diff = [
.select() {
.maybeSingle() op: "replace",
setSaving(false) path: "/contenidos",
if (error) { from: value,
alert(error.message || "No se pudo guardar") value: contenidos,
return },
]
// 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 = () => { const cancel = () => {
@@ -839,7 +965,7 @@ export function EditContenidosButton({
<Button <Button
variant="secondary" variant="secondary"
onClick={() => 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 <Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
@@ -850,7 +976,7 @@ export function EditContenidosButton({
<DialogFooter className="px-6 pb-5"> <DialogFooter className="px-6 pb-5">
<Button variant="outline" onClick={cancel}>Cancelar</Button> <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 ? ( {saving ? (
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando <Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando

View File

@@ -10,6 +10,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { toast } from 'sonner' import { toast } from 'sonner'
import { InfoChip } from '@/components/planes/InfoChip'
/* ================== Tipos ================== */ /* ================== Tipos ================== */
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null } type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
@@ -79,6 +80,7 @@ async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId' | 'carrera
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> { async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
const planIds = await fetchPlanIdsByScope(search) const planIds = await fetchPlanIdsByScope(search)
if (planIds && planIds.length === 0) return [] if (planIds && planIds.length === 0) return []
console.log(AsignaturaCard);
let query = supabase let query = supabase
.from('asignaturas') .from('asignaturas')
@@ -166,9 +168,63 @@ function RouteComponent() {
const [q, setQ] = useState(search.q ?? '') const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos') const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos') const [tipo, setTipo] = useState<string>('todos')
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre') const [groupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '') const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas")
const [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 // NEW: Clonado individual
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null) const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
@@ -200,12 +256,6 @@ function RouteComponent() {
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b)) return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas]) }, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud // Salud
const salud = useMemo(() => { const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0 let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
@@ -217,7 +267,6 @@ function RouteComponent() {
return { sinBibliografia, sinCriterios, sinContenidos } return { sinBibliografia, sinCriterios, sinContenidos }
}, [asignaturas]) }, [asignaturas])
// Filtrado
const filtered = useMemo(() => { const filtered = useMemo(() => {
const t = q.trim().toLowerCase() const t = q.trim().toLowerCase()
return asignaturas.filter(a => { return asignaturas.filter(a => {
@@ -229,6 +278,9 @@ function RouteComponent() {
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const planOK = !search.planId || a.plan?.id === search.planId
const flagOK = const flagOK =
!flag || !flag ||
@@ -236,9 +288,10 @@ function RouteComponent() {
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) || (flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0)) (flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
}) })
}, [q, sem, tipo, flag, asignaturas]) }, [q, sem, tipo, flag, carrera, facultad, asignaturas])
// Agrupación // Agrupación
const groups = useMemo(() => { const groups = useMemo(() => {
@@ -257,7 +310,20 @@ function RouteComponent() {
}, [filtered, groupBy]) }, [filtered, groupBy])
// Helpers // 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 // NEW: util para clonar 1 asignatura
async function cloneOne(src: Asignatura, overrides: { async function cloneOne(src: Asignatura, overrides: {
@@ -292,6 +358,8 @@ function RouteComponent() {
if (error) throw error if (error) throw error
} }
// NEW: abrir modal clon individual // NEW: abrir modal clon individual
function openClone(a: Asignatura) { function openClone(a: Asignatura) {
setCloneTarget(a) setCloneTarget(a)
@@ -320,6 +388,8 @@ function RouteComponent() {
setCart([]) setCart([])
} }
// NEW: clonado en lote // NEW: clonado en lote
async function cloneBulk() { async function cloneBulk() {
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return } if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
@@ -394,51 +464,116 @@ function RouteComponent() {
</div> </div>
{/* Filtros */} {/* Filtros */}
<div className="grid gap-4 sm:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-5 items-end">
{/* 🔍 Búsqueda */}
<div> <div>
<Label>Búsqueda</Label> <Label>Búsqueda</Label>
<Input <Input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={handleChange}
placeholder="Nombre, clave, plan, carrera, facultad…" placeholder="Nombre, clave, plan, carrera, facultad…"
/> />
</div> </div>
{/* 📘 Semestre */}
<div> <div>
<Label>Semestre</Label> <Label>Semestre</Label>
<Select value={sem} onValueChange={setSem}> <Select value={sem} onValueChange={setSem}>
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="todos">Todos</SelectItem> <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> </SelectContent>
</Select> </Select>
</div> </div>
{/* 🏛️ Facultad */}
<div> <div>
<Label>Tipo</Label> <Label>Facultad</Label>
<Select value={tipo} onValueChange={setTipo}> <Select
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger> value={facultad ?? "todas"}
<SelectContent className="max-h-64"> onValueChange={(val) => {
<SelectItem value="todos">Todos</SelectItem> setFacultad(val)
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)} 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> </SelectContent>
</Select> </Select>
</div> </div>
<div> {/* 🎓 Carrera */}
<Label>Agrupación</Label> <div className={!facultad || facultad === "todas" ? "invisible" : ""}>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}> <Label>Carrera</Label>
<SelectTrigger><SelectValue /></SelectTrigger> <Select
value={carrera ?? "todas"}
onValueChange={(val) => setCarrera(val)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por carrera" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="semestre">Por semestre</SelectItem> <SelectItem value="todas">Todas las carreras</SelectItem>
<SelectItem value="ninguno">Sin agrupación</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({
to: '/asignaturas',
search: {
...search,
planId: val === 'todos' ? '' : val,
},
replace: true,
})
}}
>
<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> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
{/* Chips de salud */} {/* Chips de salud */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<HealthChip <HealthChip
@@ -462,7 +597,7 @@ function RouteComponent() {
label="Sin contenidos" label="Sin contenidos"
value={salud.sinContenidos} 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}> <Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
Limpiar filtros Limpiar filtros
</Button> </Button>
@@ -693,10 +828,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
const horasT = a.horas_teoricas ?? 0 const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0 const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo) const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2 console.log(a);
return ( return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"> <li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
style={{
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
}}
>
<div className="p-3"> <div className="p-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80"> <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 +887,17 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
<Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre} <Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre}
</span> </span>
{a.plan.carrera && ( {a.plan.carrera && (
<span className="inline-flex items-center gap-1"> <InfoChip
<Icons.GraduationCap className="w-3.5 h-3.5" /> <strong>Carrera:</strong> {a.plan.carrera.nombre} icon={<Icons.GraduationCap className="h-3 w-3" />}
</span> label={a.plan.carrera.nombre}
/>
)} )}
{a.plan.carrera?.facultad && ( {a.plan.carrera?.facultad && (
<span className="inline-flex items-center gap-1"> <InfoChip
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre} icon={<Icons.Building2 className="h-3 w-3" />}
</span> label={a.plan.carrera.facultad.nombre}
tint={a.plan.carrera.facultad.color}
/>
)} )}
</div> </div>
)} )}

View File

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

View File

@@ -175,7 +175,7 @@ function RouteComponent() {
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!' 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 role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { AcademicSections, planKeys } from "@/components/planes/academic-sections" import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
import { GradientMesh } from "../../../components/planes/GradientMesh" import { GradientMesh } from "../../../components/planes/GradientMesh"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries" import { asignaturaExtraOptions, asignaturaKeys, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { softAccentStyle } from "@/components/planes/planHelpers" import { softAccentStyle } from "@/components/planes/planHelpers"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { DialogFooter, DialogHeader } from "@/components/ui/dialog" import { DialogFooter, DialogHeader } from "@/components/ui/dialog"
@@ -19,8 +19,10 @@ import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button" import { AuroraButton } from "@/components/effect/aurora-button"
import { DeletePlanButton } from "@/components/planes/DeletePlan" import { DeletePlanButton } from "@/components/planes/DeletePlan"
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton" 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")({ export const Route = createFileRoute("/_authenticated/plan/$planId")({
component: RouteComponent, component: RouteComponent,
@@ -33,24 +35,27 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => { loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
const { planId } = params 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(planByIdOptions(planId)),
queryClient.ensureQueryData(asignaturasCountOptions(planId)), // queryClient.ensureQueryData(asignaturasCountOptions(planId)),
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)), queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
]) ])
return { planId }
return { plan, asignaturas }
}, },
}) })
// ...existing code... // ...existing code...
function RouteComponent() { function RouteComponent() {
const qc = useQueryClient() const qc = useQueryClient()
const { planId } = Route.useLoaderData() as LoaderData //const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth() const { plan } = Route.useLoaderData() as LoaderData
const { data: plan } = useSuspenseQuery(planByIdOptions(planId)) const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId)) const auth = useSupabaseAuth()
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId)) const asignaturasCount = asignaturasPreview.length
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria' const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico' const showCarrera = auth.claims?.role === 'secretario_academico'
@@ -78,7 +83,7 @@ function RouteComponent() {
</nav> </nav>
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm"> <Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
<div className="absolute inset-0 -z-0" style={accent} /> <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"> <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" <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 }}> style={{ borderColor: accent.borderColor as string }}>
@@ -98,11 +103,14 @@ function RouteComponent() {
{plan.estado} {plan.estado}
</Badge> </Badge>
)} )}
<div className='flex gap-2'> {/* <div className='flex gap-2'> */}
<EditPlanButton plan={plan} /> <EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} /> <AdjustAIButton plan={plan} />
{/* <DescargarPdfButton planId={plan.id} opcion="plan" /> */}
<DownloadPlanPDF plan={plan} />
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
<DeletePlanButton planId={plan.id} /> <DeletePlanButton planId={plan.id} />
</div> {/* </div> */}
</div> </div>
</CardHeader> </CardHeader>
<CardContent ref={statsRef}> <CardContent ref={statsRef}>
@@ -197,33 +205,77 @@ function StatCard({ label, value = "—", Icon = Icons.Info, accent, className =
/* ===== Editar ===== */ /* ===== Editar ===== */
function EditPlanButton({ plan }: { plan: PlanFull }) { function EditPlanButton({ plan }: { plan: PlanFull }) {
const auth = useSupabaseAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({}) const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const qc = useQueryClient() 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({ const mutation = useMutation({
mutationFn: async (payload: Partial<PlanFull>) => { mutationFn: async (payload: Partial<PlanFull>) => {
const { error } = await supabase.from('plan_estudios').update({ // 1⃣ Generar las diferencias antes del update
nombre: payload.nombre ?? plan.nombre, const diff = generateDiff(plan, payload)
nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion, // 2⃣ Guardar respaldo (solo si hay cambios)
total_creditos: payload.total_creditos ?? plan.total_creditos, if (diff.length > 0) {
}).eq('id', plan.id) 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 if (error) throw error
}, },
onMutate: async (payload) => { onMutate: async (payload) => {
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) }) await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
const prev = qc.getQueryData<PlanFull>(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 } return { prev }
}, },
onError: (_e, _vars, ctx) => { onError: (_e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev) if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
}, },
onSettled: async () => { onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) }) await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
} },
}) })
async function save() { async function save() {

View File

@@ -10,26 +10,44 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { InfoChip } from "@/components/planes/InfoChip" import { InfoChip } from "@/components/planes/InfoChip"
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog" import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
import { chipTint } from "@/components/planes/chipTint" 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 = { export type PlanDeEstudios = {
id: string; nombre: string; nivel: string | null; duracion: string | null; id: string
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null 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 & { type PlanRow = PlanDeEstudios & {
carreras: { carreras: {
id: string; nombre: string; id: string
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null nombre: string
facultades?: {
id: string
nombre: string
color?: string | null
icon?: string | null
} | null
} | null } | null
} }
const planSearchSchema = z.object({ 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")({ export const Route = createFileRoute("/_authenticated/planes")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async () => {
@@ -51,86 +69,185 @@ export const Route = createFileRoute("/_authenticated/planes")({
validateSearch: planSearchSchema, validateSearch: planSearchSchema,
}) })
function RouteComponent() { function RouteComponent() {
const auth = useSupabaseAuth() const auth = useSupabaseAuth()
const { plan } = Route.useSearch() const { plan, facultad, carrera } = Route.useSearch()
const [openCreate, setOpenCreate] = useState(false) const [openCreate, setOpenCreate] = useState(false)
const data = Route.useLoaderData() as PlanRow[] const data = Route.useLoaderData() as PlanRow[]
const router = useRouter() const router = useRouter()
const navigate = useNavigate({ from: Route.fullPath }) 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" // 🟣 Lista única de facultades
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico" 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 filtered = useMemo(() => {
const term = plan?.trim().toLowerCase() const term = plan?.trim().toLowerCase()
if (!term || !data) return data let results = data ?? []
return data.filter((p) =>
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre] if (term) {
.filter(Boolean) results = results.filter((p) =>
.some((v) => String(v).toLowerCase().includes(term)) [p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
) .filter(Boolean)
}, [plan, data]) .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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <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> <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"> <div className="relative w-full md:w-80">
<Input <Input
value={plan ?? ''} value={plan ?? ""}
onChange={e => navigate({ search: { plan: e.target.value } })} onChange={(e) =>
navigate({ search: { plan: e.target.value, facultad, carrera } })
}
placeholder="Buscar por nombre, nivel, estado…" placeholder="Buscar por nombre, nivel, estado…"
/> />
</div> </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" /> <RefreshCcw className="h-4 w-4" />
</Button> </Button>
{/* Nuevo plan */}
<Button onClick={() => setOpenCreate(true)}> <Button onClick={() => setOpenCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Nuevo plan <Plus className="mr-2 h-4 w-4" /> Nuevo plan
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
{/* GRID de tarjetas con estilo suave por facultad */} {/* GRID de tarjetas */}
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered?.map((p) => { {filtered?.map((p) => {
const fac = p.carreras?.facultades const fac = p.carreras?.facultades
const styles = chipTint(fac?.color) 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 ( return (
<Link <Link
key={p.id} key={p.id}
to="/plan/$planId" 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" 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 }} params={{ planId: p.id }}
style={styles} style={styles}
> >
<div className="relative p-5 h-40 flex flex-col justify-between"> <div className="relative p-5 h-40 flex flex-col justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2" <span
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}> 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" /> <IconComp className="w-6 h-6" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-semibold truncate">{p.nombre}</div> <div className="font-semibold truncate">{p.nombre}</div>
<div className="text-xs text-neutral-600 truncate"> <div className="text-xs text-neutral-600 truncate">
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""} {p.nivel ?? "—"}{" "}
{p.duracion ? `· ${p.duracion}` : ""}
</div> </div>
</div> </div>
</div> </div>
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
<div className="mt-3 flex items-center gap-2"> <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"> <div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
{showCarrera && p.carreras?.nombre && ( {showCarrera && p.carreras?.nombre && (
<InfoChip <InfoChip
@@ -147,18 +264,21 @@ function RouteComponent() {
)} )}
</div> </div>
{/* derecha: estado */}
{p.estado && ( {p.estado && (
<Badge <Badge
variant="outline" variant="outline"
className="bg-white/60" 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> </Badge>
)} )}
</div> </div>
</div> </div>
</Link> </Link>
) )
@@ -166,16 +286,14 @@ function RouteComponent() {
</div> </div>
{!filtered?.length && ( {!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> </CardContent>
</Card> </Card>
<CreatePlanDialog <CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
open={openCreate}
onOpenChange={setOpenCreate}
/>
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query" import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import type { Role, UserClaims } from "@/auth/supabase"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -10,58 +11,12 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import { import * as Icons from "lucide-react"
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 { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox" import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { toast } from "sonner" 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 -------------------- */ /* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = { const usersKeys = {
@@ -69,13 +24,58 @@ const usersKeys = {
list: () => [...usersKeys.root, "list"] as const, list: () => [...usersKeys.root, "list"] as const,
} }
async function fetchUsers(): Promise<AdminUser[]> { async function fetchUsers(): Promise<UserClaims[]> {
// ⚠️ Dev only: service role en cliente try {
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY) const { data: perfiles, error } = await supabase.from("perfiles").select("id");
const { data } = await admin.auth.admin.listUsers()
return (data?.users ?? []) as AdminUser[] 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 = () => const usersOptions = () =>
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 }) queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
@@ -91,12 +91,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({
/* -------------------- Página -------------------- */ /* -------------------- Página -------------------- */
function RouteComponent() { function RouteComponent() {
const auth = useSupabaseAuth() 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 router = useRouter()
const qc = useQueryClient() const qc = useQueryClient()
const { data } = useSuspenseQuery(usersOptions()) const { data } = useSuspenseQuery(usersOptions())
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [editing, setEditing] = useState<AdminUser | null>(null) const [editing, setEditing] = useState<UserClaims | null>(null)
const [form, setForm] = useState<{ const [form, setForm] = useState<{
role?: Role role?: Role
claims_admin?: boolean claims_admin?: boolean
@@ -117,9 +142,17 @@ function RouteComponent() {
carrera_id?: string | null carrera_id?: string | null
}>({ email: "", password: "" }) }>({ email: "", password: "" })
function genPassword() {
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("") function RolePill({ role }: { role: Role }) {
return s.slice(0, 14) 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 ---------- */ /* ---------- Mutations ---------- */
@@ -128,94 +161,44 @@ function RouteComponent() {
router.invalidate() router.invalidate()
} }
const upsertNombramiento = useMutation({
mutationFn: async (opts: {
user_id: string
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
facultad_id?: string | null
carrera_id?: string | null
}) => {
// cierra vigentes
if (opts.puesto === "jefe_carrera") {
if (!opts.carrera_id) throw new Error("Selecciona carrera")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", "jefe_carrera")
.eq("carrera_id", opts.carrera_id)
.is("hasta", null)
} else {
if (!opts.facultad_id) throw new Error("Selecciona facultad")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", opts.puesto)
.eq("facultad_id", opts.facultad_id)
.is("hasta", null)
}
const { error } = await supabase.from("nombramientos").insert({
user_id: opts.user_id,
puesto: opts.puesto,
facultad_id: opts.facultad_id ?? null,
carrera_id: opts.carrera_id ?? null,
desde: new Date().toISOString().slice(0, 10),
hasta: null,
})
if (error) throw error
},
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
})
const toggleBan = useMutation({
mutationFn: async (u: AdminUser) => {
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
if (error) throw new Error(error.message)
return !banned
},
onSuccess: async (isBanned) => {
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
await invalidateAll()
},
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
})
const createUser = useMutation({ const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => { mutationFn: async (payload: typeof createForm) => {
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY) // Validaciones previas
const password = payload.password?.trim() || genPassword() if (!payload.role) {
const { error, data } = await admin.auth.admin.createUser({ 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(), email: payload.email.trim(),
password, password,
email_confirm: true, options: {
user_metadata: { data: {
nombre: payload.nombre ?? "", nombre: payload.nombre ?? "",
apellidos: payload.apellidos ?? "", apellidos: payload.apellidos ?? "",
title: payload.title ?? "", title: payload.title ?? "",
clave: payload.clave ?? "", clave: payload.clave ?? "",
avatar: payload.avatar ?? "", avatar: payload.avatar ?? "",
}, role: payload.role,
app_metadata: { role_id: payload.role ? ROLE_META[payload.role]?.id : null,
role: payload.role, facultad_id: payload.facultad_id ?? null,
claims_admin: !!payload.claims_admin, carrera_id: payload.carrera_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 && 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 })
} }
});
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 () => { onSuccess: async () => {
@@ -228,19 +211,23 @@ function RouteComponent() {
}) })
const saveUser = useMutation({ const saveUser = useMutation({
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => { mutationFn: async ({ u, f }: { u: UserClaims; 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 }) }) const { error } = await supabase.rpc('actualizar_perfil_y_rol', {
// Simula éxito: datos: {
// 2) Nombramiento si aplica user_id: u.id,
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) { rol_nombre: f.role,
if (f.role === "director_facultad") { titulo: f.title,
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! }) facultad_id: f.facultad_id,
} else if (f.role === "secretario_academico") { carrera_id: f.carrera_id,
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! }) nombre: f.nombre,
} else if (f.role === "jefe_carrera") { apellidos: f.apellidos,
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! }) avatar: f.avatar,
} }
});
if (error) {
throw new Error(error.message);
} }
}, },
onSuccess: async () => { onSuccess: async () => {
@@ -251,34 +238,29 @@ function RouteComponent() {
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"), 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 filtered = useMemo(() => {
const t = q.trim().toLowerCase() const t = q.trim().toLowerCase()
if (!t) return data if (!t) return data
return data.filter((u) => { 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 : "" 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) .filter(Boolean)
.some((v) => String(v).toLowerCase().includes(t)) .some((v) => String(v).toLowerCase().includes(t))
}) })
}, [q, data]) }, [q, data])
function openEdit(u: AdminUser) { function openEdit(u: UserClaims) {
setEditing(u) setEditing(u)
setForm({ setForm({
role: u.app_metadata?.role, role: u.role,
claims_admin: !!u.app_metadata?.claims_admin, nombre: u.nombre ?? "",
nombre: u.user_metadata?.nombre ?? "", apellidos: u.apellidos ?? "",
apellidos: u.user_metadata?.apellidos ?? "", title: u.title ?? "",
title: u.user_metadata?.title ?? "", clave: u.clave ?? "",
clave: u.user_metadata?.clave ?? "", avatar: u.avatar ?? "",
avatar: u.user_metadata?.avatar ?? "", facultad_id: u.facultad_id ?? null,
facultad_id: u.app_metadata?.facultad_id ?? null, carrera_id: u.carrera_id ?? null,
carrera_id: u.app_metadata?.carrera_id ?? null,
}) })
} }
@@ -301,10 +283,10 @@ function RouteComponent() {
<div className="flex items-center gap-2"> <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" /> <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}> <Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
<RefreshCcw className="w-4 h-4" /> <Icons.RefreshCcw className="w-4 h-4" />
</Button> </Button>
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap"> <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> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -312,48 +294,48 @@ function RouteComponent() {
<CardContent> <CardContent>
<div className="grid gap-3"> <div className="grid gap-3">
{filtered.map((u) => { {filtered.map((u) => {
const m = u.user_metadata || {} const roleCode: Role | undefined = u.role
const a = u.app_metadata || {} const banned = false // cuando se tenga acceso a ese campo
const roleCode: Role | undefined = a.role // const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
return ( 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 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"> <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="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <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"> <div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
{roleCode && <RolePill role={roleCode} />} {roleCode && <RolePill role={roleCode} />}
{a.claims_admin ? ( {u.role === "lci" || u.role === "vicerrectoria" ? (
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge> <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"> <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> </Badge>
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex"> <Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} <Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
</Button> </Button>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}> <Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
<Pencil className="w-4 h-4 mr-1" /> Editar <Icons.Pencil className="w-4 h-4 mr-1" /> Editar
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600"> <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 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> </div>
<div className="sm:hidden self-start shrink-0 flex gap-1"> <div className="sm:hidden self-start shrink-0 flex gap-1">
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button> <Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button> <Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
</div> </div>
</div> </div>
</div> </div>
@@ -371,7 +353,7 @@ function RouteComponent() {
<div className="grid gap-4 md:grid-cols-2"> <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>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>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>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"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
<div className="space-y-1"> <div className="space-y-1">
@@ -422,6 +404,7 @@ function RouteComponent() {
</div> </div>
)} )}
{/* Probablemente ya no sea necesario
<div className="space-y-1"> <div className="space-y-1">
<Label>Permisos</Label> <Label>Permisos</Label>
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}> <Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
@@ -431,7 +414,7 @@ function RouteComponent() {
<SelectItem value="false">Usuario</SelectItem> <SelectItem value="false">Usuario</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div> */}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button> <Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
@@ -463,16 +446,16 @@ function RouteComponent() {
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<Label>Contraseña temporal</Label> <Label>Contraseña temporal</Label>
<div className="flex gap-2"> <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" /> <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={() => 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> <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> </div>
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p> <p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
</div> </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>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>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"><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> <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 +466,7 @@ function RouteComponent() {
onValueChange={(v) => { onValueChange={(v) => {
setCreateForm((s) => { setCreateForm((s) => {
const role = v as Role 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 === "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 } 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 } return { ...s, role, facultad_id: null, carrera_id: null }
@@ -523,6 +507,7 @@ function RouteComponent() {
</div> </div>
)} )}
{/* Probablemente ya no sea necesario
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<Label>Permisos</Label> <Label>Permisos</Label>
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}> <Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
@@ -532,12 +517,12 @@ function RouteComponent() {
<SelectItem value="false">Usuario</SelectItem> <SelectItem value="false">Usuario</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div> */}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button> <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"} {createUser.isPending ? "Creando…" : "Crear usuario"}
</Button> </Button>
</DialogFooter> </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 { useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -27,6 +27,7 @@ function LoginComponent() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -35,7 +36,7 @@ function LoginComponent() {
try { try {
await auth.login(email, password) await auth.login(email, password)
window.location.href = redirect router.navigate({ to: redirect})
} catch (err: any) { } catch (err: any) {
setError(err.message || "No fue posible iniciar sesión") setError(err.message || "No fue posible iniciar sesión")
} finally { } finally {
@@ -95,12 +96,6 @@ function LoginComponent() {
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password">Contraseña</Label> <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>
<div className="relative"> <div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center"> <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" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
<div>
<a
href="/reset-password"
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
>
¿Olvidaste tu contraseña?
</a>
</div>
</div> </div>
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg"> <Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">

View File

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

View File

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

14
staticwebapp.config.json Normal file
View File

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

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

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

View File

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