13 Commits

Author SHA1 Message Date
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
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
f92a3dae70 Merge branch 'master' of https://github.lci.ulsa.mx/AlexRG/React-Autenticado 2025-10-22 15:58:50 -06:00
4b19d166b6 refactor: remove unused imports in CreatePlanDialog and $planId components 2025-10-22 15:58:47 -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
19 changed files with 878 additions and 357 deletions

23
.vscode/launch.json vendored
View File

@@ -4,12 +4,19 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "msedge", "type": "chrome",
"request": "launch", "request": "launch",
"name": "Launch Edge against localhost", "name": "Launch Chrome against localhost",
"url": "http://localhost:3000", "url": "http://localhost:3000",
"webRoot": "${workspaceFolder}" "webRoot": "${workspaceFolder}"
} },
] {
"type": "msedge",
"request": "launch",
"name": "Launch Edge against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
} }

157
bun.lock
View File

@@ -36,6 +36,7 @@
"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,10 +466,20 @@
"@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/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="], "@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
@@ -477,10 +488,14 @@
"@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/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 +534,8 @@
"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=="],
"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 +548,18 @@
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="], "canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
"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,6 +572,8 @@
"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=="],
@@ -583,6 +612,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,6 +622,8 @@
"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=="],
@@ -611,12 +644,16 @@
"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=="],
"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=="],
"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=="],
@@ -637,8 +674,14 @@
"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=="],
"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 +690,28 @@
"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=="],
"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=="],
@@ -693,6 +748,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 +760,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,6 +836,8 @@
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="], "nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
"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=="],
@@ -737,6 +854,8 @@
"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=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
@@ -745,6 +864,8 @@
"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,6 +886,10 @@
"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=="],
"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=="],
@@ -795,12 +920,20 @@
"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=="],
"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=="],
"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=="],
@@ -837,6 +970,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 +984,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=="],
@@ -859,6 +1008,10 @@
"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 +1046,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=="],
@@ -921,6 +1076,8 @@
"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=="],

View File

@@ -42,6 +42,7 @@
"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,28 +44,35 @@ 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(() => {
// Función para manejar la sesión
const handleSession = async (session: Session | null) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}
// Carga inicial // Carga inicial
supabase.auth.getSession().then(async ({ data: { session } }) => { supabase.auth.getSession().then(({ data: { session } }) => {
const u = session?.user ?? null handleSession(session)
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}) })
// 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,
});
const data: UserClaims[] | null = result.data;
const error = result.error;
return { if (error) {
claims_admin: !!base.claims_admin, console.error('Error al obtener la información:', error);
role: (base.role ?? 'lci') as Role, throw new Error('Error al obtener la información del usuario');
clave: base.clave ?? '', }
nombre: base.nombre ?? '',
apellidos: base.apellidos ?? '', console.log(data);
title: base.title ?? '', if (!data || data.length === 0) {
avatar: base.avatar ?? null, console.warn('No se encontró información para el usuario');
facultad_id: (base.facultad_id as string | null) ?? null, return null;
carrera_id: (base.carrera_id as string | null) ?? null, }
facultad_color, // 🎨
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

@@ -114,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>
@@ -134,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

@@ -43,15 +43,13 @@ 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 } = await supabase.from("asignaturas").insert([payload])
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() {
@@ -69,8 +67,8 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } }) confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false) setOpen(false)
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

@@ -1,6 +1,6 @@
import { useRouter } from "@tanstack/react-router" import { useRouter } from "@tanstack/react-router"
import { useSupabaseAuth } from "@/auth/supabase" import { useSupabaseAuth } from "@/auth/supabase"
import { useState, useEffect, useMemo, useCallback } from "react" import { useState, useEffect, useCallback } from "react"
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 { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
@@ -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("documentos") .from("documentos")
.select("documentos_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);
@@ -102,7 +104,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
setDbFiles((data || []).map((file: any) => ({ setDbFiles((data || []).map((file: any) => ({
id: file.documentos_id, id: file.documentos_id,
titulo: file.titulo_archivo, titulo: file.titulo_archivo,
s3_file_path: file.s3_file_path, s3_file_path: file.titulo_archivo,
fecha_subida: file.fecha_subida, fecha_subida: file.fecha_subida,
tags: file.tags || [], tags: file.tags || [],
}))); })));
@@ -260,9 +262,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
{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.s3_file_path);
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}
@@ -313,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) => (
@@ -353,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,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

@@ -6,6 +6,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase" import { supabase } from "@/auth/supabase"
import { toast } from "sonner" import { toast } from "sonner"
import ReactMarkdown from 'react-markdown'
/* ===================================================== /* =====================================================
Query keys & fetcher Query keys & fetcher
@@ -72,7 +74,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,6 +111,7 @@ 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()
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)
@@ -175,17 +178,18 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
> >
Copiar Copiar
</Button> </Button>
<Button {s.key !== "prompt" &&
variant="ghost" (<Button
size="sm" variant="ghost"
onClick={() => { size="sm"
const current = Array.isArray(text) ? text.join("\n") : (text ?? "") onClick={() => {
setEditing({ key: s.key, title: s.title }) const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
setDraft(current) setEditing({ key: s.key, title: s.title })
}} setDraft(current)
> }}
Editar >
</Button> Editar
</Button>)}
</div> </div>
</SectionPanel> </SectionPanel>
) )

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,7 +149,7 @@ 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 isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '') const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
@@ -189,18 +188,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

@@ -167,6 +167,7 @@ function RouteComponent() {
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p> <p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
)} )}
{/* Tags
{r.tags && r.tags.length > 0 && ( {r.tags && r.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{r.tags.map((t, i) => ( {r.tags.map((t, i) => (
@@ -175,7 +176,7 @@ function RouteComponent() {
</span> </span>
))} ))}
</div> </div>
)} )} */}
<div className="mt-auto flex items-center justify-between gap-2"> <div className="mt-auto flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}> <Button variant="ghost" size="sm" onClick={() => setViewing(r)}>

View File

@@ -1,4 +1,5 @@
// 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"
@@ -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 */}
@@ -578,6 +580,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">
@@ -632,9 +679,9 @@ 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: [] }]
} }
}, []) }, [])
@@ -650,7 +697,7 @@ 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
}, []) }, [])
@@ -669,7 +716,7 @@ export function EditContenidosButton({
return true return true
}) })
return { return {
title: (u.title || "").trim() || `Unidad ${idx + 1}`, title: (u.title || "").trim(),
temas, temas,
} }
}) })
@@ -839,7 +886,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 +897,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

@@ -169,6 +169,36 @@ function RouteComponent() {
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre') const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '') const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
// 🟣 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)
@@ -217,28 +247,30 @@ 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 => {
const matchesQ = const matchesQ =
!t || !t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre] [a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean) .filter(Boolean)
.some(v => String(v).toLowerCase().includes(t)) .some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const flagOK = const flagOK =
!flag || !flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) || (flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) || (flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0)) (flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK
})
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
return matchesQ && semOK && tipoOK && flagOK
})
}, [q, sem, tipo, flag, asignaturas])
// Agrupación // Agrupación
const groups = useMemo(() => { const groups = useMemo(() => {
@@ -257,7 +289,7 @@ 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('') }
// NEW: util para clonar 1 asignatura // NEW: util para clonar 1 asignatura
async function cloneOne(src: Asignatura, overrides: { async function cloneOne(src: Asignatura, overrides: {
@@ -394,7 +426,7 @@ function RouteComponent() {
</div> </div>
{/* Filtros */} {/* Filtros */}
<div className="grid gap-4 sm:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-5">
<div> <div>
<Label>Búsqueda</Label> <Label>Búsqueda</Label>
<Input <Input
@@ -416,29 +448,53 @@ function RouteComponent() {
</div> </div>
<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") // reset de carrera al cambiar facultad
}}
>
<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>
{facultad && facultad !== "todas" && (
<div> <div>
<Label>Agrupación</Label> <Label>Carrera</Label>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}> <Select
<SelectTrigger><SelectValue /></SelectTrigger> 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> </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

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

@@ -17,11 +17,11 @@ import { Label } from "@/components/ui/label"
import confetti from "canvas-confetti" import confetti from "canvas-confetti"
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
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"
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,
@@ -34,24 +34,24 @@ 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 auth = useSupabaseAuth()
const asignaturasCount = asignaturasPreview.length
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
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'
@@ -79,7 +79,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 }}>
@@ -99,11 +99,13 @@ 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" />
<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}>

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,19 @@ 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 -------------------- */ /* -------------------- 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 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 +31,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 +98,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
@@ -118,10 +150,47 @@ function RouteComponent() {
}>({ email: "", password: "" }) }>({ email: "", password: "" })
function genPassword() { function genPassword() {
/*
Supabase requiere que las contraseñas tengan las siguientes características:
- Mínimo de 6 caracteres
- Debe contener al menos una letra minúscula
- Debe contener al menos una letra mayúscula
- Debe contener al menos un número
- Debe contener al menos un carácter especial
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
*/
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
const positions = Array.from({ length: 12 }, (_, i) => i);
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
// 2. Las correspondencias son las siguientes:
// - El primer número indica la posición de la letra minúscula
// - El segundo número indica la posición de la letra mayúscula
// - El tercer número indica la posición del número
// - El cuarto número indica la posición del carácter especial
// - En las demás posiciones puede haber cualquier caracter alfanumérico
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("") const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
return s.slice(0, 14) return s.slice(0, 14)
} }
function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role]
if (!meta) return null
const { Icon, className, label } = meta
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
/* ---------- Mutations ---------- */ /* ---------- Mutations ---------- */
const invalidateAll = async () => { const invalidateAll = async () => {
await qc.invalidateQueries({ queryKey: usersKeys.root }) await qc.invalidateQueries({ queryKey: usersKeys.root })
@@ -167,11 +236,13 @@ function RouteComponent() {
}) })
const toggleBan = useMutation({ const toggleBan = useMutation({
mutationFn: async (u: AdminUser) => { mutationFn: async (u: UserClaims) => {
const banned = !!u.banned_until && new Date(u.banned_until) > new Date() throw new Error("Funcionalidad de baneo no implementada aún.")
const banned = false // cuando se tenga acceso a ese campo
// const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() } const 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) // const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
if (error) throw new Error(error.message) // if (error) throw new Error(error.message)
return !banned return !banned
}, },
onSuccess: async (isBanned) => { onSuccess: async (isBanned) => {
@@ -183,39 +254,42 @@ function RouteComponent() {
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 +302,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 +329,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 +374,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 +385,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={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} <Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
</Button> </Button>
<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={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><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 +444,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 +495,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 +505,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 +537,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 +557,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 +598,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 +608,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">