88 Commits

Author SHA1 Message Date
7e1045358d Login de usuarios wip
Lo que ya sirve:
- Ya se puede hacer login con email y contraseña
- Se puede hacer logout con un botón en el header
- La página te redirige a login si no hay sesion
- La página te redirige a dashboard desde login si hay sesión

Lo que falta:
- Comprobar si se atrapan y manejan correctamente los errores por violación a RLS
- Cambiar la BDD para asignar roles y permisos a usuarios
- Comprobar si de manera defensiva se reestablecen los roles/permisos cuando el usuario intenta hacer algo que no está permitido
2026-03-04 12:16:48 -06:00
314a96f2c5 Merge pull request 'Se generan planes de estudio y asignaturas con IA en segundo plano y se actualiza con realtime de supabase' (#146) from issue/142-creacin-de-planes-de-estudio-y-de-asignaturas-con- into main
Reviewed-on: #146
2026-02-27 18:32:48 +00:00
7693f86951 tipos de supabase actualizados 2026-02-27 12:31:11 -06:00
8ad6c8096e Merge branch 'main' into issue/142-creacin-de-planes-de-estudio-y-de-asignaturas-con- 2026-02-27 12:26:16 -06:00
28742615d8 Implementa suscripción en tiempo real para el estado de los planes de estudio en WizardControls 2026-02-27 11:29:48 -06:00
0cb467cb78 Merge pull request 'Se funcionalidad para abrir ventana de sugerencias dentro del texto, se agrega autocompletado fix #141' (#145) from issue/141-mejorar-experiencia-de-usuario into main
Reviewed-on: #145
2026-02-27 15:38:29 +00:00
ff5ba3952d Se funcionalidad para abrir ventana de sugerencias dentro del texto, se agrega autocompletado
fix #141
2026-02-27 09:37:28 -06:00
f6b25ad86a Se cambió el polling de tanstack query por realtime de supabase y postgres_changes 2026-02-26 16:37:21 -06:00
d7d4eff523 Generación de asignaturas funcional 2026-02-26 16:20:21 -06:00
6773247b03 Merge pull request 'Mejorar experiencia de usuario #141' (#144) from issue/141-mejorar-experiencia-de-usuario into main
Reviewed-on: #144
2026-02-26 22:05:01 +00:00
ef614be2f1 Se mejora experiencia de usaurio y se borran referencias despues de mandar el chat se pone mensaje si esta vacia la conversación
#141
2026-02-26 16:03:44 -06:00
2f0005baa7 Merge pull request 'Mejorar experiencia de usuario #141' (#143) from issue/141-mejorar-experiencia-de-usuario into main
Reviewed-on: #143
2026-02-26 16:52:06 +00:00
3c37b5f313 Mejorar experiencia de usuario
fix #141
2026-02-26 10:51:23 -06:00
4d1f102acb Creación de planes de estudio con polling debido a mandar crear los datos en segundo plano 2026-02-25 17:37:06 -06:00
f706456ff6 Merge pull request 'Agregar estilo cuando la respuesta del asistente sea refusal #139' (#140) from issue/139-agregar-estilo-cuando-la-respuesta-del-asistente-s into main
Reviewed-on: #140
2026-02-25 22:05:07 +00:00
b888bb22cd Agregar estilo cuando la respuesta del asistente sea refusal
fix #139
2026-02-25 16:04:30 -06:00
543e83609e Merge branch 'issue/136-que-la-conversacin-se-obtenga-de-conversationjson-' 2026-02-25 14:03:30 -06:00
4a8a9e1857 Que la conversación se obtenga de conversation_json de supabase #136 2026-02-25 13:58:12 -06:00
d28b32d34e Merge pull request 'Que te permita renombrar los chats #96' (#135) from issue/96-que-te-permita-renombrar-los-chats into main
Reviewed-on: #135
2026-02-24 22:10:38 +00:00
6db7c1c023 Que te permita renombrar los chats fix #96 2026-02-24 16:09:35 -06:00
cfc2153fa2 Merge pull request 'Añadir Bibliografía #128' (#132) from issue/128-aadir-bibliografa into main
Reviewed-on: #132
2026-02-24 20:37:46 +00:00
f28804bb5b closes #133: Mejoras de usabilidad en ContenidoTemático — edición inmediata y foco
- Mueve el botón "+ Nueva unidad" al final de la lista y lo centra.
- Al crear una unidad: hace scrollIntoView, la unidad queda expandida, el título entra en modo edición y recibe focus.
- Al crear un subtema: nombre y horas quedan editables y el input del nombre recibe focus.
- Click en título de unidad o en un subtema inicia la edición y pone focus en el campo correspondiente.
- Elimina el botón "Listo": los cambios se guardan al pulsar Enter o perder el foco (onBlur).
- Presionar Esc cancela la edición y restaura el valor anterior.
- Evita el bug donde pulsar Enter tras crear una unidad añadía unidades extra (se desenfoca el botón y se dirige el foco al input correspondiente).
- Persistencia inmediata: las modificaciones se guardan vía useUpdateSubjectContenido en los puntos de commit.
- Conserva el estado de unidades expandidas tras las actualizaciones para evitar colapsos inesperados.
2026-02-24 14:28:52 -06:00
f1d09a37ed Merge pull request 'issue/129-renderizar-datos-generales-ligados-a-columnas-de-t' (#130) from issue/129-renderizar-datos-generales-ligados-a-columnas-de-t into main
Reviewed-on: #130
2026-02-24 19:46:31 +00:00
3c63fdef69 Feat: Al picarle al botón de listo, ya no se ocultan los temas de la unidad 2026-02-24 13:45:39 -06:00
3dc01c3fba finalización del merge de main a la rama issue/129... 2026-02-24 13:10:59 -06:00
a51fa6b1fc Añadir Bibliografía
fix #128
2026-02-24 12:33:27 -06:00
1fddb75bf8 Merge branch 'main' into issue/129-renderizar-datos-generales-ligados-a-columnas-de-t 2026-02-24 12:30:42 -06:00
ec994c9586 Merge pull request 'Hacer que se navegue por rutas en los tabs de la asignatura #110' (#131) from issue/110-hacer-que-se-navegue-por-rutas-en-los-tabs-de-la-a into main
Reviewed-on: #131
2026-02-24 15:54:09 +00:00
1acc37403d Hacer que se navegue por rutas en los tabs de la asignatura
fix #110
2026-02-24 09:42:53 -06:00
f7ab1d61f0 Cambio al logo 2026-02-23 17:18:20 -06:00
6ed5d3541f Al darle clic al botón de editar de Contenido Temático, te lleva a esa tab 2026-02-23 14:06:06 -06:00
3188a61431 Se renderiza el contenido temático en datos generales a partir de su columna en la BDD 2026-02-23 13:58:02 -06:00
5912a7c1fb Se quita bibliografia fantasma 2026-02-20 14:45:34 -06:00
1c45330da6 Se corrige bibliografía fantasma 2026-02-20 11:04:52 -06:00
88ad7d74e3 Merge branch 'main' of https://github.lci.ulsa.mx/Guillermo.Arrieta/acad-ia-2 2026-02-20 10:36:32 -06:00
08afd27f80 se corrigen claves en historial 2026-02-20 10:36:30 -06:00
b0a89ac57f Merge pull request 'Historial de cambios #62' (#127) from issue/62.1-historial-de-cambios into main
Reviewed-on: #127
2026-02-19 16:03:24 +00:00
440147edec Historial de cambios
fix #62
2026-02-19 10:02:48 -06:00
d415344ee7 Merge pull request 'Que desaparezca el botón de clonar #107' (#126) from issue/107-que-desaparezca-el-botn-de-clonar into main
Reviewed-on: #126
2026-02-19 14:45:30 +00:00
5a0f7acac3 Que desaparezca el botón de clonar #107 2026-02-19 08:44:41 -06:00
f882d8c89d Merge pull request 'Desaparecer bibliografía fantasma #109' (#125) from issue/109-desaparecer-bibliografa-fantasma into main
Reviewed-on: #125
2026-02-19 14:37:34 +00:00
517b9497f1 Desaparecer bibliografía fantasma
fix #109
2026-02-19 08:36:52 -06:00
eb95dec097 Merge pull request 'Persistencia en aplicar mejora' (#124) from issue/113-persistencia-en-columnas-de-plan into main
Reviewed-on: #124
2026-02-18 21:44:52 +00:00
cf4caa2857 Merge branch 'main' into issue/113-persistencia-en-columnas-de-plan 2026-02-18 21:44:41 +00:00
2de1e4237c Merge pull request 'Corregir mensajes de conversación #121' (#123) from issue/121-corregir-mensajes-de-conversacin into main
Reviewed-on: #123
2026-02-18 21:42:59 +00:00
7472e2cf97 Merge branch 'main' into issue/121-corregir-mensajes-de-conversacin 2026-02-18 21:42:51 +00:00
15f60b7751 Merge pull request 'Implementar conversación con HOOKS #111' (#120) from issue/111-implementar-conversacin-con-hooks into main
Reviewed-on: #120
2026-02-18 21:42:23 +00:00
50c00293cd Persistencia en columnas de plan fix #113 2026-02-18 15:39:24 -06:00
99ed75b2eb Corregir mensajes de conversación fix #121 2026-02-18 08:41:59 -06:00
cd16b3cb4f Implementar conversación con HOOKS #111 2026-02-17 15:47:32 -06:00
8444f2a87e Merge pull request 'Ahora hay persistencia en la asignatura' (#118) from issue/114-persistencia-de-asignaturas into main
Reviewed-on: #118
2026-02-17 20:39:10 +00:00
02c415a91d Fix #114: Refactor ContenidoTemático: persistencia inmediata y normalización de datos
- Elimina botón "Guardar": persistencia automática al pulsar "Listo", al confirmar eliminación y al terminar de editar nombre de unidad.
- Añade mapper (mapContenidoTematicoFromDb) y serializador (serializeUnidadesToApi) para normalizar contenido_tematico <-> Array<ContenidoApi>.
- Conecta persistencia a useUpdateSubjectContenido: hace update directo de asignaturas.contenido_tematico en la BDD.
- Manejo de caché: setQueryData con merge y invalidación de keys centralizadas (qk.planAsignaturas, qk.planHistorial, qk.asignaturaHistorial) para evitar caché desactualizada o pérdida de relaciones.
- UX/estabilidad: identificadores consistentes, expansión inicial, y persistencia inmediata en puntos clave (añadir, editar, eliminar).
2026-02-17 14:17:09 -06:00
7d45eb4dfa fix #114: refactorización de AsignaturaDetailPage y hooks relacionados: persistencia, caché y tipado
- Persistencia de cambios de "Datos generales" usando updateAsignatura.mutate.
- Corregido el manejo de caché: uso de qk centralizada y merge en setQueryData para no perder relaciones.
- Corregidos los tipos devueltos por subjects_get.
- Evitado estado inválido tras guardar (merge local + actualización de cache).

Verificar: editar → guardar → volver al plan → reingresar muestra datos actualizados sin parpadeos.
2026-02-17 13:20:49 -06:00
54b22b7adf se arregló el estilo visual y comportamiento del grid del mapa curricular
fix #108: ahora se utiliza un único grid para todo el mapa curricular. de esta manera el espaciado se mantiene consistente
2026-02-13 14:13:22 -06:00
d4a034c2fc Merge branch 'main' of https://github.lci.ulsa.mx/Guillermo.Arrieta/acad-ia-2 2026-02-13 13:56:33 -06:00
56d23f1aa5 Se agrega componente 2026-02-13 13:56:30 -06:00
13d9f1fe4a Merge pull request 'Se agregan indicadores de que está generandose el plan o la asignatura' (#115) from issue/29-botn-de-generando-plan-de-estudios into main
Reviewed-on: #115
2026-02-13 18:46:20 +00:00
2624b0694d spinners, creación manual de asignatura, actualización de asignaturas generadas por sugerencias
fix #29:
-  Se agregaron spinners en la creación con IA de un plan o una asignatura
- Se añadió la creación manual de asignaturas
- Al generar asignaturas a partir de sugerencias, el badge de estado de la asignatura dice 'Generando' y muestra una animación tipo respiro para indicar que está siendo generada. Adicionalmente, se actualiza automáticamente la UI una vez que acabó de ser generada
2026-02-13 12:44:05 -06:00
04909513bb Merge pull request 'Que quede centrado con un ancho máximo de 1 tercio de pantalla SIN QUE SEA UN MODAL #98' (#112) from issue/98-que-quede-centrado-con-un-ancho-mximo-de-1-tercio- into main
Reviewed-on: #112
2026-02-13 16:27:58 +00:00
5f8d758f67 Que quede centrado con un ancho máximo de 1 tercio de pantalla SIN QUE SEA UN MODAL
fix #98
2026-02-13 10:27:03 -06:00
41aecc4a45 Merge pull request 'Que solo te autocomplete el título del campo y te quite los dos puntos #97' (#106) from issue/97-que-solo-te-autocomplete-el-ttulo-del-campo-y-te-q into main
Reviewed-on: #106
2026-02-13 15:04:55 +00:00
1e4a58e4da Que solo te autocomplete el título del campo y te quite los dos puntos
fix #97
2026-02-13 09:04:19 -06:00
2157ffe3bc Merge pull request 'Generación de múltiples asignaturas con sugerencias' (#105) from issue/89-nueva-opcin-en-wizard-crear-asignaturas-con-sugere into main
Reviewed-on: #105
2026-02-12 23:31:45 +00:00
c280faef22 feat: add 'vaul' dependency and update database types for conversation management 2026-02-12 17:26:47 -06:00
d6c567195a Generación existosa de múltiples asignaturas con IA
TODO: actualización automática de el estado de las asignaturas generadas
2026-02-12 16:17:48 -06:00
9c588cfd8f PasoResumen muestra resumen antes de crear múltiples asignaturas 2026-02-12 16:15:11 -06:00
46d8d6142e feat: integrate Radix UI Accordion component and enhance subject wizard
- Added Radix UI Accordion component for better UI organization in PasoDetallesPanel.
- Implemented structure selection and subject suggestions management in the wizard.
- Updated subject API to initialize new subjects with null values for structure and cycle.
- Modified state management in useNuevaAsignaturaWizard to include estructuraId.
- Adjusted types for suggested subjects to include line and cycle information.
2026-02-12 16:15:11 -06:00
07d08e1b57 Add AI progress loader and enhance suggestion generation logic
- Introduced AIProgressLoader component to display loading progress and messages during suggestion generation.
- Updated PasoSugerenciasForm to manage loading state and display tooltip for preserved suggestions.
- Adjusted suggestion limits and removed unused ciclo input from state.
2026-02-12 16:15:11 -06:00
ded54c18dd Se mandan generar sugerencias de asignaturas junto con el id del plan, el enfoque que se le quiere dar, la cantidad de sugerencias, y las sugerencias conservadas 2026-02-12 16:15:10 -06:00
89f264bf5d Primera version funcional de sugerencias 2026-02-12 16:15:10 -06:00
675c76db74 wip 2026-02-12 16:15:10 -06:00
d74807c84e wip 2026-02-12 16:15:10 -06:00
4d0f5815eb Merge pull request 'Que haga la cuenta de cuántas referencias llevas #99' (#103) from issue/99-que-haga-la-cuenta-de-cuntas-referencias-llevas into main
Reviewed-on: #103
2026-02-12 21:58:41 +00:00
2f9e779bce Se corrigen incidencias
fix #100
fix #101
2026-02-12 15:55:14 -06:00
0c57bdfc38 Que haga la cuenta de cuántas referencias llevas
fix #99
2026-02-12 14:14:02 -06:00
2250a1afd1 Merge pull request 'Se agrega paginacion a historial' (#95) from issue/82-paginacin-del-historial-para-evitar-que-crezca-al- into main
Reviewed-on: #95
2026-02-12 16:55:56 +00:00
9102e756cb Se agrega paginacion a historial 2026-02-12 10:55:19 -06:00
e788eb788f Merge pull request 'Archivado de chats y editar por campos de ia' (#94) from issue/90-historial-de-chats-archivado into main
Reviewed-on: #94
2026-02-12 16:02:51 +00:00
2ec222694d Se agrega editar por campos en ia y archivar chats 2026-02-12 10:01:27 -06:00
58d4ee8b6e Merge pull request 'Se agrega drawer de referencias de ia y panel de historial de conversaciones' (#93) from issue/90-crearSeccionHistorialChat into main
Reviewed-on: #93
2026-02-11 16:24:18 +00:00
d9a6852f43 Se agrega drawer de referencias de ia y panel de historial de chats 2026-02-11 10:22:14 -06:00
ba188329dc Se agrega avance de historial de chat y referencias de la ia 2026-02-10 14:33:04 -06:00
777be81d2a Merge pull request 'Corregir que se duplica el campo #90' (#92) from issue/90-corregir-que-se-duplica-el-campo into main
Reviewed-on: #92
2026-02-10 17:37:14 +00:00
3afce0de77 Corregir que se duplica el campo
fix #90
2026-02-10 11:34:58 -06:00
4b8ec2c5ab Merge pull request 'Se borra boton de guardar y se cierran incidencias' (#91) from issue/87-no-tiene-sentido-este-botn-de-guardar into main
Reviewed-on: #91
2026-02-10 17:23:47 +00:00
0788002c9b No todo debe ser editable #47 2026-02-10 10:10:40 -06:00
c7c631a701 Botón de Exportar Mapa Curricular fix #27 2026-02-10 09:06:57 -06:00
9ba94f2c2c No tiene sentido este botón de guardar
fix #87
2026-02-10 08:35:46 -06:00
59 changed files with 6087 additions and 2409 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
Al funcionar como agente, ignora los problemas de eslint del orden de imports

View File

@@ -20,7 +20,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@tanstack/react-query": "^5.66.5",
@@ -36,12 +36,14 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.24.7", "motion": "^12.24.7",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0", "use-debounce": "^10.1.0",
"vaul": "^1.1.2",
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.3.11", "@tanstack/devtools-vite": "^0.3.11",
@@ -251,10 +253,16 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
@@ -281,12 +289,24 @@
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
@@ -297,6 +317,10 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@@ -305,10 +329,22 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -405,17 +441,17 @@
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="], "@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="], "@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="], "@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="], "@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
"@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="], "@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="], "@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@@ -1161,6 +1197,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
@@ -1351,6 +1389,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"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=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@@ -1419,6 +1459,8 @@
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1431,6 +1473,8 @@
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -1487,6 +1531,14 @@
"is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"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

@@ -10,6 +10,7 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
@@ -17,11 +18,11 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide",
"registries": { "registries": {
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json", "@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json", "@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json", "@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json" "@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
"@supabase": "https://supabase.com/ui/r/{name}.json"
} }
} }

View File

@@ -33,7 +33,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@tanstack/react-query": "^5.66.5",
@@ -49,12 +49,14 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.24.7", "motion": "^12.24.7",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0" "use-debounce": "^10.1.0",
"vaul": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.3.11", "@tanstack/devtools-vite": "^0.3.11",

118
public/lasalle-logo.svg Normal file
View File

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 192.3 63.4" style="enable-background:new 0 0 192.3 63.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#FFFFFF;}
.st2{fill:#FFFFFF;}
</style>
<g>
<g id="Group_1247_1_">
<path id="Path_477_1_" class="st0" d="M50.7,50.6l4.4-7.8h-8.9l-12-21l-4.4,7.8l12,21C41.8,50.6,50.7,50.6,50.7,50.6z"/>
<path id="Path_478_1_" class="st0" d="M34.3,1h-9l4.4,7.8l-12,20.8h9.1l12-20.8L34.3,1z"/>
<path id="Path_479_1_" class="st0" d="M0,40.1l4.4,7.8l4.4-7.8h23.9l-4.4-7.8H4.4L0,40.1z"/>
<path id="Path_480_1_" class="st1" d="M56.7,40.1l4.4-7.8h-9L40.3,11.4l-4.4,7.8l12,20.8H56.7z"/>
<path id="Path_481_1_" class="st1" d="M22.3,1h-8.9l4.4,7.8l-12,20.8h9l12-20.8L22.3,1z"/>
<path id="Path_482_1_" class="st1" d="M5.9,50.6l4.4,7.8l4.4-7.8h23.9l-4.4-7.8H10.5L5.9,50.6z"/>
</g>
<g>
<path class="st1" d="M67.9,3.9c0-0.8,0-1.6-0.1-2.4l1.7-0.1l0.1,0.1v6.3c0,0.7,0.1,1.2,0.5,1.6C70.6,9.8,71,10,71.7,10
c0.5,0,1.1-0.1,1.3-0.5c0.4-0.4,0.5-0.9,0.5-1.6V3.5c0-0.8,0-1.5-0.1-2.2l1.9-0.1v6.7c0,1.1-0.4,2-1.1,2.6
c-0.7,0.5-1.6,0.9-2.7,0.9c-1.1,0-2-0.3-2.7-0.9C68.2,10,67.8,9,67.8,7.9L67.9,3.9L67.9,3.9z"/>
<path class="st1" d="M83,11.3h-1.7V6.6c0-0.7-0.1-1.5-0.3-2.3L82.6,4c0.1,0.4,0.3,0.7,0.3,1.1C83.5,4.4,84.3,4,85.1,4
c0.7,0,1.1,0.1,1.5,0.5C87,5,87.1,5.5,87.1,6.2v5.1h-1.7V6.6c0-0.8-0.4-1.3-1.1-1.3c-0.4,0-0.9,0.1-1.3,0.5L83,11.3L83,11.3z"/>
<path class="st1" d="M95.1,1.6c0,0.3-0.1,0.7-0.4,0.8c-0.3,0.3-0.5,0.4-0.8,0.4S93.3,2.7,93,2.6c-0.1-0.1-0.3-0.4-0.3-0.7
s0.1-0.7,0.4-0.8c0.3-0.1,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.7,0.3S95.1,1.4,95.1,1.6z M93,11.3V6.6c0-0.8,0-1.6-0.1-2.4l1.7-0.3
L94.8,4v7.1L93,11.3L93,11.3z"/>
<path class="st1" d="M106.4,4.3l-2.3,7h-1.9l-2.3-7.1l1.9-0.1l0.9,3.6c0.3,1.1,0.5,1.9,0.5,2.4l0,0c0.1-0.5,0.3-1.3,0.7-2.4
l0.9-3.6L106.4,4.3L106.4,4.3z"/>
<path class="st1" d="M116.7,7.7l-0.3,0.3h-4c0,0.8,0.3,1.3,0.7,1.7c0.4,0.4,0.8,0.5,1.5,0.5c0.5,0,1.2-0.1,1.7-0.5l0.1,1.2
c-0.7,0.4-1.3,0.7-2.4,0.7c-1.1,0-1.9-0.3-2.6-0.9s-0.9-1.5-0.9-2.7s0.3-2,0.9-2.8s1.5-1.1,2.4-1.1c0.8,0,1.5,0.3,2,0.8
c0.5,0.5,0.8,1.2,0.8,2.2C116.7,7.3,116.7,7.5,116.7,7.7z M113.9,5.1c-0.4,0-0.8,0.1-0.9,0.5c-0.3,0.4-0.4,0.9-0.4,1.6l2.6-0.1
c0-0.1,0-0.3,0-0.5c0-0.4-0.1-0.8-0.3-1.1C114.6,5.3,114.3,5.1,113.9,5.1z"/>
<path class="st1" d="M124,11.3h-1.7V6.6c0-0.7-0.1-1.5-0.3-2.3l1.6-0.3c0.1,0.5,0.3,0.9,0.4,1.5c0.5-0.9,1.2-1.5,1.9-1.5
c0.3,0,0.5,0,0.7,0.1l-0.1,1.7c-0.3-0.1-0.5-0.1-0.8-0.1c-0.5,0-1.1,0.1-1.6,0.5C124,6.3,124,11.3,124,11.3z"/>
<path class="st1" d="M135.8,4.4l-0.1,1.3c-0.7-0.4-1.3-0.5-1.9-0.5c-0.4,0-0.7,0.1-0.8,0.3c-0.3,0.1-0.3,0.3-0.3,0.5
c0,0.3,0.1,0.4,0.4,0.7c0.3,0.1,0.5,0.4,0.8,0.5c0.3,0.1,0.7,0.3,1.1,0.4c0.4,0.1,0.7,0.4,0.8,0.7c0.3,0.3,0.4,0.7,0.4,1.1
c0,0.7-0.3,1.2-0.8,1.5c-0.5,0.4-1.2,0.5-2.2,0.5s-1.7-0.1-2.4-0.5l0.1-1.3c0.8,0.4,1.5,0.7,2.3,0.7c0.4,0,0.7-0.1,0.8-0.3
c0.1-0.1,0.3-0.3,0.3-0.5c0-0.3-0.1-0.4-0.4-0.7c-0.3-0.1-0.5-0.4-0.8-0.5s-0.7-0.3-0.9-0.4s-0.7-0.4-0.8-0.7
c-0.3-0.3-0.4-0.7-0.4-1.1c0-0.7,0.3-1.2,0.8-1.6c0.5-0.4,1.2-0.5,2-0.5C134.5,4,135.1,4.2,135.8,4.4z"/>
<path class="st1" d="M143.3,1.6c0,0.3-0.1,0.7-0.4,0.8c-0.3,0.1-0.5,0.4-0.8,0.4c-0.3,0-0.5-0.1-0.8-0.3c-0.1-0.1-0.1-0.4-0.1-0.7
s0.1-0.7,0.4-0.8c0.3-0.3,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.7,0.3S143.3,1.4,143.3,1.6z M141.3,11.3V6.6c0-0.8,0-1.6-0.1-2.4
l1.7-0.3l0.1,0.1v7.1L141.3,11.3L141.3,11.3z"/>
<path class="st1" d="M153,0.7l1.7-0.3l0.1,0.1v8.7c0,0.7,0.1,1.3,0.3,1.9l-1.6,0.1c-0.1-0.1-0.3-0.5-0.4-0.9l0,0
c-0.5,0.7-1.1,0.9-2,0.9c-0.9,0-1.5-0.3-2-0.9c-0.5-0.7-0.8-1.5-0.8-2.6c0-1.2,0.4-2.2,1.1-3c0.7-0.7,1.6-1.1,2.7-1.1
c0.3,0,0.5,0,0.9,0.1V3C153.2,2,153.2,1.4,153,0.7z M150.5,7.7c0,0.8,0.1,1.5,0.4,1.9c0.3,0.5,0.7,0.7,1.2,0.7
c0.4,0,0.8-0.1,1.1-0.4V5c-0.3,0-0.5-0.1-0.7-0.1c-0.7,0-1.1,0.3-1.5,0.7C150.6,6.2,150.5,6.9,150.5,7.7z"/>
<path class="st1" d="M166.1,9.3c0,0.7,0.1,1.3,0.3,2l-1.5,0.1c-0.1-0.3-0.3-0.5-0.4-0.9l0,0c-0.3,0.3-0.5,0.5-0.9,0.7
c-0.4,0.1-0.8,0.3-1.2,0.3c-0.5,0-1.1-0.1-1.3-0.4c-0.4-0.3-0.5-0.7-0.5-1.2c0-0.8,0.4-1.3,1.1-1.7c0.7-0.4,1.6-0.7,2.8-0.7V6.7
c0-0.8-0.4-1.3-1.3-1.3c-0.7,0-1.5,0.3-2.2,0.7l-0.1-1.3c0.9-0.4,1.7-0.5,2.7-0.5s1.6,0.3,2,0.7c0.4,0.4,0.7,0.9,0.7,1.7
c0,0.4,0,0.8,0,1.5C166.1,8.6,166.1,9,166.1,9.3z M162.2,9.4c0,0.3,0.1,0.5,0.3,0.7c0.1,0.1,0.4,0.3,0.7,0.3
c0.4,0,0.8-0.1,1.2-0.5V7.9c-0.7,0-1.1,0.3-1.5,0.4C162.3,8.6,162.2,9,162.2,9.4z"/>
<path class="st1" d="M175.6,0.7l1.7-0.3l0.1,0.1v8.7c0,0.7,0.1,1.3,0.3,1.9l-1.6,0.1c-0.1-0.1-0.3-0.5-0.4-0.9l0,0
c-0.5,0.7-1.1,0.9-2,0.9s-1.5-0.3-2-0.9c-0.5-0.7-0.8-1.5-0.8-2.6c0-1.2,0.4-2.2,1.1-3c0.7-0.7,1.6-1.1,2.7-1.1
c0.3,0,0.5,0,0.9,0.1V3C175.7,2,175.7,1.4,175.6,0.7z M173.1,7.7c0,0.8,0.1,1.5,0.4,1.9c0.3,0.5,0.7,0.7,1.2,0.7
c0.4,0,0.8-0.1,1.1-0.4V5c-0.3,0-0.5-0.1-0.7-0.1c-0.7,0-1.1,0.3-1.5,0.7C173.2,6.2,173.1,6.9,173.1,7.7z"/>
</g>
<g>
<path class="st1" d="M78.8,51.2l0.3,0.3l1.2,11h-2l-0.4-4c-0.1-1.2-0.3-3-0.4-5l0,0c-0.3,1.2-0.7,2.8-1.2,5l-1.2,4h-2.3l-1.1-4
c-0.5-1.9-0.9-3.5-1.2-5l0,0c-0.1,1.2-0.3,2.8-0.4,5l-0.4,4h-1.7l1.2-11.2l2.7-0.1l1.3,4.6c0.4,1.6,0.8,3.2,1.1,4.8l0,0
c0.3-1.6,0.5-3.2,1.1-4.8l1.3-4.4L78.8,51.2z"/>
<path class="st1" d="M89.4,58.5l-0.3,0.3h-4.4c0.1,0.8,0.3,1.5,0.8,1.9c0.5,0.4,0.9,0.7,1.6,0.7s1.3-0.1,2-0.5l0.1,1.3
c-0.7,0.4-1.6,0.7-2.7,0.7c-1.2,0-2.2-0.4-3-1.1c-0.7-0.7-1.1-1.7-1.1-3c0-1.3,0.4-2.3,1.1-3.1c0.7-0.8,1.6-1.2,2.7-1.2
c0.9,0,1.7,0.3,2.3,0.9c0.5,0.5,0.8,1.3,0.8,2.4C89.4,58,89.4,58.4,89.4,58.5z M87.7,50.4l0.1,0.4c-0.7,0.9-1.5,1.9-2.6,2.7
l-0.8-0.1c0.7-1.1,1.1-2.2,1.3-3H87.7z M86.3,55.5c-0.4,0-0.8,0.3-1.1,0.7c-0.3,0.4-0.4,1.1-0.5,1.7l2.8-0.1c0-0.1,0-0.3,0-0.5
c0-0.5-0.1-0.9-0.3-1.2C87,55.7,86.7,55.5,86.3,55.5z"/>
<path class="st1" d="M96.4,62.5l-1.7-3.1L93,62.5h-1.6l-0.1-0.3l2.3-3.8l-2.3-4l2-0.3l1.7,3.4l1.6-3.4l1.6,0.1l0.1,0.3L96,58.4
l2.4,4h-2V62.5z"/>
<path class="st1" d="M103.1,51.8c0,0.4-0.1,0.7-0.4,0.9s-0.5,0.4-0.9,0.4c-0.4,0-0.7-0.1-0.8-0.4c-0.3-0.3-0.3-0.5-0.3-0.8
c0-0.4,0.1-0.7,0.4-0.9c0.3-0.3,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.8,0.3C103,51.1,103.1,51.4,103.1,51.8z M100.8,62.5v-5.2
c0-0.9,0-1.9-0.1-2.7l2-0.3l0.3,0.3v7.9H100.8z"/>
<path class="st1" d="M112.3,55l-0.4,1.6c-0.7-0.4-1.2-0.7-1.9-0.7c-0.5,0-1.1,0.3-1.5,0.7c-0.4,0.4-0.5,1.1-0.5,1.9
c0,0.9,0.1,1.6,0.7,2c0.4,0.5,0.9,0.8,1.7,0.8c0.5,0,1.2-0.1,1.7-0.5l0.1,1.3c-0.7,0.4-1.5,0.7-2.4,0.7c-1.2,0-2.2-0.4-2.8-1.1
s-1.1-1.7-1.1-3c0-1.3,0.4-2.3,1.1-3.1c0.8-0.8,1.7-1.2,2.8-1.2C110.8,54.3,111.6,54.6,112.3,55z"/>
<path class="st1" d="M121.4,58.5c0,1.3-0.4,2.4-1.1,3.1s-1.6,1.1-2.7,1.1c-1.1,0-1.9-0.4-2.6-1.1s-1.1-1.7-1.1-3
c0-1.3,0.4-2.4,1.1-3.1s1.6-1.2,2.7-1.2c1.1,0,2,0.4,2.7,1.1C121.1,56.2,121.4,57.3,121.4,58.5z M116,58.5c0,2,0.5,3,1.6,3
c0.5,0,0.9-0.3,1.2-0.8c0.3-0.5,0.4-1.2,0.4-2.2c0-2-0.5-3.1-1.6-3.1c-0.5,0-0.9,0.3-1.2,0.8C116.3,56.8,116,57.6,116,58.5z"/>
</g>
<g>
<path class="st2" d="M83.5,41v3.8H68.3V25.3c0-2.4-0.1-4.6-0.4-6.5l4.6-0.4L73,19v22.2L83.5,41z"/>
<path class="st2" d="M100.4,39.5c0,1.9,0.3,3.6,0.8,5.1l-3.9,0.4c-0.4-0.7-0.8-1.6-1.1-2.6h-0.1c-0.5,0.7-1.3,1.3-2.3,1.9
c-0.9,0.5-2.2,0.8-3.2,0.8c-1.5,0-2.7-0.4-3.6-1.2c-0.9-0.8-1.3-1.9-1.3-3.4c0-2,0.9-3.6,2.8-4.6c1.9-1.1,4.3-1.6,7.4-1.7v-1.7
c0-2.3-1.2-3.4-3.5-3.4c-1.9,0-3.8,0.5-5.6,1.6l-0.3-3.6c2.4-0.9,4.7-1.5,7.1-1.5c2.3,0,4,0.5,5.2,1.6c1.2,1.1,1.7,2.6,1.7,4.6
c0,0.9,0,2.3,0,4C100.4,37.7,100.4,38.9,100.4,39.5z M90.2,39.8c0,0.7,0.3,1.3,0.7,1.7c0.4,0.5,1.1,0.7,1.7,0.7
c1.2,0,2.2-0.4,3.1-1.3v-5.1c-1.6,0.1-3,0.5-4,1.2C90.8,37.8,90.2,38.7,90.2,39.8z"/>
</g>
<g>
<path class="st2" d="M126.3,37.3c0,2.4-0.8,4.2-2.6,5.5c-1.7,1.3-4,2-6.9,2c-3.1,0-5.5-0.5-7.3-1.5L110,39c0.9,0.5,2,1.1,3.5,1.3
c1.3,0.3,2.6,0.4,3.6,0.4c1.3,0,2.4-0.3,3.2-0.9c0.8-0.5,1.1-1.3,1.1-2.4c0-0.3,0-0.5-0.1-0.8c0-0.3-0.1-0.5-0.3-0.7
s-0.3-0.4-0.4-0.7c-0.1-0.3-0.4-0.4-0.4-0.5c-0.1-0.1-0.3-0.3-0.7-0.5c-0.3-0.3-0.5-0.4-0.7-0.4c-0.1-0.1-0.4-0.3-0.8-0.4
c-0.4-0.3-0.7-0.4-0.8-0.4c-0.1-0.1-0.4-0.3-0.9-0.4c-0.4-0.3-0.7-0.4-0.8-0.4c-0.9-0.4-1.6-0.8-2.2-1.2c-0.5-0.4-1.2-0.8-1.7-1.5
c-0.7-0.5-1.1-1.2-1.3-2c-0.3-0.8-0.4-1.6-0.4-2.7c0-2.4,0.9-4.2,2.7-5.5c1.7-1.3,4.2-2,7-2c2.4,0,4.4,0.4,6.2,1.1l-0.7,4.3
c-1.7-1.1-3.6-1.5-5.6-1.5c-1.5,0-2.6,0.3-3.4,0.9c-0.8,0.7-1.2,1.3-1.2,2.4c0,0.4,0,0.7,0.1,1.1c0.1,0.3,0.3,0.7,0.5,0.9
c0.3,0.3,0.5,0.5,0.7,0.8c0.1,0.1,0.5,0.4,0.9,0.7c0.5,0.3,0.8,0.5,1.1,0.5c0.1,0.1,0.5,0.3,1.2,0.7c0.7,0.3,1.1,0.5,1.2,0.5
c0.8,0.4,1.5,0.8,2.2,1.2c0.5,0.4,1.2,0.8,1.7,1.5c0.7,0.7,1.1,1.3,1.3,2.2C126.1,35.4,126.3,36.3,126.3,37.3z"/>
<path class="st2" d="M143.9,39.1c0,1.9,0.3,3.8,0.8,5.4l-4,0.4c-0.4-0.7-0.8-1.6-1.1-2.7h-0.1c-0.5,0.8-1.3,1.3-2.4,1.9
c-1.1,0.5-2.2,0.8-3.4,0.8c-1.6,0-2.8-0.4-3.8-1.2c-0.9-0.8-1.3-2-1.3-3.5c0-2.2,0.9-3.6,3-4.7c1.9-1.1,4.4-1.6,7.7-1.7v-1.9
c0-2.3-1.2-3.5-3.6-3.5c-2,0-3.9,0.5-5.9,1.7l-0.3-3.8c2.4-1.1,4.8-1.5,7.4-1.5c2.4,0,4.2,0.5,5.4,1.6c1.2,1.1,1.9,2.7,1.9,4.8
c0,0.9,0,2.3,0,4.2C143.9,37.1,143.9,38.3,143.9,39.1z M133.4,39.3c0,0.7,0.3,1.3,0.7,1.9c0.4,0.5,1.1,0.8,1.9,0.8
c1.2,0,2.3-0.4,3.2-1.3v-5.2c-1.7,0.1-3.1,0.5-4.2,1.2S133.4,38.2,133.4,39.3z"/>
<path class="st2" d="M153.7,44.4h-4.8V21.9c0-2-0.1-4.2-0.4-6.5l4.8-0.5l0.5,0.5L153.7,44.4L153.7,44.4z"/>
<path class="st2" d="M163.4,44.4h-4.8V21.9c0-2-0.1-4.2-0.4-6.5l4.8-0.5l0.5,0.5L163.4,44.4L163.4,44.4z"/>
<path class="st2" d="M183.7,34.7l-0.5,0.5h-10.9c0.1,2,0.8,3.6,1.7,4.6c0.9,0.9,2.4,1.5,4,1.5c1.6,0,3.2-0.4,4.8-1.3l0.3,3.2
c-1.7,1.1-3.9,1.6-6.5,1.6c-3,0-5.2-0.8-7-2.6s-2.6-4.2-2.6-7.3c0-3.1,0.8-5.6,2.6-7.5c1.7-1.9,3.9-2.8,6.6-2.8
c2.3,0,4.2,0.7,5.5,2.2c1.3,1.5,2,3.4,2,5.6C183.8,33.5,183.8,34.2,183.7,34.7z M176,27.6c-1.1,0-2,0.5-2.7,1.6
c-0.7,1.1-1.1,2.6-1.2,4.3l6.7-0.3c0-0.3,0-0.8,0-1.3c0-1.2-0.3-2.3-0.8-3.1S176.9,27.6,176,27.6z"/>
</g>
<g>
<path id="Path_483" class="st1" d="M187,39.4h1.5c0.3,0,0.7,0.1,1,0.2c0.3,0.2,0.5,0.5,0.5,0.8c0,0.3-0.1,0.6-0.3,0.8
c-0.1,0.1-0.3,0.2-0.5,0.3c0.4,0.1,0.6,0.3,0.6,0.8c0,0.4,0.1,0.9,0.3,1.3h-0.6c-0.1-0.4-0.2-0.7-0.2-1.1
c-0.1-0.6-0.2-0.8-0.9-0.8h-0.7v1.9H187V39.4 M187.5,41.2h0.9c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.3,0.3-0.6c0-0.7-0.6-0.7-0.8-0.7
h-0.9V41.2z"/>
<path id="Path_484" class="st1" d="M191.9,41.5c0,1.9-1.6,3.4-3.4,3.3s-3.4-1.6-3.3-3.4c0-1.9,1.5-3.3,3.4-3.3
C190.4,38.1,191.9,39.6,191.9,41.5z M188.5,37.8c-2.1,0-3.7,1.6-3.8,3.7c0,2.1,1.6,3.7,3.7,3.8c2.1,0,3.7-1.6,3.8-3.7c0,0,0,0,0,0
C192.2,39.4,190.5,37.8,188.5,37.8z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,9 +1,20 @@
import { Link } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { Home, Menu, Network, X } from 'lucide-react' import { Home, LogOut, Menu, Network, X } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { supabaseBrowser } from '@/data/supabase/client'
export default function Header() { export default function Header() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const handleLogout = async () => {
try {
await supabaseBrowser().auth.signOut()
} finally {
void navigate({ to: '/login', replace: true })
}
}
return ( return (
<> <>
@@ -18,13 +29,19 @@ export default function Header() {
</button> </button>
<h1 className="ml-4 text-xl font-semibold"> <h1 className="ml-4 text-xl font-semibold">
<Link to="/"> <Link to="/">
<img <img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
src="/tanstack-word-logo-white.svg"
alt="TanStack Logo"
className="h-10"
/>
</Link> </Link>
</h1> </h1>
<button
onClick={handleLogout}
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
aria-label="Logout"
title="Logout"
>
<LogOut size={20} />
<span className="hidden sm:inline">Salir</span>
</button>
</header> </header>
<aside <aside

View File

@@ -1,30 +1,11 @@
import { import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
createFileRoute, import { Pencil, Sparkles } from 'lucide-react'
Link, import { useState, useEffect } from 'react'
useNavigate,
useParams,
useRouterState,
} from '@tanstack/react-router'
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
import { useCallback, useState, useEffect } from 'react'
import { BibliographyItem } from './BibliographyItem' import type { AsignaturaDetail } from '@/data'
import { ContenidoTematico } from './ContenidoTematico'
import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
import { IAAsignaturaTab } from './IAAsignaturaTab'
import type {
CampoEstructura,
IAMessage,
IASugerencia,
} from '@/types/asignatura'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -32,12 +13,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useSubject } from '@/data/hooks/useSubjects' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import {
mockAsignatura,
mockEstructura,
mockDocumentoSep,
} from '@/data/mockAsignaturaData'
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string id: string
@@ -61,45 +37,54 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos datos: AsignaturaDatos
} }
function EditableHeaderField({ function isRecord(value: unknown): value is Record<string, unknown> {
value, return typeof value === 'object' && value !== null && !Array.isArray(value)
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
const textValue = String(value)
// Manejador para cuando el usuario termina de editar (pierde el foco)
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText
if (newValue !== textValue) {
onSave(newValue)
}
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { function parseContenidoTematicoToPlainText(value: unknown): string {
if (e.key === 'Enter') { if (!Array.isArray(value)) return ''
e.preventDefault()
e.currentTarget.blur() // Forzamos el guardado al presionar Enter const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
} }
})
blocks.push(lines.join('\n'))
} }
return ( return blocks.join('\n\n').trimEnd()
// eslint-disable-next-line jsx-a11y/no-static-element-interactions }
<span
contentEditable const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable contenido_tematico: parseContenidoTematicoToPlainText,
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
>
{textValue}
</span>
)
} }
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -109,305 +94,67 @@ export const Route = createFileRoute(
}) })
export default function AsignaturaDetailPage() { export default function AsignaturaDetailPage() {
const routerState = useRouterState()
const state = routerState.location.state as any
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { planId } = useParams({ const { data: asignaturaApi } = useSubject(asignaturaId)
from: '/planes/$planId/asignaturas/$asignaturaId',
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura()
const handlePersistDatoGeneral = (clave: string, value: string) => {
const baseDatos = asignatura?.datos ?? (asignaturaApi as any)?.datos ?? {}
const mergedDatos = { ...baseDatos, [clave]: value }
// Mantener estado local coherente para merges posteriores.
setAsignatura((prev) => ({
...((prev ?? asignaturaApi ?? {}) as any),
datos: mergedDatos,
}))
updateAsignatura.mutate({
asignaturaId,
patch: {
datos: mergedDatos,
},
}) })
const { data: asignaturasApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<Array<IAMessage>>([])
const [datosGenerales, setDatosGenerales] = useState({})
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
const [activeTab, setActiveTab] = useState('datos')
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
useEffect(() => {
// Si en el state de la ruta viene una pestaña específica, cámbiate a ella
if (state?.activeTab) {
setActiveTab(state.activeTab)
}
}, [state])
// Sincronizar cuando llegue la API
useEffect(() => {
if (asignaturasApi) {
setHeaderData({
codigo: asignaturasApi.codigo ?? '',
nombre: asignaturasApi.nombre,
creditos: asignaturasApi.creditos,
ciclo: asignaturasApi.numero_ciclo ?? 0,
})
}
}, [asignaturasApi])
const handleUpdateHeader = (key: string, value: string | number) => {
const newData = { ...headerData, [key]: value }
setHeaderData(newData)
console.log('💾 Guardando en estado y base de datos:', key, value)
} }
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
if (asignaturasApi?.datos) { if (asignaturaApi) setAsignatura(asignaturaApi)
setDatosGenerales(asignaturasApi) }, [asignaturaApi])
}
}, [asignaturasApi])
// 2. Funciones de manejo para la IA return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
const handleSendMessage = (text: string, campoId?: string) => {
const newMessage: IAMessage = {
id: Date.now().toString(),
role: 'user',
content: text,
timestamp: new Date(),
campoAfectado: campoId,
}
setMessages([...messages, newMessage])
// Aquí llamarías a tu API de OpenAI/Claude
// toast.info("Enviando consulta a la IA...");
} }
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
// toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
}
// Dentro de tu componente principal (donde están los Tabs)
const [bibliografia, setBibliografia] = useState<Array<BibliografiaEntry>>([
{
id: '1',
tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.',
},
])
const [isSaving, setIsSaving] = useState(false)
const handleSaveBibliografia = (data: Array<BibliografiaEntry>) => {
setIsSaving(true)
// Aquí iría tu llamada a la API
setBibliografia(data)
// Simulamos un guardado
setTimeout(() => {
setIsSaving(false)
// toast.success("Cambios guardados");
}, 1000)
}
const [isRegenerating, setIsRegenerating] = useState(false)
const handleRegenerateDocument = useCallback(() => {
setIsRegenerating(true)
setTimeout(() => {
setIsRegenerating(false)
}, 2000)
}, [])
return (
<div className="w-full">
{/* ================= HEADER ACTUALIZADO ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes/$planId/asignaturas"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="h-4 w-4" /> Volver al plan
</Link>
<div className="flex items-start justify-between gap-6">
<div className="space-y-3">
{/* CÓDIGO EDITABLE */}
<Badge className="border border-blue-700 bg-blue-900/50">
<EditableHeaderField
value={headerData.codigo}
onSave={(val) => handleUpdateHeader('codigo', val)}
/>
</Badge>
{/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
{/* Eliminamos el max-w y dejamos que el flex-wrap haga su trabajo */}
<EditableHeaderField
value={asignaturasApi?.planes_estudio?.datos?.nombre || ''}
onSave={(val) => handleUpdateHeader('plan_nombre', val)}
className="min-w-[10ch] text-blue-100" // min-w para que sea clickeable si está vacío
/>
</span>
<span className="flex items-center gap-1">
<EditableHeaderField
value={
asignaturasApi?.planes_estudio?.carreras?.facultades
?.nombre || ''
}
onSave={(val) => handleUpdateHeader('facultad_nombre', val)}
className="min-w-[10ch] text-blue-100"
/>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturasApi?.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
</span>
<span>créditos</span>
</Badge>
{/* SEMESTRE EDITABLE */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.ciclo}
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
</div>
</div>
</div>
</section>
{/* ================= TABS ================= */}
<section className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="h-auto gap-6 bg-transparent p-0">
<TabsTrigger value="datos">Datos generales</TabsTrigger>
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
<TabsTrigger value="ia">IA de la asignatura</TabsTrigger>
<TabsTrigger value="sep">Documento SEP</TabsTrigger>
<TabsTrigger value="historial">Historial</TabsTrigger>
</TabsList>
<Separator className="mt-2" />
{/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos">
<DatosGenerales
data={datosGenerales}
isLoading={loadingAsig}
asignaturaId={asignaturaId}
/>
</TabsContent>
<TabsContent value="contenido">
<ContenidoTematico
data={asignaturasApi}
isLoading={loadingAsig}
></ContenidoTematico>
</TabsContent>
<TabsContent value="bibliografia">
<BibliographyItem
bibliografia={bibliografia}
id={asignaturaId}
onSave={handleSaveBibliografia}
isSaving={isSaving}
/>
</TabsContent>
<TabsContent value="ia">
<IAAsignaturaTab
campos={campos}
datosGenerales={datosGenerales}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={
(id) =>
console.log(
'Rechazada',
) /* toast.error("Sugerencia rechazada")*/
}
/>
</TabsContent>
<TabsContent value="sep">
<DocumentoSEPTab
documento={mockDocumentoSep}
asignatura={mockAsignatura}
estructura={mockEstructura}
datosGenerales={datosGenerales}
onRegenerate={handleRegenerateDocument}
isRegenerating={isRegenerating}
/>
</TabsContent>
<TabsContent value="historial">
<HistorialTab asignaturaId={asignaturaId} />
</TabsContent>
</Tabs>
</div>
</section>
</div>
)
}
/* ================= TAB CONTENT ================= */
interface DatosGeneralesProps {
asignaturaId: string
data: AsignaturaDatos
isLoading: boolean
}
function DatosGenerales({ function DatosGenerales({
data, onPersistDato,
isLoading, }: {
asignaturaId, onPersistDato: (clave: string, value: string) => void
}: DatosGeneralesProps) { }) {
const formatTitle = (key: string): string => const { asignaturaId } = useParams({
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()) from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
// 1. Extraemos la definición de la estructura (los metadatos) // 1. Extraemos la definición de la estructura (los metadatos)
const structureProps = const definicionRaw = data?.estructuras_asignatura?.definicion
data?.estructuras_asignatura?.definicion?.properties || {} const definicion = isRecord(definicionRaw)
? (definicionRaw as Record<string, unknown>)
: null
const propertiesRaw = definicion ? (definicion as any).properties : undefined
const structureProps = isRecord(propertiesRaw)
? (propertiesRaw as Record<string, any>)
: {}
// 2. Extraemos los valores reales (el contenido redactado) // 2. Extraemos los valores reales (el contenido redactado)
const valoresActuales = data?.datos || {} const datosRaw = data?.datos
const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>)
: {}
if (isLoading) return <p>Cargando información...</p>
return ( return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500"> <div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
@@ -427,15 +174,16 @@ function DatosGenerales({
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */} {/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
{isLoading && <p>Cargando información...</p>} {Object.entries(structureProps).map(
{!isLoading &&
Object.entries(structureProps).map(
([key, config]: [string, any]) => { ([key, config]: [string, any]) => {
// 1. METADATOS (Vienen de structureProps -> config)
const cardTitle = config.title || key const cardTitle = config.title || key
const description = config.description || '' const description = config.description || ''
const xColumn =
typeof config?.['x-column'] === 'string'
? config['x-column']
: undefined
// Obtenemos el placeholder del arreglo 'examples' de la estructura // Obtenemos el placeholder del arreglo 'examples' de la estructura
const placeholder = const placeholder =
config.examples && config.examples.length > 0 config.examples && config.examples.length > 0
@@ -444,11 +192,15 @@ function DatosGenerales({
const valActual = valoresActuales[key] const valActual = valoresActuales[key]
const isContentEmpty = let currentContent = valActual ?? ''
!valActual?.description ||
valActual.description === config.description
const currentContent = valActual ?? '' if (xColumn) {
const rawValue = (data as any)?.[xColumn]
const parser = columnParsers[xColumn]
currentContent = parser
? parser(rawValue)
: String(rawValue ?? '')
}
return ( return (
<InfoCard <InfoCard
@@ -456,10 +208,11 @@ function DatosGenerales({
key={key} key={key}
clave={key} clave={key}
title={cardTitle} title={cardTitle}
initialContent={currentContent} // Si es igual a la descripción de la SEP, pasamos vacío initialContent={currentContent}
placeholder={placeholder} // Aquí irá "Primer semestre", "MAT-101", etc. xColumn={xColumn}
description={description} // El texto largo de "Indicar el ciclo..." placeholder={placeholder}
onEnhanceAI={(contenido) => console.log(contenido)} description={description}
onPersist={(clave, value) => onPersistDato(clave, value)}
/> />
) )
}, },
@@ -512,9 +265,11 @@ interface InfoCardProps {
initialContent: any initialContent: any
placeholder?: string placeholder?: string
description?: string description?: string
xColumn?: string
required?: boolean // Nueva prop para el asterisco required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation' type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void onEnhanceAI?: (content: any) => void
onPersist?: (clave: string, value: string) => void
} }
function InfoCard({ function InfoCard({
@@ -524,13 +279,18 @@ function InfoCard({
initialContent, initialContent,
placeholder, placeholder,
description, description,
xColumn,
required, required,
type = 'text', type = 'text',
onPersist,
}: InfoCardProps) { }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent) const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent) const [tempText, setTempText] = useState(initialContent)
const navigate = useNavigate() const navigate = useNavigate()
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
@@ -538,21 +298,29 @@ function InfoCard({
}, [initialContent]) }, [initialContent])
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
setData(tempText) setData(tempText)
setIsEditing(false) setIsEditing(false)
// Aquí iría tu lógica de guardado a la DB
if (type === 'text' && clave && onPersist) {
onPersist(clave, String(tempText ?? ''))
}
} }
const handleIARequest = (campoClave: string) => { const handleIARequest = (campoClave: string) => {
console.log(placeholder) console.log(placeholder)
// Añadimos un timestamp a la state para forzar que la navegación
// genere una nueva ubicación incluso si la ruta y los params son iguales.
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { asignaturaId: asignaturaId! }, params: { planId, asignaturaId: asignaturaId! },
state: { state: {
activeTab: 'ia', activeTab: 'ia',
prefillCampo: campoClave, prefillCampo: campoClave,
prefillContenido: data, prefillContenido: data,
_ts: Date.now(),
} as any, } as any,
}) })
} }
@@ -592,7 +360,7 @@ function InfoCard({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100" className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => handleIARequest(clave)} onClick={() => clave && handleIARequest(clave)}
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
</Button> </Button>
@@ -606,7 +374,21 @@ function InfoCard({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-slate-400" className="h-8 w-8 text-slate-400"
onClick={() => setIsEditing(true)} onClick={() => {
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico),
// redirigimos a la pestaña de Contenido en vez de editar inline.
if (xColumn === 'contenido_tematico') {
// Agregamos un timestamp para forzar la actualización
// de la location.state aunque la ruta sea la misma.
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
params: { planId, asignaturaId: asignaturaId! },
})
return
}
setIsEditing(true)
}}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</Button> </Button>
@@ -626,7 +408,7 @@ function InfoCard({
value={tempText} value={tempText}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => setTempText(e.target.value)} onChange={(e) => setTempText(e.target.value)}
className="min-h-[120px] text-sm leading-relaxed" className="min-h-30 text-sm leading-relaxed"
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button

View File

@@ -1,13 +1,9 @@
import { /* eslint-disable jsx-a11y/click-events-have-key-events */
Plus, /* eslint-disable jsx-a11y/label-has-associated-control */
Search, /* eslint-disable jsx-a11y/no-static-element-interactions */
BookOpen, import { useParams } from '@tanstack/react-router'
Trash2, import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
Library, import { useState } from 'react'
Edit3,
Save,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -38,40 +34,13 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects' import {
useCreateBibliografia,
useDeleteBibliografia,
useSubjectBibliografia,
useUpdateBibliografia,
} from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// import { toast } from 'sonner';
// import { mockLibraryResources } from '@/data/mockAsignaturaData';
export const mockLibraryResources = [
{
id: 'lib-1',
titulo: 'Deep Learning',
autor: 'Goodfellow, I., Bengio, Y., & Courville, A.',
editorial: 'MIT Press',
anio: 2016,
isbn: '9780262035613',
disponible: true,
},
{
id: 'lib-2',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Russell, S., & Norvig, P.',
editorial: 'Pearson',
anio: 2020,
isbn: '9780134610993',
disponible: true,
},
{
id: 'lib-3',
titulo: 'Hands-On Machine Learning',
autor: 'Aurélien Géron',
editorial: "O'Reilly Media",
anio: 2019,
isbn: '9781492032649',
disponible: false,
},
]
// --- Interfaces --- // --- Interfaces ---
export interface BibliografiaEntry { export interface BibliografiaEntry {
@@ -84,24 +53,21 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any fuenteBiblioteca?: any
} }
interface BibliografiaTabProps { export function BibliographyItem() {
id: string const { asignaturaId } = useParams({
bibliografia: Array<BibliografiaEntry> from: '/planes/$planId/asignaturas/$asignaturaId',
onSave: (bibliografia: Array<BibliografiaEntry>) => void })
isSaving: boolean
}
export function BibliographyItem({ // --- 1. Única fuente de verdad: La Query ---
bibliografia, const { data: bibliografia = [], isLoading } =
id, useSubjectBibliografia(asignaturaId)
onSave,
isSaving,
}: BibliografiaTabProps) {
console.log(id)
const { data: bibliografia2, isLoading: loadinasignatura } = // --- 2. Mutaciones ---
useSubjectBibliografia(id) const { mutate: crearBibliografia } = useCreateBibliografia()
const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia) const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
// --- 3. Estados de UI (Solo para diálogos y edición) ---
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -110,30 +76,27 @@ export function BibliographyItem({
'BASICA', 'BASICA',
) )
useEffect(() => { console.log('Datos actuales en el front:', bibliografia)
if (bibliografia2 && Array.isArray(bibliografia2)) { // --- 4. Derivación de datos (Se calculan en cada render) ---
setEntries(bibliografia2) const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
} else if (bibliografia) { const complementariaEntries = bibliografia.filter(
// Fallback a la prop inicial si la API no devuelve nada
setEntries(bibliografia)
}
}, [bibliografia2, bibliografia])
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = entries.filter(
(e) => e.tipo === 'COMPLEMENTARIA', (e) => e.tipo === 'COMPLEMENTARIA',
) )
console.log(bibliografia2)
// --- Handlers Conectados a la Base de Datos ---
const handleAddManual = (cita: string) => { const handleAddManual = (cita: string) => {
const newEntry: BibliografiaEntry = { crearBibliografia(
id: `manual-${Date.now()}`, {
asignatura_id: asignaturaId,
tipo: newEntryType, tipo: newEntryType,
cita, cita,
} tipo_fuente: 'MANUAL',
setEntries([...entries, newEntry]) },
setIsAddDialogOpen(false) {
// toast.success('Referencia manual añadida'); onSuccess: () => setIsAddDialogOpen(false),
},
)
} }
const handleAddFromLibrary = ( const handleAddFromLibrary = (
@@ -141,22 +104,43 @@ export function BibliographyItem({
tipo: 'BASICA' | 'COMPLEMENTARIA', tipo: 'BASICA' | 'COMPLEMENTARIA',
) => { ) => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.` const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
const newEntry: BibliografiaEntry = { crearBibliografia(
id: `lib-ref-${Date.now()}`, {
asignatura_id: asignaturaId,
tipo, tipo,
cita, cita,
fuenteBibliotecaId: resource.id, tipo_fuente: 'BIBLIOTECA',
fuenteBiblioteca: resource, biblioteca_item_id: resource.id,
} },
setEntries([...entries, newEntry]) {
setIsLibraryDialogOpen(false) onSuccess: () => setIsLibraryDialogOpen(false),
// toast.success('Añadido desde biblioteca'); },
)
} }
const handleUpdateCita = (id: string, cita: string) => { const handleUpdateCita = (id: string, nuevaCita: string) => {
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e))) actualizarBibliografia(
{
id,
updates: { cita: nuevaCita },
},
{
onSuccess: () => setEditingId(null),
},
)
} }
const onConfirmDelete = () => {
if (deleteId) {
eliminarBibliografia(deleteId, {
onSuccess: () => setDeleteId(null),
})
}
}
if (isLoading)
return <div className="p-10 text-center">Cargando bibliografía...</div>
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500"> <div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
@@ -184,8 +168,13 @@ export function BibliographyItem({
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<LibrarySearchDialog <LibrarySearchDialog
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
onSelect={handleAddFromLibrary} onSelect={handleAddFromLibrary}
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')} // CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
existingIds={bibliografia.map(
(e) => e.biblioteca_item_id || '',
)}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -204,15 +193,6 @@ export function BibliographyItem({
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button
onClick={() => onSave(entries)}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div> </div>
</div> </div>
@@ -274,13 +254,7 @@ export function BibliographyItem({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
onClick={() => {
setEntries(entries.filter((e) => e.id !== deleteId))
setDeleteId(null)
}}
className="bg-red-600"
>
Eliminar Eliminar
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -433,14 +407,16 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
) )
} }
function LibrarySearchDialog({ onSelect, existingIds }: any) { function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA') const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
const filtered = mockLibraryResources.filter( const filtered = (resources || []).filter(
(r) => (r: any) =>
!existingIds.includes(r.id) && !existingIds.includes(r.id) &&
r.titulo.toLowerCase().includes(search.toLowerCase()), r.titulo?.toLowerCase().includes(search.toLowerCase()),
) )
console.log(filtered)
console.log(resources)
return ( return (
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
@@ -468,7 +444,7 @@ function LibrarySearchDialog({ onSelect, existingIds }: any) {
</Select> </Select>
</div> </div>
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2"> <div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map((res) => ( {filtered.map((res: any) => (
<div <div
key={res.id} key={res.id}
onClick={() => onSelect(res, tipo)} onClick={() => onSelect(res, tipo)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useParams } from '@tanstack/react-router'
import { import {
Plus, Plus,
GripVertical, GripVertical,
@@ -7,17 +7,12 @@ import {
Edit3, Edit3,
Trash2, Trash2,
Clock, Clock,
Save,
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import { Badge } from '@/components/ui/badge' import type { FocusEvent, KeyboardEvent } from 'react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -28,6 +23,16 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// import { toast } from 'sonner'; // import { toast } from 'sonner';
@@ -42,78 +47,306 @@ export interface UnidadTematica {
id: string id: string
nombre: string nombre: string
numero: number numero: number
temas: Tema[] temas: Array<Tema>
} }
const initialData: UnidadTematica[] = [ function isRecord(value: unknown): value is Record<string, unknown> {
{ return typeof value === 'object' && value !== null && !Array.isArray(value)
id: 'u1', }
numero: 1,
nombre: 'Fundamentos de Inteligencia Artificial',
temas: [
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
],
},
]
// Estructura que viene de tu JSON/API function coerceNumber(value: unknown): number | undefined {
interface ContenidoApi { if (typeof value === 'number' && Number.isFinite(value)) return value
unidad: number if (typeof value === 'string') {
titulo: string const trimmed = value.trim()
temas: string[] | any[] // Acepta strings o objetos if (!trimmed) return undefined
[key: string]: any // Esta línea permite que haya más claves desconocidas const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
function coerceString(value: unknown): string | undefined {
if (typeof value === 'string') return value
return undefined
}
function mapTemaValue(value: unknown): ContenidoTemaApi | null {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed ? trimmed : null
}
if (isRecord(value)) {
const nombre = coerceString(value.nombre)
if (!nombre) return null
const horasEstimadas = coerceNumber(value.horasEstimadas)
const descripcion = coerceString(value.descripcion)
return {
...value,
nombre,
horasEstimadas,
descripcion,
}
}
return null
}
function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (!isRecord(value)) return null
const unidad = coerceNumber(value.unidad) ?? index + 1
const titulo = coerceString(value.titulo) ?? 'Sin título'
let temas: Array<ContenidoTemaApi> = []
if (Array.isArray(value.temas)) {
temas = value.temas
.map(mapTemaValue)
.filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
}
return { unidad, titulo, temas }
}
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') {
try {
return mapContenidoTematicoFromDb(JSON.parse(value))
} catch {
return []
}
}
if (Array.isArray(value)) {
return value
.map((item, idx) => mapContenidoItem(item, idx))
.filter((x): x is ContenidoApi => x !== null)
}
if (isRecord(value)) {
if (Array.isArray(value.contenido_tematico)) {
return mapContenidoTematicoFromDb(value.contenido_tematico)
}
if (Array.isArray(value.unidades)) {
return mapContenidoTematicoFromDb(value.unidades)
}
}
return []
}
function serializeUnidadesToApi(
unidades: Array<UnidadTematica>,
): Array<ContenidoApi> {
return unidades
.slice()
.sort((a, b) => a.numero - b.numero)
.map((u, idx) => ({
unidad: u.numero || idx + 1,
titulo: u.nombre || 'Sin título',
temas: u.temas.map((t) => ({
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
})),
}))
} }
// Props del componente // Props del componente
interface ContenidoTematicoProps {
data: { export function ContenidoTematico() {
contenido_tematico: ContenidoApi[] const updateContenido = useUpdateSubjectContenido()
} const { asignaturaId } = useParams({
isLoading: boolean from: '/planes/$planId/asignaturas/$asignaturaId',
} })
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
const [unidades, setUnidades] = useState<UnidadTematica[]>([]) const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
const [expandedUnits, setExpandedUnits] = useState<Set<string>>( const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
new Set(['u1']), const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
const unitContainerRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const unitTitleInputRef = useRef<HTMLInputElement | null>(null)
const temaNombreInputElRef = useRef<HTMLInputElement | null>(null)
const [pendingScrollUnitId, setPendingScrollUnitId] = useState<string | null>(
null,
) )
const cancelNextBlurRef = useRef(false)
const [deleteDialog, setDeleteDialog] = useState<{ const [deleteDialog, setDeleteDialog] = useState<{
type: 'unidad' | 'tema' type: 'unidad' | 'tema'
id: string id: string
parentId?: string parentId?: string
} | null>(null) } | null>(null)
const [editingUnit, setEditingUnit] = useState<string | null>(null) const [editingUnit, setEditingUnit] = useState<string | null>(null)
const [unitDraftNombre, setUnitDraftNombre] = useState('')
const [unitOriginalNombre, setUnitOriginalNombre] = useState('')
const [editingTema, setEditingTema] = useState<{ const [editingTema, setEditingTema] = useState<{
unitId: string unitId: string
temaId: string temaId: string
} | null>(null) } | null>(null)
const [isSaving, setIsSaving] = useState(false) const [temaDraftNombre, setTemaDraftNombre] = useState('')
const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({
subjectId: asignaturaId,
unidades: payload,
})
}
const beginEditUnit = (unitId: string) => {
const unit = unidades.find((u) => u.id === unitId)
const nombre = unit?.nombre ?? ''
setEditingUnit(unitId)
setUnitDraftNombre(nombre)
setUnitOriginalNombre(nombre)
setExpandedUnits((prev) => {
const next = new Set(prev)
next.add(unitId)
return next
})
}
const commitEditUnit = () => {
if (!editingUnit) return
const next = unidades.map((u) =>
u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
)
setUnidades(next)
setEditingUnit(null)
void persistUnidades(next)
}
const cancelEditUnit = () => {
setEditingUnit(null)
setUnitDraftNombre(unitOriginalNombre)
}
const beginEditTema = (unitId: string, temaId: string) => {
const unit = unidades.find((u) => u.id === unitId)
const tema = unit?.temas.find((t) => t.id === temaId)
const nombre = tema?.nombre ?? ''
const horas = tema?.horasEstimadas ?? 0
setEditingTema({ unitId, temaId })
setTemaDraftNombre(nombre)
setTemaOriginalNombre(nombre)
setTemaDraftHoras(String(horas))
setTemaOriginalHoras(horas)
setExpandedUnits((prev) => {
const next = new Set(prev)
next.add(unitId)
return next
})
}
const commitEditTema = () => {
if (!editingTema) return
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u
return {
...u,
temas: u.temas.map((t) =>
t.id === editingTema.temaId
? { ...t, nombre: temaDraftNombre, horasEstimadas }
: t,
),
}
})
setUnidades(next)
setEditingTema(null)
void persistUnidades(next)
}
const cancelEditTema = () => {
setEditingTema(null)
setTemaDraftNombre(temaOriginalNombre)
setTemaDraftHoras(String(temaOriginalHoras))
}
const handleTemaEditorBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
const nextFocus = e.relatedTarget as Node | null
if (nextFocus && e.currentTarget.contains(nextFocus)) return
commitEditTema()
}
const handleTemaEditorKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.target instanceof HTMLElement) e.target.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditTema()
if (e.target instanceof HTMLElement) e.target.blur()
}
}
useEffect(() => { useEffect(() => {
if (data?.contenido_tematico) { const contenido = mapContenidoTematicoFromDb(
const transformed = data.contenido_tematico.map( data ? data.contenido_tematico : undefined,
(u: any, idx: number) => ({ )
id: `u-${idx}`,
const transformed = contenido.map((u, idx) => ({
id: `u-${u.unidad || idx + 1}`,
numero: u.unidad || idx + 1, numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título', nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas) temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({ ? u.temas.map((t: any, tidx: number) => ({
id: `t-${idx}-${tidx}`, id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
nombre: typeof t === 'string' ? t : t.nombre || 'Tema', nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
horasEstimadas: t.horasEstimadas || 0, horasEstimadas: t?.horasEstimadas || 0,
})) }))
: [], : [],
}), }))
)
setUnidades(transformed)
// Expandir la primera unidad automáticamente setUnidades(transformed)
if (transformed.length > 0) { // Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits(new Set([transformed[0].id])) setExpandedUnits((prev) => {
} const validIds = new Set(transformed.map((u) => u.id))
} const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)),
)
if (filtered.size > 0) return filtered
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
})
}, [data]) }, [data])
useEffect(() => {
if (!editingUnit) return
// Foco controlado (evitamos autoFocus por lint/a11y)
setTimeout(() => unitTitleInputRef.current?.focus(), 0)
}, [editingUnit])
useEffect(() => {
if (!editingTema) return
setTimeout(() => temaNombreInputElRef.current?.focus(), 0)
}, [editingTema])
useEffect(() => {
if (!pendingScrollUnitId) return
const el = unitContainerRefs.current.get(pendingScrollUnitId)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setPendingScrollUnitId(null)
}, [pendingScrollUnitId, unidades.length])
if (isLoading) if (isLoading)
return <div className="p-10 text-center">Cargando contenido...</div> return <div className="p-10 text-center">Cargando contenido...</div>
@@ -132,79 +365,76 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
} }
const addUnidad = () => { const addUnidad = () => {
const newId = `u-${Date.now()}` const newNumero = unidades.length + 1
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: unidades.length + 1, numero: newNumero,
temas: [], temas: [],
} }
setUnidades([...unidades, newUnidad]) const next = [...unidades, newUnidad]
setExpandedUnits(new Set([...expandedUnits, newId])) setUnidades(next)
setEditingUnit(newId) setExpandedUnits((prev) => {
} const n = new Set(prev)
n.add(newId)
return n
})
setPendingScrollUnitId(newId)
const updateUnidadNombre = (id: string, nombre: string) => { // Abrir edición del título inmediatamente
setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u))) setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre)
setUnitOriginalNombre(newUnidad.nombre)
} }
// --- Lógica de Temas --- // --- Lógica de Temas ---
const addTema = (unidadId: string) => { const addTema = (unidadId: string) => {
setUnidades( const unit = unidades.find((u) => u.id === unidadId)
unidades.map((u) => { const unitNumero = unit?.numero ?? 0
if (u.id === unidadId) { const newTemaIndex = (unit?.temas.length ?? 0) + 1
const newTemaId = `t-${Date.now()}` const newTemaId = `t-${unitNumero}-${newTemaIndex}`
const newTema: Tema = { const newTema: Tema = {
id: newTemaId, id: newTemaId,
nombre: 'Nuevo tema', nombre: 'Nuevo tema',
horasEstimadas: 2, horasEstimadas: 2,
} }
setEditingTema({ unitId: unidadId, temaId: newTemaId })
return { ...u, temas: [...u.temas, newTema] }
}
return u
}),
)
}
const updateTema = ( const next = unidades.map((u) =>
unidadId: string, u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
temaId: string,
updates: Partial<Tema>,
) => {
setUnidades(
unidades.map((u) => {
if (u.id === unidadId) {
return {
...u,
temas: u.temas.map((t) =>
t.id === temaId ? { ...t, ...updates } : t,
),
}
}
return u
}),
) )
setUnidades(next)
// Expandir unidad y poner el subtema en edición con foco en el nombre
setExpandedUnits((prev) => {
const n = new Set(prev)
n.add(unidadId)
return n
})
setEditingTema({ unitId: unidadId, temaId: newTemaId })
setTemaDraftNombre(newTema.nombre)
setTemaOriginalNombre(newTema.nombre)
setTemaDraftHoras(String(newTema.horasEstimadas ?? 0))
setTemaOriginalHoras(newTema.horasEstimadas ?? 0)
} }
const handleDelete = () => { const handleDelete = () => {
if (!deleteDialog) return if (!deleteDialog) return
let next: Array<UnidadTematica> = unidades
if (deleteDialog.type === 'unidad') { if (deleteDialog.type === 'unidad') {
setUnidades( next = unidades
unidades
.filter((u) => u.id !== deleteDialog.id) .filter((u) => u.id !== deleteDialog.id)
.map((u, i) => ({ ...u, numero: i + 1 })), .map((u, i) => ({ ...u, numero: i + 1 }))
)
} else if (deleteDialog.parentId) { } else if (deleteDialog.parentId) {
setUnidades( next = unidades.map((u) =>
unidades.map((u) =>
u.id === deleteDialog.parentId u.id === deleteDialog.parentId
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) } ? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
: u, : u,
),
) )
} }
setUnidades(next)
setDeleteDialog(null) setDeleteDialog(null)
void persistUnidades(next)
// toast.success("Eliminado correctamente"); // toast.success("Eliminado correctamente");
} }
@@ -219,32 +449,18 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
{unidades.length} unidades {totalHoras} horas estimadas totales {unidades.length} unidades {totalHoras} horas estimadas totales
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={addUnidad} className="gap-2">
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
<Button
onClick={() => {
setIsSaving(true)
setTimeout(() => {
setIsSaving(false) /*toast.success("Guardado")*/
}, 1000)
}}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{unidades.map((unidad) => ( {unidades.map((unidad) => (
<Card <div
key={unidad.id} key={unidad.id}
className="overflow-hidden border-slate-200 shadow-sm" ref={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
> >
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible <Collapsible
open={expandedUnits.has(unidad.id)} open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}
@@ -267,21 +483,35 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
{editingUnit === unidad.id ? ( {editingUnit === unidad.id ? (
<Input <Input
value={unidad.nombre} ref={unitTitleInputRef}
onChange={(e) => value={unitDraftNombre}
updateUnidadNombre(unidad.id, e.target.value) onChange={(e) => setUnitDraftNombre(e.target.value)}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
} }
onBlur={() => setEditingUnit(null)} commitEditUnit()
onKeyDown={(e) => }}
e.key === 'Enter' && setEditingUnit(null) onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
} }
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white" className="h-8 max-w-md bg-white"
autoFocus
/> />
) : ( ) : (
<CardTitle <CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600" className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => setEditingUnit(unidad.id)} onClick={() => beginEditUnit(unidad.id)}
> >
{unidad.nombre} {unidad.nombre}
</CardTitle> </CardTitle>
@@ -318,16 +548,22 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
tema={tema} tema={tema}
index={idx + 1} index={idx + 1}
isEditing={ isEditing={
editingTema?.unitId === unidad.id && !!editingTema &&
editingTema?.temaId === tema.id editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
} }
onEdit={() => draftNombre={temaDraftNombre}
setEditingTema({ unitId: unidad.id, temaId: tema.id }) draftHoras={temaDraftHoras}
} onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
onStopEditing={() => setEditingTema(null)} onDraftNombreChange={setTemaDraftNombre}
onUpdate={(updates) => onDraftHorasChange={setTemaDraftHoras}
updateTema(unidad.id, tema.id, updates) onEditorBlurCapture={handleTemaEditorBlurCapture}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
} }
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() => onDelete={() =>
setDeleteDialog({ setDeleteDialog({
type: 'tema', type: 'tema',
@@ -350,9 +586,24 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</Card> </Card>
</div>
))} ))}
</div> </div>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
</div>
<DeleteConfirmDialog <DeleteConfirmDialog
dialog={deleteDialog} dialog={deleteDialog}
setDialog={setDeleteDialog} setDialog={setDeleteDialog}
@@ -367,9 +618,14 @@ interface TemaRowProps {
tema: Tema tema: Tema
index: number index: number
isEditing: boolean isEditing: boolean
onEdit: () => void draftNombre: string
onStopEditing: () => void draftHoras: string
onUpdate: (updates: Partial<Tema>) => void onBeginEdit: () => void
onDraftNombreChange: (value: string) => void
onDraftHorasChange: (value: string) => void
onEditorBlurCapture: (e: FocusEvent<HTMLDivElement>) => void
onEditorKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void
onNombreInputRef: (el: HTMLInputElement | null) => void
onDelete: () => void onDelete: () => void
} }
@@ -377,9 +633,14 @@ function TemaRow({
tema, tema,
index, index,
isEditing, isEditing,
onEdit, draftNombre,
onStopEditing, draftHoras,
onUpdate, onBeginEdit,
onDraftNombreChange,
onDraftHorasChange,
onEditorBlurCapture,
onEditorKeyDownCapture,
onNombreInputRef,
onDelete, onDelete,
}: TemaRowProps) { }: TemaRowProps) {
return ( return (
@@ -391,44 +652,49 @@ function TemaRow({
> >
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span> <span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
{isEditing ? ( {isEditing ? (
<div className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2"> <div
className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2"
onBlurCapture={onEditorBlurCapture}
onKeyDownCapture={onEditorKeyDownCapture}
>
<Input <Input
value={tema.nombre} ref={onNombreInputRef}
onChange={(e) => onUpdate({ nombre: e.target.value })} value={draftNombre}
onChange={(e) => onDraftNombreChange(e.target.value)}
className="h-8 flex-1 bg-white" className="h-8 flex-1 bg-white"
placeholder="Nombre" placeholder="Nombre"
autoFocus
/> />
<Input <Input
type="number" type="number"
value={tema.horasEstimadas} value={draftHoras}
onChange={(e) => onChange={(e) => onDraftHorasChange(e.target.value)}
onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })
}
className="h-8 w-16 bg-white" className="h-8 w-16 bg-white"
/> />
<Button
size="sm"
className="h-8 bg-emerald-600"
onClick={onStopEditing}
>
Listo
</Button>
</div> </div>
) : ( ) : (
<> <>
<div className="flex-1 cursor-pointer" onClick={onEdit}> <button
type="button"
className="flex flex-1 items-center gap-3 text-left"
onClick={(e) => {
e.stopPropagation()
onBeginEdit()
}}
>
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p> <p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
</div>
<Badge variant="secondary" className="text-[10px] opacity-60"> <Badge variant="secondary" className="text-[10px] opacity-60">
{tema.horasEstimadas}h {tema.horasEstimadas}h
</Badge> </Badge>
</button>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-blue-600" className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={onEdit} onClick={(e) => {
e.stopPropagation()
onBeginEdit()
}}
> >
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
</Button> </Button>
@@ -436,7 +702,10 @@ function TemaRow({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500" className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={onDelete} onClick={(e) => {
e.stopPropagation()
onDelete()
}}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>

View File

@@ -1,16 +1,6 @@
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import {
FileText,
Download,
RefreshCw,
Calendar,
FileCheck,
AlertTriangle,
Loader2,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -22,54 +12,34 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import type { import { Button } from '@/components/ui/button'
DocumentoAsignatura, import { Card } from '@/components/ui/card'
Asignatura,
AsignaturaStructure,
} from '@/types/asignatura'
import { cn } from '@/lib/utils'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner';
//import { format } from 'date-fns';
//import { es } from 'date-fns/locale';
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
documento: DocumentoAsignatura | null pdfUrl: string | null
asignatura: Asignatura isLoading: boolean
estructura: AsignaturaStructure onDownload: () => void
datosGenerales: Record<string, any>
onRegenerate: () => void onRegenerate: () => void
isRegenerating: boolean isRegenerating: boolean
} }
export function DocumentoSEPTab({ export function DocumentoSEPTab({
documento, pdfUrl,
asignatura, isLoading,
estructura, onDownload,
datosGenerales,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false) const [showConfirmDialog, setShowConfirmDialog] = useState(false)
// Check completeness
const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
const camposCompletos = camposObligatorios.filter((c) =>
datosGenerales[c.id]?.trim(),
)
const completeness = Math.round(
(camposCompletos.length / camposObligatorios.length) * 100,
)
const isComplete = completeness === 100
const handleRegenerate = () => { const handleRegenerate = () => {
setShowConfirmDialog(false) setShowConfirmDialog(false)
onRegenerate() onRegenerate()
//toast.success('Regenerando documento...');
} }
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold"> <h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
@@ -77,28 +47,24 @@ export function DocumentoSEPTab({
Documento SEP Documento SEP
</h2> </h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial para la SEP Previsualización del documento oficial generado
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{documento?.estado === 'listo' && ( {pdfUrl && !isLoading && (
<Button <Button variant="outline" onClick={onDownload}>
variant="outline"
onClick={
() =>
console.log('descargando') /*toast.info('Descarga iniciada')*/
}
>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Descargar Descargar
</Button> </Button>
)} )}
<AlertDialog <AlertDialog
open={showConfirmDialog} open={showConfirmDialog}
onOpenChange={setShowConfirmDialog} onOpenChange={setShowConfirmDialog}
> >
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={isRegenerating || !isComplete}> <Button disabled={isRegenerating}>
{isRegenerating ? ( {isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
@@ -107,15 +73,16 @@ export function DocumentoSEPTab({
{isRegenerating ? 'Generando...' : 'Regenerar documento'} {isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle> <AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Se creará una nueva versión del documento con los datos Se generará una nueva versión del documento con la información
actuales de la asignatura. La versión anterior quedará en el actual.
historial.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRegenerate}> <AlertDialogAction onClick={handleRegenerate}>
@@ -127,308 +94,24 @@ export function DocumentoSEPTab({
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> {/* PDF Preview */}
{/* Document preview */} <Card className="h-[800px] overflow-hidden">
<div className="lg:col-span-2"> {isLoading ? (
<Card className="card-elevated h-[700px] overflow-hidden">
{documento?.estado === 'listo' ? (
<div className="bg-muted/30 flex h-full flex-col">
{/* Simulated document header */}
<div className="bg-card border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
<span className="text-foreground font-medium">
Programa de Estudios - {asignatura.clave}
</span>
</div>
<Badge variant="outline">Versión {documento.version}</Badge>
</div>
</div>
{/* Document content simulation */}
<div className="flex-1 overflow-y-auto p-8">
<div className="bg-card mx-auto max-w-2xl space-y-6 rounded-lg p-8 shadow-lg">
{/* Header */}
<div className="border-b pb-6 text-center">
<p className="text-muted-foreground mb-2 text-xs tracking-wide uppercase">
Secretaría de Educación Pública
</p>
<h1 className="font-display text-primary mb-1 text-2xl font-bold">
{asignatura.nombre}
</h1>
<p className="text-muted-foreground text-sm">
Clave: {asignatura.clave} | Créditos:{' '}
{asignatura.creditos || 'N/A'}
</p>
</div>
{/* Datos de la institución */}
<div className="space-y-1 text-sm">
<p>
<strong>Carrera:</strong> {asignatura.carrera}
</p>
<p>
<strong>Facultad:</strong> {asignatura.facultad}
</p>
<p>
<strong>Plan de estudios:</strong>{' '}
{asignatura.planNombre}
</p>
{asignatura.ciclo && (
<p>
<strong>Ciclo:</strong> {asignatura.ciclo}
</p>
)}
</div>
{/* Campos del documento */}
{estructura.campos.map((campo) => {
const valor = datosGenerales[campo.id]
if (!valor) return null
return (
<div key={campo.id} className="space-y-2">
<h3 className="text-foreground border-b pb-1 font-semibold">
{campo.nombre}
</h3>
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
{valor}
</p>
</div>
)
})}
{/* Footer */}
<div className="text-muted-foreground mt-8 border-t pt-6 text-center text-xs">
<p>
Documento generado el{' '}
{/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}
</p>
<p className="mt-1">Universidad La Salle</p>
</div>
</div>
</div>
</div>
) : documento?.estado === 'generando' ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <Loader2 className="h-10 w-10 animate-spin" />
<Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
<p className="text-muted-foreground">
Generando documento...
</p>
</div> </div>
</div> ) : pdfUrl ? (
) : ( <iframe
<div className="flex h-full items-center justify-center"> src={`${pdfUrl}#toolbar=0`}
<div className="max-w-sm text-center"> className="h-full w-full border-none"
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" /> title="Documento SEP"
<p className="text-muted-foreground mb-4">
No hay documento generado aún
</p>
{!isComplete && (
<div className="bg-warning/10 text-warning-foreground rounded-lg p-4 text-sm">
<AlertTriangle className="mr-2 inline h-4 w-4" />
Completa todos los campos obligatorios para generar el
documento
</div>
)}
</div>
</div>
)}
</Card>
</div>
{/* Info sidebar */}
<div className="space-y-4">
{/* Status */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Estado del documento
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{documento && (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Versión
</span>
<Badge variant="outline">{documento.version}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Generado
</span>
<span className="text-sm">
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Estado
</span>
<Badge
className={cn(
documento.estado === 'listo' &&
'bg-success text-success-foreground',
documento.estado === 'generando' &&
'bg-info text-info-foreground',
documento.estado === 'error' &&
'bg-destructive text-destructive-foreground',
)}
>
{documento.estado === 'listo' && 'Listo'}
{documento.estado === 'generando' && 'Generando'}
{documento.estado === 'error' && 'Error'}
</Badge>
</div>
</>
)}
</CardContent>
</Card>
{/* Completeness */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Completitud de datos
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Campos obligatorios
</span>
<span className="font-medium">
{camposCompletos.length}/{camposObligatorios.length}
</span>
</div>
<div className="bg-muted h-2 overflow-hidden rounded-full">
<div
className={cn(
'h-full transition-all duration-500',
completeness === 100 ? 'bg-success' : 'bg-accent',
)}
style={{ width: `${completeness}%` }}
/> />
</div> ) : (
<p <div className="text-muted-foreground flex h-full items-center justify-center">
className={cn( No se pudo cargar el documento.
'text-xs',
completeness === 100
? 'text-success'
: 'text-muted-foreground',
)}
>
{completeness === 100
? 'Todos los campos obligatorios están completos'
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
</p>
</div>
{/* Missing fields */}
{!isComplete && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Campos faltantes:
</p>
{camposObligatorios
.filter((c) => !datosGenerales[c.id]?.trim())
.map((campo) => (
<div
key={campo.id}
className="flex items-center gap-2 text-sm"
>
<AlertTriangle className="text-warning h-3 w-3" />
<span className="text-foreground">{campo.nombre}</span>
</div>
))}
</div> </div>
)} )}
</CardContent>
</Card> </Card>
{/* Requirements */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Requisitos SEP
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<div
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['objetivo_general']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['objetivo_general'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">
Objetivo general definido
</span>
</li>
<li className="flex items-start gap-2">
<div
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['competencias']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['competencias'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">
Competencias especificadas
</span>
</li>
<li className="flex items-start gap-2">
<div
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['evaluacion']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['evaluacion'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">
Criterios de evaluación
</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div> </div>
) )
} }
function Check({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}

View File

@@ -1,3 +1,4 @@
import { useParams } from '@tanstack/react-router'
import { format, parseISO } from 'date-fns' import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale' import { es } from 'date-fns/locale'
import { import {
@@ -53,7 +54,10 @@ const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
}, },
} }
export function HistorialTab({ asignaturaId }) { export function HistorialTab() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/historial',
})
// 1. Obtenemos los datos directamente dentro del componente // 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId) const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)

View File

@@ -1,4 +1,4 @@
import { useRouterState } from '@tanstack/react-router' import { useParams, useRouterState } from '@tanstack/react-router'
import { import {
Sparkles, Sparkles,
Send, Send,
@@ -13,17 +13,14 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import type { import type { IAMessage, IASugerencia } from '@/types/asignatura'
IAMessage,
IASugerencia,
CampoEstructura,
} from '@/types/asignatura'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useSubject } from '@/data'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// Tipos importados de tu archivo de asignatura // Tipos importados de tu archivo de asignatura
@@ -62,8 +59,7 @@ interface SelectedField {
} }
interface IAAsignaturaTabProps { interface IAAsignaturaTabProps {
campos: Array<CampoEstructura> asignatura: Record<string, any>
datosGenerales: Record<string, any>
messages: Array<IAMessage> messages: Array<IAMessage>
onSendMessage: (message: string, campoId?: string) => void onSendMessage: (message: string, campoId?: string) => void
onAcceptSuggestion: (sugerencia: IASugerencia) => void onAcceptSuggestion: (sugerencia: IASugerencia) => void
@@ -71,15 +67,18 @@ interface IAAsignaturaTabProps {
} }
export function IAAsignaturaTab({ export function IAAsignaturaTab({
campos,
datosGenerales,
messages, messages,
onSendMessage, onSendMessage,
onAcceptSuggestion, onAcceptSuggestion,
onRejectSuggestion, onRejectSuggestion,
}: IAAsignaturaTabProps) { }: IAAsignaturaTabProps) {
const routerState = useRouterState() const routerState = useRouterState()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: datosGenerales, isLoading: loadingAsig } =
useSubject(asignaturaId)
// ESTADOS PRINCIPALES (Igual que en Planes) // ESTADOS PRINCIPALES (Igual que en Planes)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([]) const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
@@ -89,25 +88,25 @@ export function IAAsignaturaTab({
// 1. Transformar datos de la asignatura para el menú // 1. Transformar datos de la asignatura para el menú
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
// Extraemos las claves directamente del objeto datosGenerales if (!datosGenerales?.datos) return []
// ["nombre", "descripcion", "perfil_de_egreso", "fines_de_aprendizaje_o_formacion"]
if (!datosGenerales.datos) return [] const estructuraProps =
return Object.keys(datosGenerales.datos).map((key) => { datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
// Buscamos si existe un nombre amigable en la estructura de campos
const estructuraCampo = campos.find((c) => c.id === key) return Object.keys(datosGenerales.datos).map((key) => {
const estructuraCampo = estructuraProps[key]
// Si existe en 'campos', usamos su nombre; si no, formateamos la clave (ej: perfil_de_egreso -> Perfil De Egreso)
const labelAmigable = const labelAmigable =
estructuraCampo?.nombre || estructuraCampo?.title ||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
return { return {
key: key, key,
label: labelAmigable, label: labelAmigable,
value: String(datosGenerales[key] || ''), value: String(datosGenerales.datos[key] || ''),
} }
}) })
}, [campos, datosGenerales]) }, [datosGenerales])
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill) // 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill)
@@ -218,7 +217,7 @@ export function IAAsignaturaTab({
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => ( {messages?.map((msg) => (
<div <div
key={msg.id} key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}

View File

@@ -1,356 +0,0 @@
import { useEffect, useState } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useSubjectEstructuras } from '@/data'
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const { data: estructuras } = useSubjectEstructuras()
const [creditosInput, setCreditosInput] = useState<string>(() => {
const c = Number(wizard.datosBasicos.creditos ?? 0)
let newC = c
console.log('antes', newC)
if (Number.isFinite(c) && c > 999) {
newC = 999
}
console.log('desp', newC)
return newC > 0 ? newC.toFixed(2) : ''
})
const [creditosFocused, setCreditosFocused] = useState(false)
useEffect(() => {
if (creditosFocused) return
const c = Number(wizard.datosBasicos.creditos ?? 0)
let newC = c
if (Number.isFinite(c) && c > 999) {
newC = 999
}
setCreditosInput(newC > 0 ? newC.toFixed(2) : '')
}, [wizard.datosBasicos.creditos, creditosFocused])
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombre">Nombre de la asignatura</Label>
<Input
id="nombre"
placeholder="Ej. Matemáticas Discretas"
maxLength={200}
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="codigo">
Código
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="codigo"
placeholder="Ej. MAT-101"
maxLength={200}
value={wizard.datosBasicos.codigo || ''}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label>
<Select
value={(wizard.datosBasicos.tipo ?? '') as string}
onValueChange={(value: string) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
},
}),
)
}
>
<SelectTrigger
id="tipo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipo
? 'text-muted-foreground font-normal italic opacity-70'
: 'font-medium not-italic',
)}
>
<SelectValue placeholder="Ej. Obligatoria" />
</SelectTrigger>
<SelectContent>
{TIPOS_MATERIA.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="creditos">Créditos</Label>
<Input
id="creditos"
type="text"
inputMode="decimal"
maxLength={6}
pattern="^\\d*(?:[.,]\\d{0,2})?$"
value={creditosInput}
onKeyDown={(e) => {
if (['-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onFocus={() => setCreditosFocused(true)}
onBlur={() => {
setCreditosFocused(false)
const raw = creditosInput.trim()
if (!raw) {
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const normalized = raw.replace(',', '.')
let asNumber = Number.parseFloat(normalized)
if (!Number.isFinite(asNumber) || asNumber <= 0) {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
// Cap to 999
if (asNumber > 999) asNumber = 999
const fixed = asNumber.toFixed(2)
setCreditosInput(fixed)
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
}))
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const nextRaw = e.target.value
if (nextRaw === '') {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
// If typed number exceeds 999, cap it immediately (prevents entering >999)
const asNumberRaw = Number.parseFloat(nextRaw.replace(',', '.'))
if (Number.isFinite(asNumberRaw) && asNumberRaw > 999) {
// show capped value to the user
const cappedStr = '999.00'
setCreditosInput(cappedStr)
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: 999,
},
}))
return
}
setCreditosInput(nextRaw)
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos:
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
},
}))
}}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 4.50"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId as string}
onValueChange={(val) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
}
>
<SelectTrigger
id="estructura"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger>
<SelectContent>
{estructuras?.map(
(
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
) => (
<SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
</div>
<div className="grid gap-1">
<Label htmlFor="horasAcademicas">
Horas Académicas
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasAcademicas"
type="number"
min={1}
max={999}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasAcademicas ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasAcademicas: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 48"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horasIndependientes">
Horas Independientes
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasIndependientes"
type="number"
min={1}
max={999}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasIndependientes ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasIndependientes: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 24"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,362 @@
import { useEffect, useState } from 'react'
import PasoSugerenciasForm from './PasoSugerenciasForm'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useSubjectEstructuras } from '@/data'
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const { data: estructuras } = useSubjectEstructuras()
const [creditosInput, setCreditosInput] = useState<string>(() => {
const c = Number(wizard.datosBasicos.creditos ?? 0)
let newC = c
console.log('antes', newC)
if (Number.isFinite(c) && c > 999) {
newC = 999
}
console.log('desp', newC)
return newC > 0 ? newC.toFixed(2) : ''
})
const [creditosFocused, setCreditosFocused] = useState(false)
useEffect(() => {
if (creditosFocused) return
const c = Number(wizard.datosBasicos.creditos ?? 0)
let newC = c
if (Number.isFinite(c) && c > 999) {
newC = 999
}
setCreditosInput(newC > 0 ? newC.toFixed(2) : '')
}, [wizard.datosBasicos.creditos, creditosFocused])
if (wizard.tipoOrigen !== 'IA_MULTIPLE') {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombre">Nombre de la asignatura</Label>
<Input
id="nombre"
placeholder="Ej. Matemáticas Discretas"
maxLength={200}
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="codigo">
Código
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="codigo"
placeholder="Ej. MAT-101"
maxLength={200}
value={wizard.datosBasicos.codigo || ''}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label>
<Select
value={(wizard.datosBasicos.tipo ?? '') as string}
onValueChange={(value: string) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
},
}),
)
}
>
<SelectTrigger
id="tipo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipo
? 'text-muted-foreground font-normal italic opacity-70'
: 'font-medium not-italic',
)}
>
<SelectValue placeholder="Ej. Obligatoria" />
</SelectTrigger>
<SelectContent>
{TIPOS_MATERIA.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="creditos">Créditos</Label>
<Input
id="creditos"
type="text"
inputMode="decimal"
maxLength={6}
pattern="^\\d*(?:[.,]\\d{0,2})?$"
value={creditosInput}
onKeyDown={(e) => {
if (['-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onFocus={() => setCreditosFocused(true)}
onBlur={() => {
setCreditosFocused(false)
const raw = creditosInput.trim()
if (!raw) {
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const normalized = raw.replace(',', '.')
let asNumber = Number.parseFloat(normalized)
if (!Number.isFinite(asNumber) || asNumber <= 0) {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
// Cap to 999
if (asNumber > 999) asNumber = 999
const fixed = asNumber.toFixed(2)
setCreditosInput(fixed)
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
}))
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const nextRaw = e.target.value
if (nextRaw === '') {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
// If typed number exceeds 999, cap it immediately (prevents entering >999)
const asNumberRaw = Number.parseFloat(nextRaw.replace(',', '.'))
if (Number.isFinite(asNumberRaw) && asNumberRaw > 999) {
// show capped value to the user
const cappedStr = '999.00'
setCreditosInput(cappedStr)
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: 999,
},
}))
return
}
setCreditosInput(nextRaw)
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos:
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
},
}))
}}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 4.50"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId as string}
onValueChange={(val) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
}
>
<SelectTrigger
id="estructura"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger>
<SelectContent>
{estructuras?.map(
(
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
) => (
<SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
</div>
<div className="grid gap-1">
<Label htmlFor="horasAcademicas">
Horas Académicas
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasAcademicas"
type="number"
min={1}
max={999}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasAcademicas ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasAcademicas: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 48"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horasIndependientes">
Horas Independientes
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasIndependientes"
type="number"
min={1}
max={999}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasIndependientes ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasIndependientes: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 24"
/>
</div>
</div>
)
}
return <PasoSugerenciasForm wizard={wizard} onChange={onChange} />
}

View File

@@ -0,0 +1,308 @@
import { RefreshCw, Sparkles, X } from 'lucide-react'
import { useState } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Dispatch, SetStateAction } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { generate_subject_suggestions, usePlan } from '@/data'
import { AIProgressLoader } from '@/features/asignaturas/nueva/AIProgressLoader'
import { cn } from '@/lib/utils'
export default function PasoSugerenciasForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
}) {
const enfoque = wizard.iaMultiple?.enfoque ?? ''
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
const isLoading = wizard.iaMultiple?.isLoading ?? false
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
const setIaMultiple = (
patch: Partial<NonNullable<NewSubjectWizardState['iaMultiple']>>,
) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: w.iaMultiple?.isLoading ?? false,
...patch,
},
}),
)
const { data: plan } = usePlan(wizard.plan_estudio_id)
const toggleAsignatura = (id: string, checked: boolean) => {
onChange((w) => ({
...w,
sugerencias: w.sugerencias.map((s) =>
s.id === id ? { ...s, selected: checked } : s,
),
}))
}
const onGenerarSugerencias = async () => {
const hadNoSugerenciasBefore = wizard.sugerencias.length === 0
const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected)
onChange((w) => ({
...w,
errorMessage: null,
sugerencias: sugerenciasConservadas,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: true,
},
}))
try {
const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 15) {
onChange((w) => ({
...w,
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 15.',
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}))
return
}
const enfoqueTrim = wizard.iaMultiple?.enfoque.trim() ?? ''
const nuevasSugerencias = await generate_subject_suggestions({
plan_estudio_id: wizard.plan_estudio_id,
enfoque: enfoqueTrim ? enfoqueTrim : undefined,
cantidad_de_sugerencias: cantidad,
sugerencias_conservadas: sugerenciasConservadas.map((s) => ({
nombre: s.nombre,
descripcion: s.descripcion,
})),
})
if (hadNoSugerenciasBefore && nuevasSugerencias.length > 0) {
setShowConservacionTooltip(true)
}
onChange(
(w): NewSubjectWizardState => ({
...w,
sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas],
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}),
)
} catch (err) {
const message =
err instanceof Error ? err.message : 'Error generando sugerencias.'
onChange(
(w): NewSubjectWizardState => ({
...w,
errorMessage: message,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}),
)
}
}
return (
<>
{/* --- BLOQUE SUPERIOR: PARÁMETROS --- */}
<div className="border-border/60 bg-muted/30 mb-4 rounded-xl border p-4">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="text-primary h-4 w-4" />
<span className="text-sm font-semibold">
Parámetros de sugerencia
</span>
</div>
<div className="flex flex-col gap-3">
<div className="w-full">
<Label className="text-muted-foreground mb-1 block text-xs">
Enfoque (opcional)
</Label>
<Textarea
placeholder="Ej. Enfocado en normativa mexicana y tecnología"
value={enfoque}
maxLength={7000}
rows={4}
onChange={(e) => setIaMultiple({ enfoque: e.target.value })}
/>
</div>
</div>
<div className="mt-3 flex w-full flex-col items-end justify-between gap-3 sm:flex-row">
<div className="w-full sm:w-44">
<Label className="text-muted-foreground mb-1 block text-xs">
Cantidad de sugerencias
</Label>
<Input
placeholder="Ej. 5"
value={cantidadDeSugerencias}
type="number"
min={1}
max={15}
step={1}
inputMode="numeric"
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e) => {
const raw = e.target.value
if (raw === '') return
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 15)
setIaMultiple({ cantidadDeSugerencias: capped })
}}
/>
</div>
<Button
type="button"
variant="outline"
className="h-9 gap-1.5"
onClick={onGenerarSugerencias}
disabled={isLoading}
>
<RefreshCw className="h-3.5 w-3.5" />
{wizard.sugerencias.length > 0
? 'Generar más sugerencias'
: 'Generar sugerencias'}
</Button>
</div>
</div>
<AIProgressLoader
isLoading={isLoading}
cantidadDeSugerencias={cantidadDeSugerencias}
/>
{/* --- HEADER LISTA --- */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-foreground text-base font-semibold">
Asignaturas sugeridas
</h3>
<p className="text-muted-foreground text-xs">
Basadas en el plan{' '}
{plan ? `${plan.nivel} en ${plan.nombre}` : '...'}
</p>
</div>
<Tooltip open={showConservacionTooltip}>
<TooltipTrigger asChild>
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
<span aria-hidden>📌</span>
{wizard.sugerencias.filter((s) => s.selected).length}{' '}
seleccionadas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm">
Al generar más sugerencias, se conservarán las asignaturas
seleccionadas.
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setShowConservacionTooltip(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</TooltipContent>
</Tooltip>
</div>
{/* --- LISTA DE ASIGNATURAS --- */}
<div className="max-h-100 space-y-1 overflow-y-auto pr-1">
{wizard.sugerencias.map((asignatura) => {
const isSelected = asignatura.selected
return (
<Label
key={asignatura.id}
aria-checked={isSelected}
className={cn(
'border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950',
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
toggleAsignatura(asignatura.id, !!checked)
}
className={cn(
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring mt-0.5 h-5 w-5 shrink-0 border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
// isSelected ? '' : 'invisible',
)}
/>
{/* Contenido de la tarjeta */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{asignatura.nombre}
</span>
{/* Badges de Tipo */}
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
asignatura.tipo === 'OBLIGATORIA'
? 'border-blue-200 bg-transparent text-blue-700 dark:border-blue-800 dark:text-blue-300'
: 'border-yellow-200 bg-transparent text-yellow-700 dark:border-yellow-800 dark:text-yellow-300',
)}
>
{asignatura.tipo}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{asignatura.creditos} cred. · {asignatura.horasAcademicas}h
acad. · {asignatura.horasIndependientes}h indep.
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{asignatura.descripcion}
</p>
</div>
</Label>
)
})}
</div>
</>
)
}

View File

@@ -4,6 +4,12 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { import {
Card, Card,
CardDescription, CardDescription,
@@ -20,6 +26,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
import { import {
FACULTADES, FACULTADES,
MATERIAS_MOCK, MATERIAS_MOCK,
@@ -29,12 +36,14 @@ import {
export function PasoDetallesPanel({ export function PasoDetallesPanel({
wizard, wizard,
onChange, onChange,
onGenerarIA: _onGenerarIA,
}: { }: {
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>> onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
onGenerarIA: () => void
}) { }) {
const { data: estructurasAsignatura } = useSubjectEstructuras()
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
if (wizard.tipoOrigen === 'MANUAL') { if (wizard.tipoOrigen === 'MANUAL') {
return ( return (
<Card> <Card>
@@ -49,7 +58,7 @@ export function PasoDetallesPanel({
) )
} }
if (wizard.tipoOrigen === 'IA') { if (wizard.tipoOrigen === 'IA_SIMPLE') {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -148,6 +157,171 @@ export function PasoDetallesPanel({
) )
} }
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
const maxCiclos = Math.max(1, plan?.numero_ciclos ?? 1)
const sugerenciasSeleccionadas = wizard.sugerencias.filter(
(s) => s.selected,
)
const patchSugerencia = (
id: string,
patch: Partial<NewSubjectWizardState['sugerencias'][number]>,
) =>
onChange((w) => ({
...w,
sugerencias: w.sugerencias.map((s) =>
s.id === id ? { ...s, ...patch } : s,
),
}))
return (
<div className="flex flex-col gap-4">
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Estructura de la asignatura
</Label>
<Select
value={wizard.datosBasicos.estructuraId ?? undefined}
onValueChange={(val) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
estructuraId: val,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una estructura" />
</SelectTrigger>
<SelectContent>
{(estructurasAsignatura ?? []).map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
<h3 className="text-foreground mx-3 mb-2 text-lg font-semibold">
Materias seleccionadas
</h3>
{sugerenciasSeleccionadas.length === 0 ? (
<div className="text-muted-foreground text-sm">
Selecciona al menos una sugerencia para configurar su descripción,
línea curricular y ciclo.
</div>
) : (
<Accordion type="multiple" className="w-full space-y-2">
{sugerenciasSeleccionadas.map((asig) => (
<AccordionItem
key={asig.id}
value={asig.id}
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
>
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
{asig.nombre}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<div className="mx-1 grid gap-3 sm:grid-cols-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Descripción
</Label>
<Textarea
value={asig.descripcion}
maxLength={7000}
rows={6}
onChange={(e) =>
patchSugerencia(asig.id, {
descripcion: e.target.value,
})
}
/>
</div>
<div className="grid content-start gap-3">
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Ciclo (opcional)
</Label>
<Input
type="number"
min={1}
max={maxCiclos}
step={1}
inputMode="numeric"
placeholder={`1-${maxCiclos}`}
value={asig.numero_ciclo ?? ''}
onKeyDown={(e) => {
if (
['.', ',', '-', 'e', 'E', '+'].includes(e.key)
) {
e.preventDefault()
}
}}
onChange={(e) => {
const raw = e.target.value
if (raw === '') {
patchSugerencia(asig.id, { numero_ciclo: null })
return
}
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(
Math.max(n >= 1 ? n : 1, 1),
maxCiclos,
)
patchSugerencia(asig.id, { numero_ciclo: capped })
}}
/>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Línea curricular (opcional)
</Label>
<Select
value={asig.linea_plan_id ?? '__none__'}
onValueChange={(val) =>
patchSugerencia(asig.id, {
linea_plan_id: val === '__none__' ? null : val,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Sin línea" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Ninguna</SelectItem>
{(lineasPlan ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') { if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">

View File

@@ -77,12 +77,93 @@ export function PasoMetodoCardGroup({
</CardTitle> </CardTitle>
<CardDescription>Generar contenido automático.</CardDescription> <CardDescription>Generar contenido automático.</CardDescription>
</CardHeader> </CardHeader>
{(wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
<CardContent className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_SIMPLE',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_SIMPLE',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('IA_SIMPLE')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Edit3 className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Una asignatura</span>
<span className="text-xs opacity-70">
Crear una asignatura con control detallado de metadatos.
</span>
</div>
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_MULTIPLE',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_MULTIPLE',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('IA_MULTIPLE')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.List className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Varias asignaturas</span>
<span className="text-xs opacity-70">
Generar varias asignaturas a partir de sugerencias de la IA.
</span>
</div>
</div>
</CardContent>
)}
</Card> </Card>
<Card <Card
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''} className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onClick={() =>
onChange((w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'OTRO' })) onChange(
(w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'CLONADO' }),
)
} }
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -93,7 +174,7 @@ export function PasoMetodoCardGroup({
</CardTitle> </CardTitle>
<CardDescription>De otra asignatura o archivo Word.</CardDescription> <CardDescription>De otra asignatura o archivo Word.</CardDescription>
</CardHeader> </CardHeader>
{(wizard.tipoOrigen === 'OTRO' || {(wizard.tipoOrigen === 'CLONADO' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' || wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && ( wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">

View File

@@ -9,12 +9,13 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { usePlan, useSubjectEstructuras } from '@/data' import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
import { formatFileSize } from '@/features/planes/utils/format-file-size' import { formatFileSize } from '@/features/planes/utils/format-file-size'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) { export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const { data: plan } = usePlan(wizard.plan_estudio_id) const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: estructuras } = useSubjectEstructuras() const { data: estructuras } = useSubjectEstructuras()
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
const estructuraNombre = (() => { const estructuraNombre = (() => {
const estructuraId = wizard.datosBasicos.estructuraId const estructuraId = wizard.datosBasicos.estructuraId
@@ -26,6 +27,8 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const modoLabel = (() => { const modoLabel = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)' if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)'
if (wizard.tipoOrigen === 'IA') return 'Generada con IA' if (wizard.tipoOrigen === 'IA') return 'Generada con IA'
if (wizard.tipoOrigen === 'IA_SIMPLE') return 'Generada con IA (Simple)'
if (wizard.tipoOrigen === 'IA_MULTIPLE') return 'Generación múltiple (IA)'
if (wizard.tipoOrigen === 'CLONADO_INTERNO') return 'Clonada (Sistema)' if (wizard.tipoOrigen === 'CLONADO_INTERNO') return 'Clonada (Sistema)'
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') return 'Clonada (Archivo)' if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') return 'Clonada (Archivo)'
return '—' return '—'
@@ -41,6 +44,10 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? [] const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? [] const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
const materiasSeleccionadas = wizard.sugerencias.filter((s) => s.selected)
const iaMultipleEnfoque = wizard.iaMultiple?.enfoque.trim() ?? ''
const iaMultipleCantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -72,7 +79,9 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
{wizard.tipoOrigen === 'MANUAL' && ( {wizard.tipoOrigen === 'MANUAL' && (
<Icons.Pencil className="h-4 w-4" /> <Icons.Pencil className="h-4 w-4" />
)} )}
{wizard.tipoOrigen === 'IA' && ( {(wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
<Icons.Sparkles className="h-4 w-4" /> <Icons.Sparkles className="h-4 w-4" />
)} )}
{(wizard.tipoOrigen === 'CLONADO_INTERNO' || {(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
@@ -83,6 +92,88 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
</span> </span>
</div> </div>
{wizard.tipoOrigen === 'IA_MULTIPLE' ? (
<>
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<div className="text-foreground text-base font-semibold">
Configuración
</div>
<div className="text-muted-foreground text-xs">
Se crearán {materiasSeleccionadas.length} asignatura(s) a
partir de tus selecciones.
</div>
</div>
<div className="bg-background/40 border-border/60 rounded-lg border p-3">
<div className="text-muted-foreground text-xs">
Estructura
</div>
<div className="text-foreground mt-1 text-sm font-medium">
{estructuraNombre}
</div>
</div>
</div>
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
<div className="flex items-end justify-between gap-2">
<div className="text-foreground text-base font-semibold">
Materias seleccionadas
</div>
<div className="text-muted-foreground text-xs">
{materiasSeleccionadas.length} en total
</div>
</div>
{materiasSeleccionadas.length === 0 ? (
<div className="text-muted-foreground text-sm">
No hay materias seleccionadas.
</div>
) : (
<div className="grid gap-3">
{materiasSeleccionadas.map((m) => {
const lineaNombre = m.linea_plan_id
? (lineasPlan?.find((l) => l.id === m.linea_plan_id)
?.nombre ?? m.linea_plan_id)
: '—'
const cicloText =
typeof m.numero_ciclo === 'number' &&
Number.isFinite(m.numero_ciclo)
? String(m.numero_ciclo)
: '—'
return (
<div
key={m.id}
className="bg-background/40 border-border/60 grid gap-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-foreground text-sm font-semibold">
{m.nombre}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
<span className="bg-accent/30 text-accent-foreground rounded-full px-2 py-0.5">
Línea: {lineaNombre}
</span>
<span className="bg-accent/30 text-accent-foreground rounded-full px-2 py-0.5">
Ciclo: {cicloText}
</span>
</div>
</div>
<div className="text-muted-foreground text-sm whitespace-pre-wrap">
{m.descripcion || '—'}
</div>
</div>
)
})}
</div>
)}
</div>
</>
) : (
<>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<span className="text-muted-foreground">Nombre: </span> <span className="text-muted-foreground">Nombre: </span>
@@ -111,7 +202,9 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
<span className="font-medium">{estructuraNombre}</span> <span className="font-medium">{estructuraNombre}</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Horas académicas: </span> <span className="text-muted-foreground">
Horas académicas:{' '}
</span>
<span className="font-medium"> <span className="font-medium">
{wizard.datosBasicos.horasAcademicas ?? '—'} {wizard.datosBasicos.horasAcademicas ?? '—'}
</span> </span>
@@ -160,7 +253,9 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
</div> </div>
<div> <div>
<div className="font-medium">Repositorios de referencia</div> <div className="font-medium">
Repositorios de referencia
</div>
{repositoriosRef.length ? ( {repositoriosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs"> <ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => ( {repositoriosRef.map((id) => (
@@ -178,7 +273,9 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
<ul className="text-muted-foreground list-disc pl-5 text-xs"> <ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => ( {adjuntos.map((f) => (
<li key={f.id}> <li key={f.id}>
<span className="text-foreground">{f.file.name}</span>{' '} <span className="text-foreground">
{f.file.name}
</span>{' '}
<span>· {formatFileSize(f.file.size)}</span> <span>· {formatFileSize(f.file.size)}</span>
</li> </li>
))} ))}
@@ -189,6 +286,8 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
</div> </div>
</div> </div>
</div> </div>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,10 +1,21 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AIGenerateSubjectInput } from '@/data' import type { AISubjectUnifiedInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useGenerateSubjectAI } from '@/data' import {
supabaseBrowser,
useGenerateSubjectAI,
qk,
useCreateSubjectManual,
subjects_get_maybe,
} from '@/data'
export function WizardControls({ export function WizardControls({
wizard, wizard,
@@ -28,7 +39,158 @@ export function WizardControls({
isLastStep: boolean isLastStep: boolean
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient()
const generateSubjectAI = useGenerateSubjectAI() const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false)
const cancelledRef = useRef(false)
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
const watchSubjectIdRef = useRef<string | null>(null)
const watchTimeoutRef = useRef<number | null>(null)
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const stopSubjectWatch = useCallback(() => {
if (watchTimeoutRef.current) {
window.clearTimeout(watchTimeoutRef.current)
watchTimeoutRef.current = null
}
watchSubjectIdRef.current = null
const ch = realtimeChannelRef.current
if (ch) {
realtimeChannelRef.current = null
try {
supabaseBrowser().removeChannel(ch)
} catch {
// noop
}
}
}, [])
useEffect(() => {
return () => {
stopSubjectWatch()
}
}, [stopSubjectWatch])
const handleSubjectReady = (args: {
id: string
plan_estudio_id: string
estado?: unknown
}) => {
if (cancelledRef.current) return
const estado = String(args.estado ?? '').toLowerCase()
if (estado === 'generando') return
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
state: { showConfetti: true },
})
}
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
stopSubjectWatch()
watchSubjectIdRef.current = args.subjectId
// Timeout de seguridad (mismo límite que teníamos con polling)
watchTimeoutRef.current = window.setTimeout(
() => {
if (cancelledRef.current) return
if (watchSubjectIdRef.current !== args.subjectId) return
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
}))
},
6 * 60 * 1000,
)
const supabase = supabaseBrowser()
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
realtimeChannelRef.current = channel
channel.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'asignaturas',
filter: `id=eq.${args.subjectId}`,
},
(payload) => {
if (cancelledRef.current) return
const next: any = (payload as any)?.new
if (!next?.id || !next?.plan_estudio_id) return
handleSubjectReady({
id: String(next.id),
plan_estudio_id: String(next.plan_estudio_id),
estado: next.estado,
})
},
)
channel.subscribe((status) => {
if (cancelledRef.current) return
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
}))
}
})
}
const uploadAiAttachments = async (args: {
planId: string
files: Array<{ file: File }>
}): Promise<Array<string>> => {
const supabase = supabaseBrowser()
if (!args.files.length) return []
const runId = crypto.randomUUID()
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
const keys: Array<string> = []
for (const f of args.files) {
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
const { error } = await supabase.storage
.from('ai-storage')
.upload(key, f.file, {
contentType: f.file.type || undefined,
})
if (error) throw new Error(error.message)
keys.push(key)
}
return keys
}
const handleCreate = async () => { const handleCreate = async () => {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
@@ -36,57 +198,245 @@ export function WizardControls({
errorMessage: null, errorMessage: null,
})) }))
let startedWaiting = false
try { try {
if (wizard.tipoOrigen === 'IA') { if (wizard.tipoOrigen === 'IA_SIMPLE') {
const aiInput: AIGenerateSubjectInput = { if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.datosBasicos.estructuraId) {
throw new Error('Estructura inválida.')
}
if (!wizard.datosBasicos.nombre.trim()) {
throw new Error('Nombre inválido.')
}
if (wizard.datosBasicos.creditos == null) {
throw new Error('Créditos inválidos.')
}
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
const supabase = supabaseBrowser()
const placeholder: TablesInsert<'asignaturas'> = {
plan_estudio_id: wizard.plan_estudio_id, plan_estudio_id: wizard.plan_estudio_id,
datosBasicos: { estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre, nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo, codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo!, tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos!, creditos: wizard.datosBasicos.creditos,
horasIndependientes: wizard.datosBasicos.horasIndependientes, horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horasAcademicas: wizard.datosBasicos.horasAcademicas, horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
estructuraId: wizard.datosBasicos.estructuraId!, estado: 'generando',
tipo_origen: 'IA',
}
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholder)
.select('id,plan_estudio_id')
.single()
if (insertError) throw new Error(insertError.message)
const subjectId = inserted.id
setIsSpinningIA(true)
// Inicia watch realtime antes de disparar la Edge para no perder updates.
startedWaiting = true
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id: subjectId,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? null,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes:
wizard.datosBasicos.horasIndependientes ?? null,
}, },
iaConfig: { iaConfig: {
descripcionEnfoqueAcademico: descripcionEnfoqueAcademico:
wizard.iaConfig!.descripcionEnfoqueAcademico, wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
instruccionesAdicionalesIA: instruccionesAdicionalesIA:
wizard.iaConfig!.instruccionesAdicionalesIA, wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosReferencia: wizard.iaConfig!.archivosReferencia, archivosAdjuntos,
repositoriosReferencia:
wizard.iaConfig!.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
}, },
} }
console.log( console.log(
`${new Date().toISOString()} - Enviando a generar asignatura con IA`, `${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
) )
const asignatura = await generateSubjectAI.mutateAsync(aiInput) await generateSubjectAI.mutateAsync(payload as any)
console.log(
`${new Date().toISOString()} - Asignatura IA generada`, // Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
asignatura, const latest = await subjects_get_maybe(subjectId)
if (latest) {
handleSubjectReady({
id: latest.id as any,
plan_estudio_id: latest.plan_estudio_id as any,
estado: (latest as any).estado,
})
}
return
}
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
const selected = wizard.sugerencias.filter((s) => s.selected)
if (selected.length === 0) {
throw new Error('Selecciona al menos una sugerencia.')
}
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.estructuraId) {
throw new Error('Selecciona una estructura para continuar.')
}
const supabase = supabaseBrowser()
setIsSpinningIA(true)
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
(s): TablesInsert<'asignaturas'> => ({
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.estructuraId,
estado: 'generando',
nombre: s.nombre,
codigo: s.codigo ?? null,
tipo: s.tipo ?? undefined,
creditos: s.creditos ?? 0,
horas_academicas: s.horasAcademicas ?? null,
horas_independientes: s.horasIndependientes ?? null,
linea_plan_id: s.linea_plan_id ?? null,
numero_ciclo: s.numero_ciclo ?? null,
}),
) )
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholders)
.select('id')
if (insertError) {
throw new Error(insertError.message)
}
const insertedIds = inserted.map((r) => r.id)
if (insertedIds.length !== selected.length) {
throw new Error('No se pudieron crear todas las asignaturas.')
}
// Disparar generación en paralelo (no bloquear navegación)
insertedIds.forEach((id, idx) => {
const s = selected[idx]
const creditosForEdge =
typeof s.creditos === 'number' && s.creditos > 0
? s.creditos
: undefined
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.estructuraId ?? undefined,
nombre: s.nombre,
codigo: s.codigo ?? null,
tipo: s.tipo ?? null,
creditos: creditosForEdge,
horas_academicas: s.horasAcademicas ?? null,
horas_independientes: s.horasIndependientes ?? null,
numero_ciclo: s.numero_ciclo ?? null,
linea_plan_id: s.linea_plan_id ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico: s.descripcion,
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosAdjuntos,
},
}
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
console.error('Error generando asignatura IA (multiple):', e)
})
})
// Invalidar la query del listado del plan (una vez) para que la lista
// muestre el estado actualizado y recargue cuando lleguen updates.
qc.invalidateQueries({
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
})
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas`,
resetScroll: false,
})
setIsSpinningIA(false)
return
}
if (wizard.tipoOrigen === 'MANUAL') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
const asignatura = await createSubjectManual.mutateAsync({
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId!,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos ?? 0,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
linea_plan_id: null,
numero_ciclo: null,
})
navigate({ navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`, to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true }, state: { showConfetti: true },
resetScroll: false,
}) })
return
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false)
stopSubjectWatch()
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura', errorMessage: err?.message ?? 'Error creando la asignatura',
})) }))
} finally { } finally {
if (!startedWaiting) {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false })) setWizard((w) => ({ ...w, isLoading: false }))
} }
} }
}
return ( return (
<div className="flex grow items-center justify-between"> <div className="flex grow items-center justify-between">
@@ -94,7 +444,7 @@ export function WizardControls({
Anterior Anterior
</Button> </Button>
<div className="flex-1"> <div className="mx-2 flex-1">
{(errorMessage ?? wizard.errorMessage) && ( {(errorMessage ?? wizard.errorMessage) && (
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
{errorMessage ?? wizard.errorMessage} {errorMessage ?? wizard.errorMessage}
@@ -102,6 +452,17 @@ export function WizardControls({
)} )}
</div> </div>
<div className="mx-2 flex w-5 items-center justify-center">
<Loader2
className={
wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA
? 'text-muted-foreground h-6 w-6 animate-spin'
: 'h-6 w-6 opacity-0'
}
aria-hidden={!(wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA)}
/>
</div>
{isLastStep ? ( {isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}> <Button onClick={handleCreate} disabled={disableCreate}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'} {wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}

View File

@@ -1,18 +1,44 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput' import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton' import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function ExternalLoginForm() { export function ExternalLoginForm() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => { const submit = async () => {
/* await supabase.auth.signInWithPassword({ setIsLoading(true)
setError(null)
try {
const { error } = await supabase.auth.signInWithPassword({
email, email,
password, password,
})*/ })
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
} }
return ( return (
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
value={password} value={password}
onChange={setPassword} onChange={setPassword}
/> />
<SubmitButton /> {error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form> </form>
) )
} }

View File

@@ -1,18 +1,45 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput' import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton' import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function InternalLoginForm() { export function InternalLoginForm() {
const [clave, setClave] = useState('') const [clave, setClave] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => { const submit = async () => {
/* await supabase.auth.signInWithPassword({ setIsLoading(true)
email: `${clave}@ulsa.mx`, setError(null)
try {
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
const { error } = await supabase.auth.signInWithPassword({
email,
password, password,
})*/ })
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
} }
return ( return (
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
value={password} value={password}
onChange={setPassword} onChange={setPassword}
/> />
<SubmitButton /> {error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form> </form>
) )
} }

View File

@@ -0,0 +1,122 @@
import { Check, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
export const ImprovementCard = ({
suggestions,
onApply,
planId,
currentDatos,
activeChatId,
onApplySuccess,
}: {
suggestions: Array<any>
onApply?: (key: string, value: string) => void
planId: string
currentDatos: any
activeChatId: any
onApplySuccess?: (key: string) => void
}) => {
const [localApplied, setLocalApplied] = useState<Array<string>>([])
const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const handleApply = (key: string, newValue: string) => {
if (!currentDatos) return
const currentValue = currentDatos[key]
let finalValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
finalValue = { ...currentValue, description: newValue }
} else {
finalValue = newValue
}
const datosActualizados = {
...currentDatos,
[key]: finalValue,
}
updatePlan.mutate(
{
planId: planId as any,
patch: { datos: datosActualizados },
},
{
onSuccess: () => {
setLocalApplied((prev) => [...prev, key])
if (onApplySuccess) onApplySuccess(key)
if (activeChatId) {
updateAppliedStatus.mutate({
conversacionId: activeChatId,
campoAfectado: key,
})
}
if (onApply) onApply(key, newValue)
},
},
)
}
return (
<div className="mt-2 flex w-full flex-col gap-4">
{suggestions.map((sug) => {
const isApplied = sug.applied === true || localApplied.includes(sug.key)
const isUpdating =
updatePlan.isPending &&
updatePlan.variables.patch.datos?.[sug.key] !== undefined
return (
<div
key={sug.key}
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
<Button
size="sm"
onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied || !!isUpdating}
className={`h-8 rounded-full px-4 text-xs transition-all ${
isApplied
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
}`}
>
{isUpdating ? (
<Loader2 size={12} className="animate-spin" />
) : isApplied ? (
<span className="flex items-center gap-1">
<Check size={12} /> Aplicado
</span>
) : (
'Aplicar mejora'
)}
</Button>
</div>
<div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied
? 'border-teal-100 bg-teal-50/50 text-slate-700'
: 'border-slate-200 bg-slate-50 text-slate-500'
}`}
>
{sug.newValue}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,13 +1,21 @@
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AIGeneratePlanInput } from '@/data' import type { AIGeneratePlanInput } from '@/data'
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain' import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
// import type { Database } from '@/types/supabase' // import type { Database } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
// import { supabaseBrowser } from '@/data' import { plans_get_maybe } from '@/data/api/plans.api'
import { useCreatePlanManual, useGeneratePlanAI } from '@/data/hooks/usePlans' import {
useCreatePlanManual,
useDeletePlanEstudio,
useGeneratePlanAI,
} from '@/data/hooks/usePlans'
import { supabaseBrowser } from '@/data/supabase/client'
export function WizardControls({ export function WizardControls({
errorMessage, errorMessage,
@@ -33,8 +41,152 @@ export function WizardControls({
const navigate = useNavigate() const navigate = useNavigate()
const generatePlanAI = useGeneratePlanAI() const generatePlanAI = useGeneratePlanAI()
const createPlanManual = useCreatePlanManual() const createPlanManual = useCreatePlanManual()
// const supabaseClient = supabaseBrowser() const deletePlan = useDeletePlanEstudio()
// const persistPlanFromAI = usePersistPlanFromAI() const [isSpinningIA, setIsSpinningIA] = useState(false)
const cancelledRef = useRef(false)
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
const watchPlanIdRef = useRef<string | null>(null)
const watchTimeoutRef = useRef<number | null>(null)
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const stopPlanWatch = useCallback(() => {
if (watchTimeoutRef.current) {
window.clearTimeout(watchTimeoutRef.current)
watchTimeoutRef.current = null
}
watchPlanIdRef.current = null
const ch = realtimeChannelRef.current
if (ch) {
realtimeChannelRef.current = null
try {
supabaseBrowser().removeChannel(ch)
} catch {
// noop
}
}
}, [])
useEffect(() => {
return () => {
stopPlanWatch()
}
}, [stopPlanWatch])
const checkPlanStateAndAct = useCallback(
async (planId: string) => {
if (cancelledRef.current) return
if (watchPlanIdRef.current !== planId) return
const plan = await plans_get_maybe(planId as any)
if (!plan) return
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
if (clave.startsWith('GENERANDO')) return
if (clave.startsWith('BORRADOR')) {
stopPlanWatch()
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${plan.id}`,
state: { showConfetti: true },
})
return
}
if (clave.startsWith('FALLID')) {
stopPlanWatch()
setIsSpinningIA(false)
deletePlan
.mutateAsync(plan.id)
.catch(() => {
// Si falla el borrado, igual mostramos el error.
})
.finally(() => {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: 'La generación del plan falló',
}))
})
}
},
[deletePlan, navigate, setWizard, stopPlanWatch],
)
const beginPlanWatch = useCallback(
(planId: string) => {
stopPlanWatch()
watchPlanIdRef.current = planId
watchTimeoutRef.current = window.setTimeout(
() => {
if (cancelledRef.current) return
if (watchPlanIdRef.current !== planId) return
stopPlanWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
}))
},
6 * 60 * 1000,
)
const supabase = supabaseBrowser()
const channel = supabase.channel(`planes-status-${planId}`)
realtimeChannelRef.current = channel
channel.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'planes_estudio',
filter: `id=eq.${planId}`,
},
() => {
void checkPlanStateAndAct(planId)
},
)
channel.subscribe((status) => {
const st = status as
| 'SUBSCRIBED'
| 'TIMED_OUT'
| 'CLOSED'
| 'CHANNEL_ERROR'
if (cancelledRef.current) return
if (st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
stopPlanWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'No se pudo suscribir al estado del plan. Intenta de nuevo.',
}))
}
})
// Fallback inmediato por si el plan ya cambió antes de suscribir.
void checkPlanStateAndAct(planId)
},
[checkPlanStateAndAct, setWizard, stopPlanWatch],
)
const handleCreate = async () => { const handleCreate = async () => {
// Start loading // Start loading
@@ -78,13 +230,17 @@ export function WizardControls({
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`) console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
const plan = await generatePlanAI.mutateAsync(aiInput as any) setIsSpinningIA(true)
console.log(`${new Date().toISOString()} - Plan IA generado`, plan) const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
const planId = resp?.plan?.id ?? resp?.id
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
navigate({ if (!planId) {
to: `/planes/${plan.id}`, throw new Error('No se pudo obtener el id del plan generado por IA')
state: { showConfetti: true }, }
})
// Inicia realtime; los efectos navegan o marcan error.
beginPlanWatch(String(planId))
return return
} }
@@ -108,13 +264,15 @@ export function WizardControls({
return return
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false)
stopPlanWatch()
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan', errorMessage: err?.message ?? 'Error generando el plan',
})) }))
} finally { } finally {
setWizard((w) => ({ ...w, isLoading: false })) // Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
} }
} }
@@ -130,6 +288,17 @@ export function WizardControls({
</span> </span>
)} )}
</div> </div>
<div className="mx-2 flex w-5 items-center justify-center">
<Loader2
className={
wizard.tipoOrigen === 'IA' && isSpinningIA
? 'text-muted-foreground h-6 w-6 animate-spin'
: 'h-6 w-6 opacity-0'
}
aria-hidden={!(wizard.tipoOrigen === 'IA' && isSpinningIA)}
/>
</div>
{isLastStep ? ( {isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}> <Button onClick={handleCreate} disabled={disableCreate}>
Crear plan Crear plan

View File

@@ -1,13 +1,14 @@
interface Props { interface Props {
text?: string text?: string
disabled?: boolean
} }
export function SubmitButton({ text = 'Iniciar sesión' }: Props) { export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
return ( return (
<button <button
type="submit" type="submit"
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg disabled={disabled}
font-semibold hover:opacity-90 transition" className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
> >
{text} {text}
</button> </button>

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -4,9 +4,11 @@ import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
export function WizardResponsiveHeader({ export function WizardResponsiveHeader({
wizard, wizard,
methods, methods,
titleOverrides,
}: { }: {
wizard: any wizard: any
methods: any methods: any
titleOverrides?: Record<string, string>
}) { }) {
const idx = wizard.utils.getIndex(methods.current.id) const idx = wizard.utils.getIndex(methods.current.id)
const totalSteps = wizard.steps.length const totalSteps = wizard.steps.length
@@ -14,6 +16,8 @@ export function WizardResponsiveHeader({
const hasNextStep = idx < totalSteps - 1 const hasNextStep = idx < totalSteps - 1
const nextStep = wizard.steps[currentIndex] const nextStep = wizard.steps[currentIndex]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
return ( return (
<> <>
<div className="block sm:hidden"> <div className="block sm:hidden">
@@ -22,13 +26,13 @@ export function WizardResponsiveHeader({
<div className="flex flex-col justify-center"> <div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900"> <h2 className="text-lg font-bold text-slate-900">
<StepWithTooltip <StepWithTooltip
title={methods.current.title} title={resolveTitle(methods.current)}
desc={methods.current.description} desc={methods.current.description}
/> />
</h2> </h2>
{hasNextStep && nextStep ? ( {hasNextStep && nextStep ? (
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Siguiente: {nextStep.title} Siguiente: {resolveTitle(nextStep)}
</p> </p>
) : ( ) : (
<p className="text-sm font-medium text-green-500"> <p className="text-sm font-medium text-green-500">
@@ -48,7 +52,10 @@ export function WizardResponsiveHeader({
className="whitespace-nowrap" className="whitespace-nowrap"
> >
<wizard.Stepper.Title> <wizard.Stepper.Title>
<StepWithTooltip title={step.title} desc={step.description} /> <StepWithTooltip
title={resolveTitle(step)}
desc={step.description}
/>
</wizard.Stepper.Title> </wizard.Stepper.Title>
</wizard.Stepper.Step> </wizard.Stepper.Step>
))} ))}

View File

@@ -1,81 +1,238 @@
import { invokeEdge } from "../supabase/invokeEdge"; import { supabaseBrowser } from '../supabase/client'
import type { InteraccionIA, UUID } from "../types/domain"; import { invokeEdge } from '../supabase/invokeEdge'
import type { InteraccionIA, UUID } from '../types/domain'
const EDGE = { const EDGE = {
ai_plan_improve: "ai_plan_improve", ai_plan_improve: 'ai_plan_improve',
ai_plan_chat: "ai_plan_chat", ai_plan_chat: 'ai_plan_chat',
ai_subject_improve: "ai_subject_improve", ai_subject_improve: 'ai_subject_improve',
ai_subject_chat: "ai_subject_chat", ai_subject_chat: 'ai_subject_chat',
library_search: "library_search", library_search: 'library_search',
} as const; } as const
export async function ai_plan_improve(payload: { export async function ai_plan_improve(payload: {
planId: UUID; planId: UUID
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna sectionKey: string // ej: "perfil_de_egreso" o tu key interna
prompt: string; prompt: string
context?: Record<string, any>; context?: Record<string, any>
fuentes?: { fuentes?: {
archivosIds?: UUID[]; archivosIds?: Array<UUID>
vectorStoresIds?: UUID[]; vectorStoresIds?: Array<UUID>
usarMCP?: boolean; usarMCP?: boolean
conversacionId?: string; conversacionId?: string
}; }
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> { }): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload); return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
EDGE.ai_plan_improve,
payload,
)
} }
export async function ai_plan_chat(payload: { export async function ai_plan_chat(payload: {
planId: UUID; planId: UUID
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
fuentes?: { fuentes?: {
archivosIds?: UUID[]; archivosIds?: Array<UUID>
vectorStoresIds?: UUID[]; vectorStoresIds?: Array<UUID>
usarMCP?: boolean; usarMCP?: boolean
conversacionId?: string; conversacionId?: string
}; }
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> { }): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload); return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
EDGE.ai_plan_chat,
payload,
)
} }
export async function ai_subject_improve(payload: { export async function ai_subject_improve(payload: {
subjectId: UUID; subjectId: UUID
sectionKey: string; sectionKey: string
prompt: string; prompt: string
context?: Record<string, any>; context?: Record<string, any>
fuentes?: { fuentes?: {
archivosIds?: UUID[]; archivosIds?: Array<UUID>
vectorStoresIds?: UUID[]; vectorStoresIds?: Array<UUID>
usarMCP?: boolean; usarMCP?: boolean
conversacionId?: string; conversacionId?: string
}; }
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> { }): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload); return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
EDGE.ai_subject_improve,
payload,
)
} }
export async function ai_subject_chat(payload: { export async function ai_subject_chat(payload: {
subjectId: UUID; subjectId: UUID
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
fuentes?: { fuentes?: {
archivosIds?: UUID[]; archivosIds?: Array<UUID>
vectorStoresIds?: UUID[]; vectorStoresIds?: Array<UUID>
usarMCP?: boolean; usarMCP?: boolean
conversacionId?: string; conversacionId?: string
}; }
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> { }): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload); return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
EDGE.ai_subject_chat,
payload,
)
} }
/** Biblioteca (Edge; adapta a tu API real) */ /** Biblioteca (Edge; adapta a tu API real) */
export type LibraryItem = { export type LibraryItem = {
id: string; id: string
titulo: string; titulo: string
autor?: string; autor?: string
isbn?: string; isbn?: string
citaSugerida?: string; citaSugerida?: string
disponibilidad?: string; disponibilidad?: string
}; }
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> { export async function library_search(payload: {
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload); query: string
limit?: number
}): Promise<Array<LibraryItem>> {
return invokeEdge<Array<LibraryItem>>(EDGE.library_search, payload)
}
export async function create_conversation(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations',
{
method: 'POST',
body: {
plan_estudio_id: planId, // O el nombre que confirmamos que funciona
instanciador: 'alex',
},
},
)
if (error) throw error
return data
}
export async function get_chat_history(conversacionId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${conversacionId}/messages`,
{ method: 'GET' },
)
if (error) throw error
return data // Retorna Array de mensajes
}
export async function update_conversation_status(
conversacionId: string,
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan') // Asegúrate que el nombre de la tabla sea exacto
.update({ estado: nuevoEstado })
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}
// Modificamos la función de chat para que use la ruta de mensajes
export async function ai_plan_chat_v2(payload: {
conversacionId: string
content: string
campos?: Array<string>
}): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
{
method: 'POST',
body: {
content: payload.content,
campos: payload.campos || [],
},
},
)
if (error) throw error
return data
}
export async function getConversationByPlan(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan')
.select('*')
.eq('plan_estudio_id', planId)
.order('creado_en', { ascending: false })
if (error) throw error
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? []
}
export async function update_conversation_title(
conversacionId: string,
nuevoTitulo: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan')
.update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}
export async function update_recommendation_applied_status(
conversacionId: string,
campoAfectado: string,
) {
const supabase = supabaseBrowser()
// 1. Obtener el estado actual del JSON
const { data: conv, error: fetchError } = await supabase
.from('conversaciones_plan')
.select('conversacion_json')
.eq('id', conversacionId)
.single()
if (fetchError) throw fetchError
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
// Usamos una transformación inmutable para evitar efectos secundarios
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
return {
...msg,
recommendations: msg.recommendations.map((rec: any) =>
rec.campo_afectado === campoAfectado
? { ...rec, aplicada: true }
: rec,
),
}
}
return msg
})
// 3. Actualizar la base de datos con el nuevo JSON
const { data, error: updateError } = await supabase
.from('conversaciones_plan')
.update({ conversacion_json: nuevoJson })
.eq('id', conversacionId)
.select()
.single()
if (updateError) throw updateError
return data
} }

View File

@@ -144,6 +144,48 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
return requireData(data, 'Plan no encontrado.') return requireData(data, 'Plan no encontrado.')
} }
/**
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
* Útil para flujos de polling donde el plan puede tardar en aparecer.
*/
export async function plans_get_maybe(
planId: UUID,
): Promise<PlanEstudio | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.eq('id', planId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as PlanEstudio | null
}
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.delete()
.eq('id', planId)
.select('id')
.maybeSingle()
throwIfError(error)
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
return { id: ((data as any)?.id ?? planId) as UUID }
}
export async function plan_lineas_list( export async function plan_lineas_list(
planId: UUID, planId: UUID,
): Promise<Array<LineaPlan>> { ): Promise<Array<LineaPlan>> {
@@ -165,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,horas_academicas, horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', 'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -176,18 +218,31 @@ export async function plan_asignaturas_list(
return data ?? [] return data ?? []
} }
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> { export async function plans_history(
planId: UUID,
page: number = 0,
pageSize: number = 4,
): Promise<{ data: Array<CambioPlan>; count: number }> {
// Cambiamos el retorno
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const from = page * pageSize
const to = from + pageSize - 1
const { data, error, count } = await supabase
.from('cambios_plan') .from('cambios_plan')
.select( .select(
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo', 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
{ count: 'exact' }, // <--- Pedimos el conteo exacto
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('cambiado_en', { ascending: false }) .order('cambiado_en', { ascending: false })
.range(from, to)
throwIfError(error) throwIfError(error)
return data ?? [] return {
data: data ?? [],
count: count ?? 0,
}
} }
/** Wizard: crear plan manual (Edge Function) */ /** Wizard: crear plan manual (Edge Function) */
@@ -291,7 +346,7 @@ export async function ai_generate_plan(
archivosAdjuntos: undefined, // los manejamos aparte archivosAdjuntos: undefined, // los manejamos aparte
}), }),
) )
input.iaConfig.archivosAdjuntos.forEach((file, index) => { input.iaConfig.archivosAdjuntos.forEach((file) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file) edgeFunctionBody.append(`archivosAdjuntos`, file.file)
}) })

View File

@@ -7,14 +7,22 @@ import type { DocumentoResult } from './plans.api'
import type { import type {
Asignatura, Asignatura,
BibliografiaAsignatura, BibliografiaAsignatura,
CarreraRow,
CambioAsignatura, CambioAsignatura,
EstructuraAsignatura,
FacultadRow,
PlanEstudioRow,
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone' import type {
import type { Database } from '@/types/supabase' AsignaturaSugerida,
DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types'
import type { Database, TablesInsert } from '@/types/supabase'
const EDGE = { const EDGE = {
generate_subject_suggestions: 'generate-subject-suggestions',
subjects_create_manual: 'subjects_create_manual', subjects_create_manual: 'subjects_create_manual',
ai_generate_subject: 'ai-generate-subject', ai_generate_subject: 'ai-generate-subject',
subjects_persist_from_ai: 'subjects_persist_from_ai', subjects_persist_from_ai: 'subjects_persist_from_ai',
@@ -22,21 +30,89 @@ const EDGE = {
subjects_import_from_file: 'subjects_import_from_file', subjects_import_from_file: 'subjects_import_from_file',
subjects_update_fields: 'subjects_update_fields', subjects_update_fields: 'subjects_update_fields',
subjects_update_contenido: 'subjects_update_contenido',
subjects_update_bibliografia: 'subjects_update_bibliografia', subjects_update_bibliografia: 'subjects_update_bibliografia',
subjects_generate_document: 'subjects_generate_document', subjects_generate_document: 'subjects_generate_document',
subjects_get_document: 'subjects_get_document', subjects_get_document: 'subjects_get_document',
} as const } as const
export async function subjects_get(subjectId: UUID): Promise<Asignatura> { export type ContenidoTemaApi =
| string
| {
nombre: string
horasEstimadas?: number
descripcion?: string
[key: string]: unknown
}
/**
* Estructura persistida en `asignaturas.contenido_tematico`.
* La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos).
*/
export type ContenidoApi = {
unidad: number
titulo: string
temas: Array<ContenidoTemaApi>
[key: string]: unknown
}
export type FacultadInSubject = Pick<
FacultadRow,
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
>
export type CarreraInSubject = Pick<
CarreraRow,
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
> & {
facultades: FacultadInSubject | null
}
export type PlanEstudioInSubject = Pick<
PlanEstudioRow,
| 'id'
| 'carrera_id'
| 'estructura_id'
| 'nombre'
| 'nivel'
| 'tipo_ciclo'
| 'numero_ciclos'
| 'datos'
| 'estado_actual_id'
| 'activo'
| 'tipo_origen'
| 'meta_origen'
| 'creado_por'
| 'actualizado_por'
| 'creado_en'
| 'actualizado_en'
> & {
carreras: CarreraInSubject | null
}
export type EstructuraAsignaturaInSubject = Pick<
EstructuraAsignatura,
'id' | 'nombre' | 'version' | 'definicion'
>
/**
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
*/
export type AsignaturaDetail = Omit<Asignatura, 'contenido_tematico'> & {
contenido_tematico: Array<ContenidoApi> | null
planes_estudio: PlanEstudioInSubject | null
estructuras_asignatura: EstructuraAsignaturaInSubject | null
}
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
planes_estudio( planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
@@ -48,7 +124,10 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
.single() .single()
throwIfError(error) throwIfError(error)
return requireData(data, 'Asignatura no encontrada.') return requireData(
data,
'Asignatura no encontrada.',
) as unknown as AsignaturaDetail
} }
export async function subjects_history( export async function subjects_history(
@@ -84,77 +163,105 @@ export async function subjects_bibliografia_list(
return data ?? [] return data ?? []
} }
/** Wizard: crear asignatura manual (Edge Function) */
export type SubjectsCreateManualInput = {
planId: UUID
datosBasicos: {
nombre: string
clave?: string
tipo: TipoAsignatura
creditos: number
horasSemana?: number
estructuraId: UUID
}
}
export async function subjects_create_manual( export async function subjects_create_manual(
payload: SubjectsCreateManualInput, payload: TablesInsert<'asignaturas'>,
): Promise<Asignatura> { ): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload) const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.insert(payload)
.select()
.single()
throwIfError(error)
return requireData(data, 'No se pudo crear la asignatura.')
} }
export type AIGenerateSubjectInput = { /**
plan_estudio_id: Asignatura['plan_estudio_id'] * Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
datosBasicos: { * - Siempre incluye `datosUpdate.plan_estudio_id`.
nombre: Asignatura['nombre'] * - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
codigo?: Asignatura['codigo'] * En el frontend, insertamos primero y usamos `id` para actualizar.
tipo: Asignatura['tipo'] | null */
creditos: Asignatura['creditos'] | null export type AISubjectUnifiedInput = {
horasAcademicas?: Asignatura['horas_academicas'] | null datosUpdate: Partial<{
horasIndependientes?: Asignatura['horas_independientes'] | null id: string
estructuraId: Asignatura['estructura_id'] | null plan_estudio_id: string
estructura_id: string
nombre: string
codigo: string | null
tipo: string | null
creditos: number
horas_academicas: number | null
horas_independientes: number | null
numero_ciclo: number | null
linea_plan_id: string | null
orden_celda: number | null
}> & {
plan_estudio_id: string
} }
// clonInterno?: {
// facultadId?: string
// carreraId?: string
// planOrigenId?: string
// asignaturaOrigenId?: string | null
// }
// clonTradicional?: {
// archivoWordAsignaturaId: string | null
// archivosAdicionalesIds: Array<string>
// }
iaConfig?: { iaConfig?: {
descripcionEnfoqueAcademico: string descripcionEnfoqueAcademico?: string
instruccionesAdicionalesIA: string instruccionesAdicionalesIA?: string
archivosReferencia: Array<string> archivosAdjuntos?: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
} }
} }
export async function subjects_get_maybe(
subjectId: UUID,
): Promise<Asignatura | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.select('id,plan_estudio_id,estado')
.eq('id', subjectId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as Asignatura | null
}
export type GenerateSubjectSuggestionsInput = {
plan_estudio_id: UUID
enfoque?: string
cantidad_de_sugerencias: number
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
}
export async function generate_subject_suggestions(
input: GenerateSubjectSuggestionsInput,
): Promise<Array<AsignaturaSugerida>> {
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
EDGE.generate_subject_suggestions,
input,
{ headers: { 'Content-Type': 'application/json' } },
)
return raw.map(
(s): AsignaturaSugerida => ({
id: crypto.randomUUID(),
selected: false,
source: 'IA',
nombre: s.nombre,
codigo: s.codigo,
tipo: s.tipo ?? null,
creditos: s.creditos ?? null,
horasAcademicas: s.horasAcademicas ?? null,
horasIndependientes: s.horasIndependientes ?? null,
descripcion: s.descripcion,
linea_plan_id: null,
numero_ciclo: null,
}),
)
}
export async function ai_generate_subject( export async function ai_generate_subject(
input: AIGenerateSubjectInput, input: AISubjectUnifiedInput,
): Promise<any> { ): Promise<any> {
const edgeFunctionBody = new FormData() return invokeEdge<any>(EDGE.ai_generate_subject, input, {
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id) headers: { 'Content-Type': 'application/json' },
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
edgeFunctionBody.append(
'iaConfig',
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
)
input.iaConfig?.archivosAdjuntos?.forEach((file, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
}) })
return invokeEdge<any>(
EDGE.ai_generate_subject,
edgeFunctionBody,
undefined,
supabaseBrowser(),
)
} }
export async function subjects_persist_from_ai(payload: { export async function subjects_persist_from_ai(payload: {
@@ -211,12 +318,24 @@ export async function subjects_update_fields(
export async function subjects_update_contenido( export async function subjects_update_contenido(
subjectId: UUID, subjectId: UUID,
unidades: Array<any>, unidades: Array<ContenidoApi>,
): Promise<Asignatura> { ): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { const supabase = supabaseBrowser()
subjectId,
unidades, type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update']
const { data, error } = await supabase
.from('asignaturas')
.update({
contenido_tematico:
unidades as unknown as AsignaturaUpdate['contenido_tematico'],
}) })
.eq('id', subjectId)
.select()
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar la asignatura.')
} }
export type BibliografiaUpsertInput = Array<{ export type BibliografiaUpsertInput = Array<{
@@ -343,3 +462,51 @@ export async function lineas_delete(lineaId: string) {
if (error) throw error if (error) throw error
return lineaId return lineaId
} }
export async function bibliografia_insert(entry: {
asignatura_id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('bibliografia_asignatura')
.insert([entry])
.select()
.single()
if (error) throw error
return data
}
export async function bibliografia_update(
id: string,
updates: {
cita?: string
tipo?: 'BASICA' | 'COMPLEMENTARIA'
},
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('bibliografia_asignatura')
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
.eq('id', id)
.select()
.single()
if (error) throw error
return data
}
export async function bibliografia_delete(id: string) {
const supabase = supabaseBrowser()
const { error } = await supabase
.from('bibliografia_asignatura')
.delete()
.eq('id', id)
if (error) throw error
return id
}

View File

@@ -1,29 +1,139 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ai_plan_chat, ai_plan_chat_v2,
ai_plan_improve, ai_plan_improve,
ai_subject_chat, ai_subject_chat,
ai_subject_improve, ai_subject_improve,
create_conversation,
get_chat_history,
getConversationByPlan,
library_search, library_search,
} from "../api/ai.api"; update_conversation_status,
update_recommendation_applied_status,
update_conversation_title,
} from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol
import type { UUID } from 'crypto'
export function useAIPlanImprove() { export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve }); return useMutation({ mutationFn: ai_plan_improve })
} }
export function useAIPlanChat() { export function useAIPlanChat() {
return useMutation({ mutationFn: ai_plan_chat }); return useMutation({
mutationFn: async (payload: {
planId: UUID
content: string
campos?: Array<string>
conversacionId?: string
}) => {
let currentId = payload.conversacionId
// 1. Si no hay ID, creamos la conversación
if (!currentId) {
const response = await create_conversation(payload.planId)
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
currentId = response.conversation_plan.id
}
// 2. Ahora enviamos el mensaje con el ID garantizado
const result = await ai_plan_chat_v2({
conversacionId: currentId!,
content: payload.content,
campos: payload.campos,
})
// Retornamos el resultado del chat y el ID para el estado del componente
return { ...result, conversacionId: currentId }
},
})
}
export function useChatHistory(conversacionId?: string) {
return useQuery({
queryKey: ['chat-history', conversacionId],
queryFn: async () => {
return get_chat_history(conversacionId!)
},
enabled: Boolean(conversacionId),
})
}
export function useUpdateConversationStatus() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
estado,
}: {
id: string
estado: 'ARCHIVADA' | 'ACTIVA'
}) => update_conversation_status(id, estado),
onSuccess: () => {
// Esto refresca las listas automáticamente
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
},
})
}
export function useConversationByPlan(planId: string | null) {
return useQuery({
queryKey: ['conversation-by-plan', planId],
queryFn: () => getConversationByPlan(planId!),
enabled: !!planId, // solo ejecuta si existe planId
})
}
export function useUpdateRecommendationApplied() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
conversacionId,
campoAfectado,
}: {
conversacionId: string
campoAfectado: string
}) => update_recommendation_applied_status(conversacionId, campoAfectado),
onSuccess: (_, variables) => {
// Invalidamos la query para que useConversationByPlan refresque el JSON
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
console.log(
`Recomendación ${variables.campoAfectado} marcada como aplicada.`,
)
},
onError: (error) => {
console.error('Error al actualizar el estado de la recomendación:', error)
},
})
} }
export function useAISubjectImprove() { export function useAISubjectImprove() {
return useMutation({ mutationFn: ai_subject_improve }); return useMutation({ mutationFn: ai_subject_improve })
} }
export function useAISubjectChat() { export function useAISubjectChat() {
return useMutation({ mutationFn: ai_subject_chat }); return useMutation({ mutationFn: ai_subject_chat })
} }
export function useLibrarySearch() { export function useLibrarySearch() {
return useMutation({ mutationFn: library_search }); return useMutation({ mutationFn: library_search })
}
export function useUpdateConversationTitle() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, nombre }: { id: string; nombre: string }) =>
update_conversation_title(id, nombre),
onSuccess: (_, variables) => {
// Invalidamos para que la lista de chats se refresque
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
},
})
} }

View File

@@ -1,59 +1,145 @@
import { useEffect } from "react"; import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from 'react'
import { supabaseBrowser } from "../supabase/client";
import { qk } from "../query/keys"; import { throwIfError } from '../api/_helpers'
import { throwIfError } from "../api/_helpers"; import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
export function useSession() { export function useSession() {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
const qc = useQueryClient(); const qc = useQueryClient()
const query = useQuery({ const query = useQuery({
queryKey: qk.session(), queryKey: qk.session(),
queryFn: async () => { queryFn: async () => {
const { data, error } = await supabase.auth.getSession(); const { data, error } = await supabase.auth.getSession()
throwIfError(error); throwIfError(error)
return data.session ?? null; return data.session ?? null
}, },
staleTime: Infinity, staleTime: Infinity,
}); })
useEffect(() => { useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(() => { const { data } = supabase.auth.onAuthStateChange(() => {
qc.invalidateQueries({ queryKey: qk.session() }); qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.meProfile() }); qc.invalidateQueries({ queryKey: qk.meProfile() })
qc.invalidateQueries({ queryKey: qk.auth }); qc.invalidateQueries({ queryKey: qk.meAccess() })
}); qc.invalidateQueries({ queryKey: qk.auth })
})
return () => data.subscription.unsubscribe(); return () => data.subscription.unsubscribe()
}, [supabase, qc]); }, [supabase, qc])
return query; return query
} }
export function useMeProfile() { export function useMeProfile() {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
return useQuery({ return useQuery({
queryKey: qk.meProfile(), queryKey: qk.meProfile(),
queryFn: async () => { queryFn: async () => {
const { data: u, error: uErr } = await supabase.auth.getUser(); const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr); throwIfError(uErr)
const userId = u.user?.id; const userId = u.user?.id
if (!userId) return null; if (!userId) return null
const { data, error } = await supabase const { data, error } = await supabase
.from("usuarios_app") .from('usuarios_app')
.select("id,nombre_completo,email,externo,creado_en,actualizado_en") .select('id,nombre_completo,email,externo,creado_en,actualizado_en')
.eq("id", userId) .eq('id', userId)
.single(); .single()
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo) // si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
if (error && (error as any).code === "PGRST116") return null; if (error && (error as any).code === 'PGRST116') return null
throwIfError(error); throwIfError(error)
return data ?? null; return data ?? null
}, },
staleTime: 60_000, staleTime: 60_000,
}); })
}
export type MeAccessRole = {
assignmentId: string
rolId: string
clave: string
nombre: string
descripcion: string | null
facultadId: string | null
carreraId: string | null
}
export type MeAccess = {
userId: string
roles: Array<MeAccessRole>
permissions: Array<string>
}
/**
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
*
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
*/
export function useMeAccess() {
const supabase = supabaseBrowser()
return useQuery({
queryKey: qk.meAccess(),
queryFn: async (): Promise<MeAccess | null> => {
const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr)
const userId = u.user?.id
if (!userId) return null
const { data, error } = await supabase
.from('usuarios_roles')
.select(
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
)
.eq('usuario_id', userId)
throwIfError(error)
const roles: Array<MeAccessRole> = (data ?? [])
.map((row: any) => {
const rol = row.roles
if (!rol) return null
return {
assignmentId: row.id,
rolId: rol.id,
clave: rol.clave,
nombre: rol.nombre,
descripcion: rol.descripcion ?? null,
facultadId: row.facultad_id ?? null,
carreraId: row.carrera_id ?? null,
} satisfies MeAccessRole
})
.filter(Boolean) as Array<MeAccessRole>
// Por ahora, los permisos granulares se derivan de claves de rol.
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
return {
userId,
roles,
permissions,
}
},
staleTime: 30_000,
refetchOnWindowFocus: true,
})
}
export function useAuth() {
const session = useSession()
const meProfile = useMeProfile()
const meAccess = useMeAccess()
return {
session,
meProfile,
meAccess,
}
} }

View File

@@ -4,6 +4,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_generate_plan, ai_generate_plan,
@@ -12,6 +13,7 @@ import {
plan_lineas_list, plan_lineas_list,
plans_clone_from_existing, plans_clone_from_existing,
plans_create_manual, plans_create_manual,
plans_delete,
plans_generate_document, plans_generate_document,
plans_get, plans_get,
plans_get_document, plans_get_document,
@@ -25,6 +27,7 @@ import {
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api' import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import type { import type {
PlanListFilters, PlanListFilters,
@@ -71,20 +74,92 @@ export function usePlanLineas(planId: UUID | null | undefined) {
} }
export function usePlanAsignaturas(planId: UUID | null | undefined) { export function usePlanAsignaturas(planId: UUID | null | undefined) {
return useQuery({ const qc = useQueryClient()
const query = useQuery({
queryKey: planId queryKey: planId
? qk.planAsignaturas(planId) ? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null], : ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
}) })
useEffect(() => {
if (!planId) return
const supabase = supabaseBrowser()
const channel = supabase.channel(`plan-asignaturas-${planId}`)
channel.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'asignaturas',
filter: `plan_estudio_id=eq.${planId}`,
},
(payload: {
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
new?: any
old?: any
}) => {
const eventType = payload.eventType
if (eventType === 'DELETE') {
const oldRow: any = payload.old
const deletedId = oldRow?.id
if (!deletedId) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
})
return
} }
export function usePlanHistorial(planId: UUID | null | undefined) { const newRow: any = payload.new
if (!newRow?.id) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
const idx = prev.findIndex(
(a: any) => String(a?.id) === String(newRow.id),
)
if (idx === -1) return [...prev, newRow]
const next = [...prev]
next[idx] = { ...prev[idx], ...newRow }
return next
})
},
)
channel.subscribe()
return () => {
try {
supabase.removeChannel(channel)
} catch {
// noop
}
}
}, [planId, qc])
return query
}
export function usePlanHistorial(
planId: UUID | null | undefined,
page: number,
) {
return useQuery({ return useQuery({
queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null], queryKey: planId
queryFn: () => plans_history(planId as UUID), ? [...qk.planHistorial(planId), page]
: ['planes', 'historial', null, page],
queryFn: () => plans_history(planId as UUID, page),
enabled: Boolean(planId), enabled: Boolean(planId),
placeholderData: (previousData) => previousData,
}) })
} }
@@ -247,6 +322,23 @@ export function useTransitionPlanEstado() {
}) })
} }
export function useDeletePlanEstudio() {
const qc = useQueryClient()
return useMutation({
mutationFn: (planId: UUID) => plans_delete(planId),
onSuccess: (_ok, planId) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.removeQueries({ queryKey: qk.plan(planId) })
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
qc.removeQueries({ queryKey: qk.planLineas(planId) })
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
},
})
}
export function useGeneratePlanDocumento() { export function useGeneratePlanDocumento() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -263,7 +355,7 @@ export function useDeleteLinea() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: lineas_delete, mutationFn: lineas_delete,
onSuccess: (idEliminado) => { onSuccess: (_idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen // Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] }) qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] }) qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })

View File

@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ai_generate_subject, ai_generate_subject,
asignaturas_update, asignaturas_update,
bibliografia_delete,
bibliografia_insert,
bibliografia_update,
lineas_insert, lineas_insert,
lineas_update, lineas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
@@ -23,10 +26,11 @@ import { qk } from '../query/keys'
import type { import type {
BibliografiaUpsertInput, BibliografiaUpsertInput,
SubjectsCreateManualInput, ContenidoApi,
SubjectsUpdateFieldsPatch, SubjectsUpdateFieldsPatch,
} from '../api/subjects.api' } from '../api/subjects.api'
import type { UUID } from '../types/domain' import type { UUID } from '../types/domain'
import type { TablesInsert } from '@/types/supabase'
export function useSubject(subjectId: UUID | null | undefined) { export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
@@ -82,7 +86,7 @@ export function useCreateSubjectManual() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: SubjectsCreateManualInput) => mutationFn: (payload: TablesInsert<'asignaturas'>) =>
subjects_create_manual(payload), subjects_create_manual(payload),
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject) qc.setQueryData(qk.asignatura(subject.id), subject)
@@ -97,7 +101,6 @@ export function useCreateSubjectManual() {
} }
export function useGenerateSubjectAI() { export function useGenerateSubjectAI() {
const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ai_generate_subject, mutationFn: ai_generate_subject,
}) })
@@ -162,7 +165,9 @@ export function useUpdateSubjectFields() {
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) => mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch), subjects_update_fields(vars.subjectId, vars.patch),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated) qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({ qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id), queryKey: qk.planAsignaturas(updated.plan_estudio_id),
}) })
@@ -175,10 +180,19 @@ export function useUpdateSubjectContenido() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: Array<any> }) => mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
subjects_update_contenido(vars.subjectId, vars.unidades), subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated) qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }) qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}) })
@@ -221,17 +235,22 @@ export function useUpdateAsignatura() {
}) => asignaturas_update(vars.asignaturaId, vars.patch), }) => asignaturas_update(vars.asignaturaId, vars.patch),
onSuccess: (updated) => { onSuccess: (updated) => {
// 1. Actualizamos la materia específica en la caché si tienes un query de "detalle" // ✅ Mantener consistencia con las query keys centralizadas (qk)
qc.setQueryData(['asignatura', updated.id], updated) // 1) Actualiza el detalle (esto evita volver a entrar con caché vieja)
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
// 2. IMPORTANTÍSIMO: Invalidamos la lista de materias del plan // 2) Refresca vistas derivadas del plan
// para que el mapa curricular vea los cambios (créditos, horas, nombre, etc.)
qc.invalidateQueries({ qc.invalidateQueries({
queryKey: ['plan_asignaturas', updated.plan_estudio_id], queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
}) })
// 3. Si tienes una lista general de asignaturas, también la invalidamos // 3) Refresca historial de la asignatura si existe
qc.invalidateQueries({ queryKey: ['asignaturas', 'list'] }) qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}) })
} }
@@ -260,3 +279,41 @@ export function useUpdateLinea() {
}, },
}) })
} }
export function useCreateBibliografia() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bibliografia_insert,
onSuccess: (data) => {
// USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA
queryClient.invalidateQueries({
queryKey: qk.asignaturaBibliografia(data.asignatura_id),
})
},
})
}
export function useUpdateBibliografia(asignaturaId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
bibliografia_update(id, updates),
onSuccess: () => {
qc.invalidateQueries({
queryKey: qk.asignaturaBibliografia(asignaturaId),
})
},
})
}
export function useDeleteBibliografia(asignaturaId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => bibliografia_delete(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: qk.asignaturaBibliografia(asignaturaId),
})
},
})
}

View File

@@ -5,7 +5,6 @@ import type {
BibliografiaEntry, BibliografiaEntry,
CambioAsignatura, CambioAsignatura,
DocumentoAsignatura, DocumentoAsignatura,
LibraryResource,
} from '@/types/asignatura' } from '@/types/asignatura'
export const mockAsignatura: Asignatura = { export const mockAsignatura: Asignatura = {
@@ -310,67 +309,3 @@ export const mockDocumentoSep: DocumentoAsignatura = {
fechaGeneracion: new Date('2024-12-06T11:30:00'), fechaGeneracion: new Date('2024-12-06T11:30:00'),
estado: 'listo', estado: 'listo',
} }
export const mockLibraryResources: Array<LibraryResource> = [
{
id: 'lib-1',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Stuart Russell, Peter Norvig',
editorial: 'Pearson',
anio: 2021,
isbn: '978-0134610993',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-2',
titulo:
'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
autor: 'Aurélien Géron',
editorial: "O'Reilly Media",
anio: 2022,
isbn: '978-1098125974',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-3',
titulo: 'Pattern Recognition and Machine Learning',
autor: 'Christopher Bishop',
editorial: 'Springer',
anio: 2006,
isbn: '978-0387310732',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-4',
titulo: 'Deep Learning with Python',
autor: 'François Chollet',
editorial: 'Manning Publications',
anio: 2021,
isbn: '978-1617296864',
tipo: 'libro',
disponible: false,
},
{
id: 'lib-5',
titulo: 'Neural Networks and Deep Learning: A Textbook',
autor: 'Charu C. Aggarwal',
editorial: 'Springer',
anio: 2023,
isbn: '978-3031296413',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-6',
titulo: 'Machine Learning: A Probabilistic Perspective',
autor: 'Kevin Murphy',
editorial: 'MIT Press',
anio: 2012,
isbn: '978-0262018029',
tipo: 'libro',
disponible: true,
},
]

View File

@@ -2,6 +2,7 @@ export const qk = {
auth: ['auth'] as const, auth: ['auth'] as const,
session: () => ['auth', 'session'] as const, session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const, meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
facultades: () => ['meta', 'facultades'] as const, facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) => carreras: (facultadId?: string | null) =>
@@ -13,14 +14,18 @@ export const qk = {
planesList: (filters: unknown) => ['planes', 'list', filters] as const, planesList: (filters: unknown) => ['planes', 'list', filters] as const,
plan: (planId: string) => ['planes', 'detail', planId] as const, plan: (planId: string) => ['planes', 'detail', planId] as const,
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const, planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
planAsignaturas: (planId: string) => planAsignaturas: (planId: string) =>
['planes', planId, 'asignaturas'] as const, ['planes', planId, 'asignaturas'] as const,
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const, planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const, planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) => asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const, ['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) => asignaturaBibliografia: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'bibliografia'] as const, ['asignaturas', asignaturaId, 'bibliografia'] as const,
asignaturaHistorial: (asignaturaId: string) => asignaturaHistorial: (asignaturaId: string) =>

View File

@@ -1,8 +1,44 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import {
MutationCache,
QueryCache,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { qk } from './keys'
import type React from 'react'
function isRlsViolationError(error: unknown): boolean {
const anyErr = error as any
const code = anyErr?.code
const status = anyErr?.status ?? anyErr?.response?.status
console.log('Checking RLS violation error:', { code, status })
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
return status === 403 || code === '42501'
}
export function getContext() { export function getContext() {
const queryClient = new QueryClient( const queryClientRef: { current: QueryClient | null } = { current: null }
{
const handleAuthzDesync = (error: unknown) => {
if (!isRlsViolationError(error)) return
// Forzar resincronización “database-first” del rol/permisos
console.log('RLS violation detected, invalidating queries...')
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
mutationCache: new MutationCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30_000, staleTime: 30_000,
@@ -13,8 +49,9 @@ export function getContext() {
retry: 0, retry: 0,
}, },
}, },
} })
)
queryClientRef.current = queryClient
return { return {
queryClient, queryClient,
} }

View File

@@ -0,0 +1,151 @@
import React, { useState, useEffect, useMemo } from 'react'
// --- DEFINICIÓN DE MENSAJES ---
const MENSAJES_CORTOS = [
// Hasta 5 sugerencias (6 mensajes)
'Analizando el plan de estudios...',
'Identificando áreas de oportunidad...',
'Consultando bases de datos académicas...',
'Redactando competencias específicas...',
'Calculando créditos y horas...',
'Afinando los últimos detalles...',
]
const MENSAJES_MEDIOS = [
// Hasta 10 sugerencias (10 mensajes)
'Conectando con el motor de IA...',
'Analizando estructura curricular...',
'Buscando asignaturas compatibles...',
'Verificando prerrequisitos...',
'Generando descripciones detalladas...',
'Balanceando cargas académicas...',
'Asignando horas independientes...',
'Validando coherencia temática...',
'Formateando resultados...',
'Finalizando generación...',
]
const MENSAJES_LARGOS = [
// Más de 10 sugerencias (14 mensajes)
'Iniciando procesamiento masivo...',
'Escaneando retícula completa...',
'Detectando líneas de investigación...',
'Generando primer bloque de asignaturas...',
'Evaluando pertinencia académica...',
'Optimizando créditos por ciclo...',
'Redactando objetivos de aprendizaje...',
'Generando segundo bloque...',
'Revisando duplicidad de contenidos...',
'Ajustando tiempos teóricos y prácticos...',
'Verificando normatividad...',
'Compilando sugerencias...',
'Aplicando formato final...',
'Casi listo, gracias por tu paciencia...',
]
interface AIProgressLoaderProps {
isLoading: boolean
cantidadDeSugerencias: number
}
export const AIProgressLoader: React.FC<AIProgressLoaderProps> = ({
isLoading,
cantidadDeSugerencias,
}) => {
const [progress, setProgress] = useState(0)
const [currentMessageIndex, setCurrentMessageIndex] = useState(0)
// 1. Seleccionar el grupo de mensajes según la cantidad
const messages = useMemo(() => {
if (cantidadDeSugerencias <= 5) return MENSAJES_CORTOS
if (cantidadDeSugerencias <= 10) return MENSAJES_MEDIOS
return MENSAJES_LARGOS
}, [cantidadDeSugerencias])
useEffect(() => {
if (!isLoading) {
setProgress(0)
setCurrentMessageIndex(0)
return
}
// --- CÁLCULO DEL TIEMPO TOTAL ---
// y = 4.07x + 10.93 (en segundos)
const estimatedSeconds = 4.07 * cantidadDeSugerencias + 10.93
const durationMs = estimatedSeconds * 1000
// Intervalo de actualización de la barra (cada 50ms para suavidad)
const updateInterval = 50
const totalSteps = durationMs / updateInterval
const incrementPerStep = 99 / totalSteps // Llegamos al 99% para esperar la respuesta real
// --- TIMER 1: BARRA DE PROGRESO ---
const progressTimer = setInterval(() => {
setProgress((prev) => {
const next = prev + incrementPerStep
return next >= 99 ? 99 : next // Topar en 99%
})
}, updateInterval)
// --- TIMER 2: MENSAJES (CADA 5 SEGUNDOS) ---
const messagesTimer = setInterval(() => {
setCurrentMessageIndex((prev) => {
// Si ya es el último mensaje, no avanzar más (no ciclar)
if (prev >= messages.length - 1) return prev
return prev + 1
})
}, 5000)
// Cleanup al desmontar o cuando isLoading cambie
return () => {
clearInterval(progressTimer)
clearInterval(messagesTimer)
}
}, [isLoading, cantidadDeSugerencias, messages])
if (!isLoading) return null
return (
<div className="animate-in fade-in zoom-in m-2 mx-auto w-full max-w-md duration-300">
{/* Contenedor de la barra */}
<div className="relative pt-1">
<div className="mb-2 flex items-center justify-between">
<div>
<span className="inline-block rounded-full bg-blue-200 px-2 py-1 text-xs font-semibold text-blue-600 uppercase">
Generando IA
</span>
</div>
<div className="text-right">
<span className="inline-block text-xs font-semibold text-blue-600">
{Math.floor(progress)}%
</span>
</div>
</div>
{/* Barra de fondo */}
<div className="mb-4 flex h-2 overflow-hidden rounded bg-blue-100 text-xs">
{/* Barra de progreso dinámica */}
<div
style={{ width: `${progress}%` }}
className="flex flex-col justify-center bg-blue-500 text-center whitespace-nowrap text-white shadow-none transition-all duration-75 ease-linear"
></div>
</div>
{/* Mensajes cambiantes */}
<div className="h-6 text-center">
{' '}
{/* Altura fija para evitar saltos */}
<p className="text-sm text-slate-500 italic transition-opacity duration-500">
{messages[currentMessageIndex]}
</p>
</div>
{/* Nota de tiempo estimado (Opcional, transparencia operacional) */}
<p className="mt-2 text-center text-[10px] text-slate-400">
Tiempo estimado: ~{Math.ceil(4.07 * cantidadDeSugerencias + 10.93)}{' '}
segs
</p>
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard' import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm' import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel' import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup' import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard' import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
@@ -55,10 +55,16 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
canContinueDesdeMetodo, canContinueDesdeMetodo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
simularGeneracionIA,
crearAsignatura,
} = useNuevaAsignaturaWizard(planId) } = useNuevaAsignaturaWizard(planId)
const titleOverrides =
wizard.tipoOrigen === 'IA_MULTIPLE'
? {
basicos: 'Sugerencias',
detalles: 'Estructura',
}
: undefined
const handleClose = () => { const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false }) navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
} }
@@ -99,7 +105,11 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
title="Nueva Asignatura" title="Nueva Asignatura"
onClose={handleClose} onClose={handleClose}
headerSlot={ headerSlot={
<WizardResponsiveHeader wizard={Wizard} methods={methods} /> <WizardResponsiveHeader
wizard={Wizard}
methods={methods}
titleOverrides={titleOverrides}
/>
} }
footerSlot={ footerSlot={
<Wizard.Stepper.Controls> <Wizard.Stepper.Controls>
@@ -137,11 +147,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
{idx === 2 && ( {idx === 2 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoDetallesPanel <PasoDetallesPanel wizard={wizard} onChange={setWizard} />
wizard={wizard}
onChange={setWizard}
onGenerarIA={simularGeneracionIA}
/>
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}

View File

@@ -1,11 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import type { AsignaturaPreview, NewSubjectWizardState } from '../types' import type { NewSubjectWizardState } from '../types'
export function useNuevaAsignaturaWizard(planId: string) { export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({ const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1, step: 1,
plan_estudio_id: planId, plan_estudio_id: planId,
estructuraId: null,
tipoOrigen: null, tipoOrigen: null,
datosBasicos: { datosBasicos: {
nombre: '', nombre: '',
@@ -16,6 +17,7 @@ export function useNuevaAsignaturaWizard(planId: string) {
horasIndependientes: null, horasIndependientes: null,
estructuraId: '', estructuraId: '',
}, },
sugerencias: [],
clonInterno: {}, clonInterno: {},
clonTradicional: { clonTradicional: {
archivoWordAsignaturaId: null, archivoWordAsignaturaId: null,
@@ -28,6 +30,11 @@ export function useNuevaAsignaturaWizard(planId: string) {
repositoriosReferencia: [], repositoriosReferencia: [],
archivosAdjuntos: [], archivosAdjuntos: [],
}, },
iaMultiple: {
enfoque: '',
cantidadDeSugerencias: 10,
isLoading: false,
},
resumen: {}, resumen: {},
isLoading: false, isLoading: false,
errorMessage: null, errorMessage: null,
@@ -35,20 +42,23 @@ export function useNuevaAsignaturaWizard(planId: string) {
const canContinueDesdeMetodo = const canContinueDesdeMetodo =
wizard.tipoOrigen === 'MANUAL' || wizard.tipoOrigen === 'MANUAL' ||
wizard.tipoOrigen === 'IA' || wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' || wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL' wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
const canContinueDesdeBasicos = const canContinueDesdeBasicos =
!!wizard.datosBasicos.nombre && (!!wizard.datosBasicos.nombre &&
wizard.datosBasicos.tipo !== null && wizard.datosBasicos.tipo !== null &&
wizard.datosBasicos.creditos !== null && wizard.datosBasicos.creditos !== null &&
wizard.datosBasicos.creditos > 0 && wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId !!wizard.datosBasicos.estructuraId) ||
(wizard.tipoOrigen === 'IA_MULTIPLE' &&
wizard.sugerencias.filter((s) => s.selected).length > 0)
const canContinueDesdeDetalles = (() => { const canContinueDesdeDetalles = (() => {
if (wizard.tipoOrigen === 'MANUAL') return true if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.tipoOrigen === 'IA') { if (wizard.tipoOrigen === 'IA_SIMPLE') {
return !!wizard.iaConfig?.descripcionEnfoqueAcademico return !!wizard.iaConfig?.descripcionEnfoqueAcademico
} }
if (wizard.tipoOrigen === 'CLONADO_INTERNO') { if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
@@ -57,38 +67,17 @@ export function useNuevaAsignaturaWizard(planId: string) {
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') { if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return !!wizard.clonTradicional?.archivoWordAsignaturaId return !!wizard.clonTradicional?.archivoWordAsignaturaId
} }
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
return wizard.estructuraId !== null
}
return false return false
})() })()
const simularGeneracionIA = async () => {
setWizard((w) => ({ ...w, isLoading: true }))
await new Promise((r) => setTimeout(r, 1500))
setWizard((w) => ({
...w,
isLoading: false,
resumen: {
previewAsignatura: {
nombre: w.datosBasicos.nombre,
objetivo:
'Aplicar los fundamentos teóricos para la resolución de problemas...',
unidades: 5,
bibliografiaCount: 3,
} as AsignaturaPreview,
},
}))
}
const crearAsignatura = async () => {
await new Promise((r) => setTimeout(r, 1000))
}
return { return {
wizard, wizard,
setWizard, setWizard,
canContinueDesdeMetodo, canContinueDesdeMetodo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
simularGeneracionIA,
crearAsignatura,
} }
} }

View File

@@ -2,7 +2,6 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import type { Asignatura } from '@/data' import type { Asignatura } from '@/data'
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO' export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
export type SubModoClonado = 'INTERNO' | 'TRADICIONAL'
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO' export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
export type AsignaturaPreview = { export type AsignaturaPreview = {
@@ -12,10 +11,34 @@ export type AsignaturaPreview = {
bibliografiaCount: number bibliografiaCount: number
} }
export type DataAsignaturaSugerida = {
nombre: Asignatura['nombre']
codigo?: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos'] | null
horasAcademicas?: number | null
horasIndependientes?: number | null
descripcion: string
}
export type AsignaturaSugerida = {
id: string
selected: boolean
source: 'IA' | 'MANUAL' | 'CLON'
linea_plan_id: string | null
numero_ciclo: number | null
} & DataAsignaturaSugerida
export type NewSubjectWizardState = { export type NewSubjectWizardState = {
step: 1 | 2 | 3 | 4 step: 1 | 2 | 3 | 4
plan_estudio_id: Asignatura['plan_estudio_id'] plan_estudio_id: Asignatura['plan_estudio_id']
tipoOrigen: Asignatura['tipo_origen'] | null estructuraId: Asignatura['estructura_id'] | null
tipoOrigen:
| Asignatura['tipo_origen']
| 'CLONADO'
| 'IA_SIMPLE'
| 'IA_MULTIPLE'
| null
datosBasicos: { datosBasicos: {
nombre: Asignatura['nombre'] nombre: Asignatura['nombre']
codigo?: Asignatura['codigo'] codigo?: Asignatura['codigo']
@@ -25,6 +48,7 @@ export type NewSubjectWizardState = {
horasIndependientes?: Asignatura['horas_independientes'] | null horasIndependientes?: Asignatura['horas_independientes'] | null
estructuraId: Asignatura['estructura_id'] | null estructuraId: Asignatura['estructura_id'] | null
} }
sugerencias: Array<AsignaturaSugerida>
clonInterno?: { clonInterno?: {
facultadId?: string facultadId?: string
carreraId?: string carreraId?: string
@@ -42,6 +66,11 @@ export type NewSubjectWizardState = {
repositoriosReferencia?: Array<string> repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile> archivosAdjuntos?: Array<UploadedFile>
} }
iaMultiple?: {
enfoque: string
cantidadDeSugerencias: number
isLoading: boolean
}
resumen: { resumen: {
previewAsignatura?: AsignaturaPreview previewAsignatura?: AsignaturaPreview
} }

View File

@@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx' import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import { supabaseBrowser } from '@/data/supabase/client'
import './styles.css' import './styles.css'
@@ -16,6 +17,7 @@ const router = createRouter({
routeTree, routeTree,
context: { context: {
...TanStackQueryProviderContext, ...TanStackQueryProviderContext,
supabase: supabaseBrowser(),
}, },
defaultPreload: 'intent', defaultPreload: 'intent',
scrollRestoration: true, scrollRestoration: true,

View File

@@ -17,13 +17,19 @@ import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-qu
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle' import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index' import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId'
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa' import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan' import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial' import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo' import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento' import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas' import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/historial'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/documento'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva' import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
@@ -67,12 +73,6 @@ const PlanesPlanIdDetalleIndexRoute =
path: '/', path: '/',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({ const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
id: '/mapa', id: '/mapa',
path: '/mapa', path: '/mapa',
@@ -108,6 +108,48 @@ const PlanesPlanIdDetalleAsignaturasRoute =
path: '/asignaturas', path: '/asignaturas',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
id: '/iaasignatura',
path: '/iaasignatura',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
id: '/historial',
path: '/historial',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
id: '/documento',
path: '/documento',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
id: '/contenido',
path: '/contenido',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
id: '/bibliografia',
path: '/bibliografia',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasNuevaRoute = const PlanesPlanIdDetalleAsignaturasNuevaRoute =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({ PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
id: '/nueva', id: '/nueva',
@@ -123,15 +165,21 @@ export interface FileRoutesByFullPath {
'/planes': typeof PlanesListaRouteWithChildren '/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -146,9 +194,14 @@ export interface FileRoutesByTo {
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -159,15 +212,21 @@ export interface FileRoutesById {
'/planes/_lista': typeof PlanesListaRouteWithChildren '/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren '/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -179,15 +238,21 @@ export interface FileRouteTypes {
| '/planes' | '/planes'
| '/planes/$planId' | '/planes/$planId'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas' | '/planes/$planId/asignaturas'
| '/planes/$planId/documento' | '/planes/$planId/documento'
| '/planes/$planId/flujo' | '/planes/$planId/flujo'
| '/planes/$planId/historial' | '/planes/$planId/historial'
| '/planes/$planId/iaplan' | '/planes/$planId/iaplan'
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/' | '/planes/$planId/'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -202,9 +267,14 @@ export interface FileRouteTypes {
| '/planes/$planId/historial' | '/planes/$planId/historial'
| '/planes/$planId/iaplan' | '/planes/$planId/iaplan'
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -214,15 +284,21 @@ export interface FileRouteTypes {
| '/planes/_lista' | '/planes/_lista'
| '/planes/$planId/_detalle' | '/planes/$planId/_detalle'
| '/planes/_lista/nuevo' | '/planes/_lista/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/asignaturas' | '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/_detalle/documento' | '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo' | '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial' | '/planes/$planId/_detalle/historial'
| '/planes/$planId/_detalle/iaplan' | '/planes/$planId/_detalle/iaplan'
| '/planes/$planId/_detalle/mapa' | '/planes/$planId/_detalle/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/' | '/planes/$planId/_detalle/'
| '/planes/$planId/_detalle/asignaturas/nueva' | '/planes/$planId/_detalle/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -232,7 +308,7 @@ export interface RootRouteChildren {
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesListaRoute: typeof PlanesListaRouteWithChildren PlanesListaRoute: typeof PlanesListaRouteWithChildren
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRoute PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -293,13 +369,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/_detalle/mapa': { '/planes/$planId/_detalle/mapa': {
id: '/planes/$planId/_detalle/mapa' id: '/planes/$planId/_detalle/mapa'
path: '/mapa' path: '/mapa'
@@ -342,6 +411,55 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/$asignaturaId/': {
id: '/planes/$planId/asignaturas/$asignaturaId/'
path: '/'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
path: '/iaasignatura'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/historial': {
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
path: '/historial'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/documento': {
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
path: '/documento'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
path: '/contenido'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
path: '/bibliografia'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/_detalle/asignaturas/nueva': { '/planes/$planId/_detalle/asignaturas/nueva': {
id: '/planes/$planId/_detalle/asignaturas/nueva' id: '/planes/$planId/_detalle/asignaturas/nueva'
path: '/nueva' path: '/nueva'
@@ -403,6 +521,36 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
const PlanesPlanIdDetalleRouteWithChildren = const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren) PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
@@ -410,8 +558,8 @@ const rootRouteChildren: RootRouteChildren = {
DemoTanstackQueryRoute: DemoTanstackQueryRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesListaRoute: PlanesListaRouteWithChildren, PlanesListaRoute: PlanesListaRouteWithChildren,
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren, PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdRoute: PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRoute, PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -1,22 +1,59 @@
import { TanStackDevtools } from '@tanstack/react-devtools' import { TanStackDevtools } from '@tanstack/react-devtools'
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import {
Outlet,
createRootRouteWithContext,
redirect,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { useEffect } from 'react'
import Header from '../components/Header' import Header from '../components/Header'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { throwIfError } from '@/data/api/_helpers'
import { useSession } from '@/data/hooks/useAuth'
import { qk } from '@/data/query/keys'
interface MyRouterContext { interface MyRouterContext {
queryClient: QueryClient queryClient: QueryClient
supabase: SupabaseClient<Database>
} }
export const Route = createRootRouteWithContext<MyRouterContext>()({ export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context, location }) => {
const pathname = location.pathname
const isLogin = pathname === '/login'
const isIndex = pathname === '/'
const session = await context.queryClient.ensureQueryData({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await context.supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
},
staleTime: Infinity,
})
if (!session && !isLogin) {
throw redirect({ to: '/login' })
}
if (session && (isLogin || isIndex)) {
throw redirect({ to: '/dashboard' })
}
},
component: () => ( component: () => (
<> <>
<Header /> <AuthSync />
<MaybeHeader />
<Outlet /> <Outlet />
<TanStackDevtools <TanStackDevtools
config={{ config={{
@@ -60,3 +97,40 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
) )
}, },
}) })
function MaybeHeader() {
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
if (pathname === '/login') return null
return <Header />
}
function AuthSync() {
const { data: session, isLoading } = useSession()
// Mantiene roles/permisos sincronizados con la BD (database-first)
// useMeAccess()
const navigate = useNavigate()
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
useEffect(() => {
if (isLoading) return
if (!session && pathname !== '/login') {
void navigate({ to: '/login', replace: true })
return
}
if (session && pathname === '/login') {
void navigate({ to: '/dashboard', replace: true })
}
}, [isLoading, session, pathname, navigate])
return null
}

View File

@@ -1,16 +1,16 @@
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router' import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import { import {
Plus, Plus,
Copy,
Search, Search,
Filter, Filter,
ChevronRight, ChevronRight,
BookOpen, BookOpen,
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useMemo } from 'react' import { useMemo, useState } from 'react'
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan' import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan'
import type { Tables } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -37,6 +37,11 @@ const statusConfig: Record<
AsignaturaStatus, AsignaturaStatus,
{ label: string; className: string } { label: string; className: string }
> = { > = {
generando: {
label: 'Generando',
className:
'bg-slate-100 text-slate-600 animate-pulse [animation-duration:2s]',
},
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' }, borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' }, revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' }, aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
@@ -44,31 +49,31 @@ const statusConfig: Record<
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> = const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> =
{ {
obligatoria: { OBLIGATORIA: {
label: 'Obligatoria', label: 'Obligatoria',
className: 'bg-blue-100 text-blue-700', className: 'bg-blue-100 text-blue-700',
}, },
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' }, OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' }, TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
} }
// --- Mapeadores de API --- // --- Mapeadores de API ---
const mapAsignaturas = (asigApi: Array<any> = []): Array<Asignatura> => { const mapAsignaturas = (
asigApi: Array<Tables<'asignaturas'>> = [],
): Array<Asignatura> => {
return asigApi.map((asig) => ({ return asigApi.map((asig) => ({
id: asig.id, id: asig.id,
clave: asig.codigo, clave: asig.codigo ?? '',
nombre: asig.nombre, nombre: asig.nombre,
creditos: asig.creditos ?? 0, creditos: asig.creditos,
ciclo: asig.numero_ciclo ?? null, ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null, lineaCurricularId: asig.linea_plan_id ?? null,
tipo: tipo: asig.tipo,
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa', estado: asig.estado,
estado: 'borrador', // O el campo que venga de tu API hd: asig.horas_academicas ?? 0,
hd: Math.floor((asig.horas_semana ?? 0) / 2), hi: asig.horas_independientes ?? 0,
hi: Math.ceil((asig.horas_semana ?? 0) / 2), prerrequisitos: [],
prerrequisitos: Array.isArray(asig.prerrequisitos)
? asig.prerrequisitos
: [],
})) }))
} }
@@ -81,7 +86,7 @@ function AsignaturasPage() {
const navigate = useNavigate() const navigate = useNavigate()
// 1. Fetch de datos reales // 1. Fetch de datos reales
const { data: asignaturasApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
@@ -93,8 +98,8 @@ function AsignaturasPage() {
// 3. Procesamiento de datos // 3. Procesamiento de datos
const asignaturas = useMemo( const asignaturas = useMemo(
() => mapAsignaturas(asignaturasApi), () => mapAsignaturas(asignaturaApi),
[asignaturasApi], [asignaturaApi],
) )
const lineas = useMemo(() => lineasApi || [], [lineasApi]) const lineas = useMemo(() => lineasApi || [], [lineasApi])
@@ -138,9 +143,6 @@ function AsignaturasPage() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm">
<Copy className="mr-2 h-4 w-4" /> Clonar
</Button>
<Button <Button
onClick={() => { onClick={() => {
console.log('planId desde asignaturas', planId) console.log('planId desde asignaturas', planId)

View File

@@ -12,10 +12,13 @@ import {
Eye, Eye,
History, History,
Calendar, Calendar,
ChevronLeft,
ChevronRight,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { import {
Dialog, Dialog,
@@ -57,15 +60,14 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(planId) const [page, setPage] = useState(0)
const pageSize = 4
const { data: response, isLoading } = usePlanHistorial(planId, page)
const rawData = response?.data ?? []
const totalRecords = response?.count ?? 0
const totalPages = Math.ceil(totalRecords / pageSize)
const [structure, setStructure] = useState<any>(null) const [structure, setStructure] = useState<any>(null)
const { data } = usePlan(planId) const { data } = usePlan(planId)
console.log('analizando estructura')
console.log(data?.estructuras_plan?.definicion?.properties)
// console.log(structure)
// ESTADOS PARA EL MODAL
const [selectedEvent, setSelectedEvent] = useState<any>(null) const [selectedEvent, setSelectedEvent] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
@@ -94,7 +96,8 @@ function RouteComponent() {
}`, }`,
date: parseISO(item.cambiado_en), date: parseISO(item.cambiado_en),
icon: config.icon, icon: config.icon,
campo: item.campo, campo:
data?.estructuras_plan?.definicion?.properties?.[item.campo]?.title,
details: { details: {
from: item.valor_anterior, from: item.valor_anterior,
to: item.valor_nuevo, to: item.valor_nuevo,
@@ -229,6 +232,46 @@ function RouteComponent() {
</div> </div>
)) ))
)} )}
{historyEvents.length > 0 && (
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
<p className="text-xs text-slate-500">
Mostrando {rawData.length} de {totalRecords} cambios
</p>
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => {
setPage((p) => Math.max(0, p - 1))
window.scrollTo(0, 0) // Opcional: volver arriba
}}
disabled={page === 0 || isLoading}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Anterior
</Button>
<span className="text-sm font-medium text-slate-700">
Página {page + 1} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
setPage((p) => p + 1)
window.scrollTo(0, 0)
}}
// Ahora se deshabilita si llegamos a la última página real
disabled={page + 1 >= totalPages || isLoading}
>
Siguiente
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
</div> </div>
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */} {/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
@@ -256,6 +299,8 @@ function RouteComponent() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6"> <div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */} {/* Lado Antes */}
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
{selectedEvent?.details.from && (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1"> <div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-red-400" /> <div className="h-2 w-2 rounded-full bg-red-400" />
@@ -264,9 +309,10 @@ function RouteComponent() {
</span> </span>
</div> </div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700"> <div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent?.details.from)} {renderValue(selectedEvent.details.from)}
</div> </div>
</div> </div>
)}
{/* Lado Después */} {/* Lado Después */}
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
@@ -286,6 +332,11 @@ function RouteComponent() {
<div className="flex justify-center border-t bg-slate-50 p-4"> <div className="flex justify-center border-t bg-slate-50 p-4">
<Badge variant="outline" className="font-mono text-[10px]"> <Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo} Campo: {selectedEvent?.campo}
{console.log(
data?.estructuras_plan?.definicion?.properties?.[
selectedEvent?.campo
]?.title,
)}
</Badge> </Badge>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -1,22 +1,37 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useQueryClient } from '@tanstack/react-query'
import { createFileRoute, useRouterState } from '@tanstack/react-router' import { createFileRoute, useRouterState } from '@tanstack/react-router'
import { import {
Sparkles,
Send, Send,
Target, Target,
UserCheck,
Lightbulb, Lightbulb,
FileText, FileText,
GraduationCap, GraduationCap,
BookOpen, BookOpen,
Check, Check,
X, X,
MessageSquarePlus,
Archive,
RotateCcw,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
useAIPlanChat,
useConversationByPlan,
useUpdateConversationStatus,
useUpdateConversationTitle,
} from '@/data'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
const PRESETS = [ const PRESETS = [
@@ -52,78 +67,284 @@ interface SelectedField {
label: string label: string
value: string value: string
} }
interface EstructuraDefinicion {
const formatLabel = (key: string) => { properties?: {
const result = key.replace(/_/g, ' ') [key: string]: {
return result.charAt(0).toUpperCase() + result.slice(1) title: string
description?: string
}
}
}
interface ChatMessageJSON {
user: 'user' | 'assistant'
message?: string
prompt?: string
refusal?: boolean
recommendations?: Array<{
campo_afectado: string
texto_mejora: string
aplicada: boolean
}>
} }
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent, component: RouteComponent,
}) })
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
// Usamos el ID dinámico del plan o el hardcoded según tu necesidad const { data } = usePlan(planId)
const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
const routerState = useRouterState() const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false)
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
// ESTADOS PRINCIPALES const [activeChatId, setActiveChatId] = useState<string | undefined>(
const [messages, setMessages] = useState<Array<any>>([ undefined,
{ )
id: '1', const { data: lastConversation, isLoading: isLoadingConv } =
role: 'assistant', useConversationByPlan(planId)
content: const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
'¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Puedes escribir ":" para seleccionar uno.', [],
}, )
]) const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
const [messages, setMessages] = useState<Array<any>>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([]) const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [showArchived, setShowArchived] = useState(false)
// 1. Transformar datos de la API para el menú de selección const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null)
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
const [isSending, setIsSending] = useState(false)
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
null,
)
const [filterQuery, setFilterQuery] = useState('')
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
if (!data?.estructuras_plan?.definicion?.properties) return [] const definicion = data?.estructuras_plan
return Object.entries(data.estructuras_plan.definicion.properties).map( ?.definicion as EstructuraDefinicion
([key, value]) => ({
// Encadenamiento opcional para evitar errores si data es null
if (!definicion.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({
key, key,
label: value.title, label: value.title,
value: String(value.description || ''), value: String(value.description || ''),
}), }))
)
}, [data]) }, [data])
// 2. Manejar el estado inicial si viene de "Datos Generales" const filteredFields = useMemo(() => {
return availableFields.filter(
(field) =>
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
)
}, [availableFields, filterQuery, selectedFields])
const activeChatData = useMemo(() => {
return lastConversation?.find((chat: any) => chat.id === activeChatId)
}, [lastConversation, activeChatId])
const chatMessages = useMemo(() => {
// 1. Si no hay ID o no hay data del chat, retornamos vacío
if (!activeChatId || !activeChatData) return []
const json = (activeChatData.conversacion_json ||
[]) as unknown as Array<ChatMessageJSON>
// 2. Verificamos que 'json' sea realmente un array antes de mapear
if (!Array.isArray(json)) return []
return json.map((msg, index: number) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!msg?.user) {
return {
id: `err-${index}`,
role: 'assistant',
content: '',
suggestions: [],
}
}
const isAssistant = msg.user === 'assistant'
return {
id: `${activeChatId}-${index}`,
role: isAssistant ? 'assistant' : 'user',
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
isRefusal: isAssistant && msg.refusal === true,
suggestions:
isAssistant && msg.recommendations
? msg.recommendations.map((rec) => {
const fieldConfig = availableFields.find(
(f) => f.key === rec.campo_afectado,
)
return {
key: rec.campo_afectado,
label: fieldConfig
? fieldConfig.label
: rec.campo_afectado.replace(/_/g, ' '),
newValue: rec.texto_mejora,
applied: rec.aplicada,
}
})
: [],
}
})
}, [activeChatData, activeChatId, availableFields])
const scrollToBottom = () => {
if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
)
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
})
}
}
}
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
useEffect(() => {
scrollToBottom()
}, [chatMessages, isLoading])
useEffect(() => {
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
const camposActualizados = selectedFields.filter((field) =>
input.includes(field.label),
)
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
}
}, [input, selectedFields])
useEffect(() => {
if (isLoadingConv || !lastConversation) return
const isChatStillActive = activeChats.some(
(chat) => chat.id === activeChatId,
)
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// Caso A: El chat actual ya no es válido (fue archivado o borrado)
if (activeChatId && !isChatStillActive && !isCreationMode) {
setActiveChatId(undefined)
setMessages([])
return // Salimos para evitar ejecuciones extra en este render
}
// Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar)
if (!activeChatId && activeChats.length > 0 && !isCreationMode) {
setActiveChatId(activeChats[0].id)
}
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
setActiveChatId(undefined)
}
}, [activeChats, activeChatId, isLoadingConv, messages.length])
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return if (!state?.campo_edit || availableFields.length === 0) return
const field = availableFields.find( const field = availableFields.find(
(f) => (f) =>
f.value === state.campo_edit.label || f.key === state.campo_edit.clave, f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
) )
if (!field) return
if (field && !selectedFields.some((sf) => sf.key === field.key)) {
setSelectedFields([field]) setSelectedFields([field])
}
setInput((prev) => setInput((prev) =>
injectFieldsIntoInput(prev || 'Mejora este campo:', field ? [field] : []), injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
) )
}, [availableFields]) }, [availableFields])
// 3. Lógica para el disparador ":" const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
setMessages([
{
id: 'welcome',
role: 'assistant',
content: 'Iniciando una nueva conversación. ¿En qué puedo ayudarte?',
},
])
setInput('')
setSelectedFields([])
}
const archiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
updateStatusMutation(
{ id, estado: 'ARCHIVADA' },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
if (activeChatId === id) {
setActiveChatId(undefined)
setMessages([])
setOptimisticMessage(null)
setInput('')
setSelectedFields([])
}
},
},
)
}
const unarchiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
updateStatusMutation(
{ id, estado: 'ACTIVA' },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
},
},
)
}
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value const val = e.target.value
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
setInput(val) setInput(val)
if (val.endsWith(':')) {
// Busca un ":" seguido de letras justo antes del cursor
const textBeforeCursor = val.slice(0, cursorPosition)
const match = textBeforeCursor.match(/:(\w*)$/)
if (match) {
setShowSuggestions(true) setShowSuggestions(true)
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
} else { } else {
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('')
} }
} }
@@ -131,91 +352,259 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
const baseText = input.replace(/\[[^\]]+]/g, '').trim() const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
const tags = fields.map((f) => `${f.label}`).join(' ') if (fields.length === 0) return cleaned
return `${baseText} ${tags}`.trim() const fieldLabels = fields.map((f) => f.label).join(', ')
return `${cleaned}\n[Campos: ${fieldLabels}]`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => { setSelectedFields((prev) => {
let nextFields const isSelected = prev.find((f) => f.key === field.key)
return isSelected ? prev : [...prev, field]
if (prev.find((f) => f.key === field.key)) {
nextFields = prev.filter((f) => f.key !== field.key)
} else {
nextFields = [...prev, field]
}
setInput((prevInput) =>
injectFieldsIntoInput(prevInput || 'Mejora este campo:', nextFields),
)
return nextFields
}) })
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
setInput((prev) => {
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
})
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('')
} }
const buildPrompt = (userInput: string) => {
if (selectedFields.length === 0) return userInput
const fieldsText = selectedFields const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
.map((f) => `- ${f.label}: ${f.value || '(sin contenido)'}`) if (fields.length === 0) return userInput
.join('\n')
return ` return ` ${userInput}`
${userInput || 'Mejora los siguientes campos:'}
Campos a analizar:
${fieldsText}
`.trim()
} }
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const finalPrompt = buildPrompt(rawText) const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields)
const userMsg = { setIsSending(true)
id: Date.now().toString(), setOptimisticMessage(rawText)
role: 'user',
content: finalPrompt,
}
setMessages((prev) => [...prev, userMsg])
setInput('') setInput('')
setIsLoading(true) setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setTimeout(() => { setUploadedFiles([])
const mockText = try {
'Sugerencia generada basada en los campos seleccionados...' const payload: any = {
planId: planId,
setMessages((prev) => [ content: finalPrompt,
...prev, conversacionId: activeChatId || undefined,
{
id: Date.now().toString(),
role: 'assistant',
content: `He analizado ${selectedFields
.map((f) => f.label)
.join(', ')}. Aquí tienes una propuesta:\n\n${mockText}`,
},
])
setPendingSuggestion({ text: mockText })
setIsLoading(false)
}, 1200)
} }
if (currentFields.length > 0) {
payload.campos = currentFields.map((f) => f.key)
}
const response = await sendChat(payload)
if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId)
}
await queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
setOptimisticMessage(null)
} catch (error) {
console.error('Error en el chat:', error)
// Aquí sí podrías usar un toast o un mensaje de error temporal
} finally {
// 5. CRÍTICO: Detener el estado de carga SIEMPRE
setIsSending(false)
setOptimisticMessage(null)
}
}
const totalReferencias = useMemo(() => {
return (
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
)
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const removeSelectedField = (fieldKey: string) => {
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
}
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-1">
{!showArchived ? (
activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<FileText size={16} className="shrink-0 opacity-40" />
<span
ref={editingChatId === chat.id ? editableRef : null}
contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${
editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
: 'cursor-pointer'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const newTitle = e.currentTarget.textContent || ''
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent = chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || ''
if (newTitle !== chat.nombre) {
updateTitleMutation({ id: chat.id, nombre: newTitle })
}
setEditingChatId(null)
}
}}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
>
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
</span>
{/* ACCIONES */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50)
}}
className="p-1 text-slate-400 hover:text-teal-600"
>
<Send size={12} className="rotate-45" />
</button>
<button
onClick={(e) => archiveChat(e, chat.id)}
className="p-1 text-slate-400 hover:text-amber-600"
>
<Archive size={14} />
</button>
</div>
</div>
))
) : (
/* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados
</p>
{archivedChats.map((chat) => (
<div
key={chat.id}
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
>
<Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
<button
onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
>
<RotateCcw size={14} />
</button>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
{/* PANEL DE CHAT PRINCIPAL */} {/* PANEL DE CHAT PRINCIPAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm"> <div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* NUEVO: Barra superior de campos seleccionados */} {/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3"> <div className="shrink-0 border-b bg-white p-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase"> <span className="text-[10px] font-bold text-slate-400 uppercase">
Mejorar con IA Mejorar con IA
</span> </span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
>
<Archive size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div> </div>
</div> </div>
@@ -223,43 +612,98 @@ ${fieldsText}
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => ( {!activeChatId &&
chatMessages.length === 0 &&
!optimisticMessage ? (
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus
size={48}
className="mb-4 text-slate-300"
/>
<h3 className="text-lg font-medium text-slate-900">
No hay un chat seleccionado
</h3>
<p className="text-sm text-slate-500">
Selecciona un chat del historial o crea uno nuevo para
empezar.
</p>
</div>
) : (
<>
{chatMessages.map((msg: any) => (
<div <div
key={msg.id} key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`} className={`flex max-w-[85%] flex-col ${
>
<Avatar
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
>
<AvatarFallback className="text-[10px]">
{msg.role === 'assistant' ? (
<Sparkles size={14} className="text-teal-600" />
) : (
<UserCheck size={14} />
)}
</AvatarFallback>
</Avatar>
<div
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
>
<div
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
msg.role === 'user' msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white' ? 'ml-auto items-end'
: 'rounded-tl-none border bg-white text-slate-700' : 'items-start'
}`} }`}
> >
{msg.content} <div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: `rounded-tl-none border bg-white text-slate-700 ${
// --- LÓGICA DE REFUSAL ---
msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
}`
}`}
>
{/* Icono opcional de advertencia si es refusal */}
{msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span>
</div> </div>
)}
{msg.content}
{!msg.isRefusal &&
msg.suggestions &&
msg.suggestions.length > 0 && (
<div className="mt-4">
<ImprovementCard
suggestions={msg.suggestions}
planId={planId}
currentDatos={data?.datos}
activeChatId={activeChatId}
onApplySuccess={(key) =>
removeSelectedField(key)
}
/>
</div>
)}
</div> </div>
</div> </div>
))} ))}
{isLoading && (
<div className="flex gap-2 p-4"> {optimisticMessage && (
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" /> <div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" /> <div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" /> {optimisticMessage}
</div> </div>
</div>
)}
{isSending && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
Esperando respuesta...
</span>
</div>
</div>
</div>
)}
</>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -285,38 +729,47 @@ ${fieldsText}
)} )}
</div> </div>
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */} {/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
<div className="shrink-0 border-t bg-white p-4"> <div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE (Se mantiene igual) */} {/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
Seleccionar campo para IA Resultados para "{filterQuery}"
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => ( {filteredFields.length > 0 ? (
filteredFields.map((field, index) => (
<button <button
key={field.key} key={field.key}
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50" className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
index === 0
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
: 'hover:bg-slate-50'
}`}
> >
<span className="text-slate-700 group-hover:text-teal-700"> <span>{field.label}</span>
{field.label} {index === 0 && (
<span className="font-mono text-[10px] opacity-50">
TAB
</span> </span>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)} )}
</button> </button>
))} ))
) : (
<div className="p-3 text-center text-xs text-slate-400">
No hay coincidencias
</div>
)}
</div> </div>
</div> </div>
)} )}
{/* CONTENEDOR DEL INPUT TRANSFORMADO */} {/* CONTENEDOR DEL INPUT TRANSFORMADO */}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500"> <div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{/* 1. Visualización de campos dentro del input (Tags) */} {/* 1. Visualización de campos dentro del input ) */}
{selectedFields.length > 0 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1"> <div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
@@ -342,9 +795,32 @@ ${fieldsText}
value={input} value={input}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (showSuggestions) {
if (e.key === 'Tab' || e.key === 'Enter') {
if (filteredFields.length > 0) {
e.preventDefault() e.preventDefault()
handleSend() toggleField(filteredFields[0])
}
}
if (e.key === 'Escape') {
e.preventDefault()
setShowSuggestions(false)
setFilterQuery('')
}
} else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if (
e.key === 'Backspace' &&
input === '' &&
selectedFields.length > 0
) {
setSelectedFields((prev) => prev.slice(0, -1))
}
}
if (e.key === 'Enter' && !e.shiftKey && !showSuggestions) {
e.preventDefault()
if (!isSending) handleSend()
} }
}} }}
placeholder={ placeholder={
@@ -352,25 +828,28 @@ ${fieldsText}
? 'Escribe instrucciones adicionales...' ? 'Escribe instrucciones adicionales...'
: 'Escribe tu solicitud o ":" para campos...' : 'Escribe tu solicitud o ":" para campos...'
} }
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent py-2 text-sm shadow-none focus-visible:ring-0"
/> />
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={
(!input.trim() && selectedFields.length === 0) || isLoading isSending || (!input.trim() && selectedFields.length === 0)
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700" className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
> >
<Send size={16} className="text-white" /> {isSending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* PANEL LATERAL */}
{/* PANEL LATERAL (PRESETS) - SE MANTIENE COMO LO TENÍAS */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2"> <div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800"> <h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
@@ -392,6 +871,44 @@ ${fieldsText}
))} ))}
</div> </div>
</div> </div>
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
{/* Cabecera más compacta */}
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 transition-colors hover:text-slate-600"
>
<X size={18} />
</button>
</div>
{/* Contenido con scroll interno */}
<div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds}
selectedRepositorioIds={selectedRepositorioIds}
uploadedFiles={uploadedFiles}
onToggleArchivo={(id, checked) => {
setSelectedArchivoIds((prev) =>
checked ? [...prev, id] : prev.filter((a) => a !== id),
)
}}
onToggleRepositorio={(id, checked) => {
setSelectedRepositorioIds((prev) =>
checked ? [...prev, id] : prev.filter((r) => r !== id),
)
}}
onFilesChange={(files) => {
setUploadedFiles(files)
}}
/>
</div>
</DrawerContent>
</Drawer>
</div> </div>
) )
} }

View File

@@ -160,6 +160,7 @@ function DatosGeneralesPage() {
if (!data?.datos) return if (!data?.datos) return
const datosActualizados = prepararDatosActualizados(data, campo, valor) const datosActualizados = prepararDatosActualizados(data, campo, valor)
console.log(datosActualizados)
updatePlan.mutate({ updatePlan.mutate({
planId, planId,

View File

@@ -9,9 +9,11 @@ import {
Trash2, Trash2,
Pencil, Pencil,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect, Fragment } from 'react'
import type { TipoAsignatura } from '@/data'
import type { Asignatura, LineaCurricular } from '@/types/plan' import type { Asignatura, LineaCurricular } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -60,21 +62,23 @@ const mapLineasToLineaCurricular = (
const mapAsignaturasToAsignaturas = ( const mapAsignaturasToAsignaturas = (
asigApi: Array<any> = [], asigApi: Array<any> = [],
): Array<Asignatura> => { ): Array<Asignatura> => {
return asigApi.map((asig) => ({ return asigApi.map((asig) => {
return {
id: asig.id, id: asig.id,
clave: asig.codigo, clave: asig.codigo,
nombre: asig.nombre, nombre: asig.nombre,
creditos: asig.creditos ?? 0, creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null, ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null, lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa', tipo: asig.tipo,
estado: 'borrador', estado: 'borrador',
orden: asig.orden_celda ?? 0, orden: asig.orden_celda ?? 0,
// Mapeo directo de los nuevos campos de la API // Mapeo directo de los nuevos campos de la API
hd: asig.horas_academicas ?? 0, hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0, hi: asig.horas_independientes ?? 0,
prerrequisitos: [], prerrequisitos: [],
})) }
})
} }
const lineColors = [ const lineColors = [
@@ -179,7 +183,7 @@ function MapaCurricularPage() {
const { mutate: createLinea } = useCreateLinea() const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea() const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea() const { mutate: deleteLineaApi } = useDeleteLinea()
const { data: asignaturasApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([]) const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
@@ -190,7 +194,7 @@ function MapaCurricularPage() {
const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const { mutate: updateAsignatura } = useUpdateAsignatura() const { mutate: updateAsignatura } = useUpdateAsignatura()
const [seriacionValue, setSeriacionValue] = useState<string>('unassigned') const [seriacionValue, setSeriacionValue] = useState<string>('')
useEffect(() => { useEffect(() => {
if (data?.numero_ciclos) { if (data?.numero_ciclos) {
@@ -282,9 +286,9 @@ function MapaCurricularPage() {
}, [lineas]) }, [lineas])
useEffect(() => { useEffect(() => {
if (asignaturasApi) if (asignaturaApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi)) setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
}, [asignaturasApi]) }, [asignaturaApi])
useEffect(() => { useEffect(() => {
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi)) if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
@@ -323,7 +327,17 @@ function MapaCurricularPage() {
setAsignaturas((prev) => setAsignaturas((prev) =>
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
) )
const patch = { type AsignaturaPatch = {
codigo?: TablesUpdate<'asignaturas'>['codigo']
nombre?: TablesUpdate<'asignaturas'>['nombre']
tipo?: TablesUpdate<'asignaturas'>['tipo']
creditos?: TablesUpdate<'asignaturas'>['creditos']
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas']
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
}
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre, nombre: editingData.nombre,
codigo: editingData.clave, codigo: editingData.clave,
creditos: editingData.creditos, creditos: editingData.creditos,
@@ -331,12 +345,11 @@ function MapaCurricularPage() {
horas_independientes: editingData.hi, horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo, numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId, linea_plan_id: editingData.lineaCurricularId,
tipo: editingData.tipo.toUpperCase(), // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
// datos: editingData.datos, // Si editaste algo del JSONB
} }
updateAsignatura( updateAsignatura(
{ asignaturaId: editingData.id, patch }, { asignaturaId: editingData.id, patch: patch as any },
{ {
onSuccess: () => { onSuccess: () => {
setIsEditModalOpen(false) setIsEditModalOpen(false)
@@ -504,6 +517,9 @@ function MapaCurricularPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button className="bg-teal-700 text-white hover:bg-teal-800">
<Plus size={16} className="mr-2" /> Exportar{' '}
</Button>
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length > {asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
0 && ( 0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50"> <Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
@@ -572,37 +588,33 @@ function MapaCurricularPage() {
<div className="overflow-x-auto pb-6"> <div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]"> <div className="min-w-[1500px]">
<div <div
className="mb-4 grid gap-3" className="grid gap-3"
style={{ style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`, gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
}} }}
> >
<div className="self-end px-2 text-xs font-bold text-slate-400"> <div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR LÍNEA CURRICULAR
</div> </div>
{ciclosArray.map((n) => ( {ciclosArray.map((n) => (
<div <div
key={n} key={`header-${n}`}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600" className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
> >
Ciclo {n} Ciclo {n}
</div> </div>
))} ))}
<div className="self-end text-center text-xs font-bold text-slate-400"> <div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL SUBTOTAL
</div> </div>
</div>
{lineas.map((linea, idx) => { {lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id) const sub = getSubtotalLinea(linea.id)
return ( return (
<div <Fragment key={linea.id}>
key={linea.id}
className="mb-3 grid gap-3"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div <div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${ className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
lineColors[idx % lineColors.length] lineColors[idx % lineColors.length]
@@ -630,41 +642,34 @@ function MapaCurricularPage() {
{linea.nombre} {linea.nombre}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Botón de edición que aparece en hover o si está editando */}
<button <button
onClick={() => setEditingLineaId(linea.id)} onClick={() => setEditingLineaId(linea.id)}
className={`text-slate-400 transition-opacity hover:text-teal-600 ${ className="..."
editingLineaId === linea.id
? 'opacity-0'
: 'opacity-0 group-hover:opacity-100'
}`}
> >
<Pencil size={12} /> {' '}
<Pencil size={12} />{' '}
</button> </button>
<Trash2 <Trash2
size={14}
className="cursor-pointer text-slate-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500"
onClick={() => borrarLinea(linea.id)} onClick={() => borrarLinea(linea.id)}
className="..."
size={14}
/> />
</div> </div>
</div> </div>
{ciclosArray.map((ciclo) => ( {ciclosArray.map((ciclo) => (
<div <div
key={ciclo} key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
// ANTES: onDrop={(e) => handleDrop(e, null, null)}
// AHORA: Usamos las variables 'ciclo' y 'linea.id' del map
onDrop={(e) => handleDrop(e, ciclo, linea.id)} onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2" className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
> >
{asignaturas {asignaturas
.filter( .filter(
(m) => (m) =>
m.ciclo === ciclo && m.lineaCurricularId === linea.id, m.ciclo === ciclo &&
m.lineaCurricularId === linea.id,
) )
.map((m) => ( .map((m) => (
<AsignaturaCardItem <AsignaturaCardItem
@@ -686,24 +691,21 @@ function MapaCurricularPage() {
<div>HD: {sub.hd}</div> <div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div> <div>HI: {sub.hi}</div>
</div> </div>
</div> </Fragment>
) )
})} })}
<div <div className="col-span-full my-2 border-t border-slate-200"></div>
className="mt-6 grid gap-3 border-t pt-4"
style={{ <div className="self-center p-2 font-bold text-slate-600">
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div className="p-2 font-bold text-slate-600">
Totales por Ciclo Totales por Ciclo
</div> </div>
{ciclosArray.map((ciclo) => { {ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo) const t = getTotalesCiclo(ciclo)
return ( return (
<div <div
key={ciclo} key={`footer-${ciclo}`}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]" className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
> >
<div className="font-bold text-slate-700">Cr: {t.cr}</div> <div className="font-bold text-slate-700">Cr: {t.cr}</div>
@@ -713,6 +715,7 @@ function MapaCurricularPage() {
</div> </div>
) )
})} })}
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800"> <div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
<div>{stats.cr} Cr</div> <div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div> <div>{stats.hd + stats.hi} Hrs</div>
@@ -722,7 +725,6 @@ function MapaCurricularPage() {
</div> </div>
{/* Asignaturas Sin Asignar */} {/* Asignaturas Sin Asignar */}
{/* SECCIÓN DE MATERIAS SIN ASIGNAR (Mejorada para estar siempre disponible) */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6"> <div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
@@ -938,8 +940,8 @@ function MapaCurricularPage() {
<Select <Select
value={seriacionValue} value={seriacionValue}
onValueChange={(val) => { onValueChange={(val) => {
if (val === 'unassigned') { if (val === 'none') {
setSeriacionValue('unassigned') setSeriacionValue('')
return return
} }
if (!editingData.prerrequisitos.includes(val)) { if (!editingData.prerrequisitos.includes(val)) {
@@ -948,21 +950,19 @@ function MapaCurricularPage() {
prerrequisitos: [...editingData.prerrequisitos, val], prerrequisitos: [...editingData.prerrequisitos, val],
}) })
} }
setSeriacionValue('unassigned') setSeriacionValue('')
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Seleccionar asignatura..." /> <SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="unassigned"> <SelectItem value="none">-- Sin Seriación --</SelectItem>
-- Sin Seriación --
</SelectItem>
{asignaturas {asignaturas
.filter((m) => m.id !== editingData.id) .filter((m) => m.id !== editingData.id)
.map((m) => ( .map((m) => (
<SelectItem key={m.id} value={m.clave}> <SelectItem key={m.id} value={m.id}>
{m.nombre} ({m.clave}) {m.nombre} ({m.clave})
</SelectItem> </SelectItem>
))} ))}
@@ -1003,7 +1003,7 @@ function MapaCurricularPage() {
</label> </label>
<Select <Select
value={editingData.tipo} value={editingData.tipo}
onValueChange={(val: 'obligatoria' | 'optativa') => onValueChange={(val: 'OBLIGATORIA' | 'OPTATIVA') =>
setEditingData({ ...editingData, tipo: val }) setEditingData({ ...editingData, tipo: val })
} }
> >
@@ -1011,8 +1011,8 @@ function MapaCurricularPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="obligatoria">Obligatoria</SelectItem> <SelectItem value="OBLIGATORIA">Obligatoria</SelectItem>
<SelectItem value="optativa">Optativa</SelectItem> <SelectItem value="OPTATIVA">Optativa</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -1,55 +0,0 @@
import { createFileRoute, notFound, useLocation } from '@tanstack/react-router'
import { useEffect } from 'react'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { subjects_get } from '@/data/api/subjects.api'
import { qk } from '@/data/query/keys'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
loader: async ({ context: { queryClient }, params: { asignaturaId } }) => {
try {
await queryClient.ensureQueryData({
queryKey: qk.asignatura(asignaturaId),
queryFn: () => subjects_get(asignaturaId),
})
} catch (e: any) {
// PGRST116: The result contains 0 rows (Supabase Single response error)
if (e?.code === 'PGRST116') {
throw notFound()
}
throw e
}
},
notFoundComponent: () => {
return (
<NotFoundPage
title="Materia no encontrada"
message="La asignatura que buscas no existe o fue eliminada."
/>
)
},
component: RouteComponent,
})
function RouteComponent() {
// const { planId, asignaturaId } = Route.useParams()
const location = useLocation()
// Confetti al llegar desde creación
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
}
}, [location.state])
return (
<div>
<AsignaturaDetailPage></AsignaturaDetailPage>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia',
)({
component: RouteComponent,
})
function RouteComponent() {
return <BibliographyItem />
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { ContenidoTematico } from '@/components/asignaturas/detalle/ContenidoTematico'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/contenido',
)({
component: RouteComponent,
})
function RouteComponent() {
return <ContenidoTematico />
}

View File

@@ -0,0 +1,85 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento',
)({
component: RouteComponent,
})
function RouteComponent() {
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
return url
})
} catch (error) {
console.error('Error cargando PDF:', error)
} finally {
setIsLoading(false)
}
}, [planId])
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'documento_sep.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleRegenerate = async () => {
try {
setIsRegenerating(true)
await loadPdfPreview()
} finally {
setIsRegenerating(false)
}
}
return (
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { HistorialTab } from '@/components/asignaturas/detalle/HistorialTab'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/historial',
)({
component: RouteComponent,
})
function RouteComponent() {
return <HistorialTab />
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { IAAsignaturaTab } from '@/components/asignaturas/detalle/IAAsignaturaTab'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
)({
component: RouteComponent,
})
function RouteComponent() {
return <IAAsignaturaTab />
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/',
)({
component: DatosGeneralesPage,
})
function DatosGeneralesPage() {
return <AsignaturaDetailPage />
}

View File

@@ -0,0 +1,273 @@
import {
createFileRoute,
Outlet,
Link,
useLocation,
useParams,
useRouterState,
} from '@tanstack/react-router'
import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: AsignaturaLayout,
})
function EditableHeaderField({
value,
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
const textValue = String(value)
// Manejador para cuando el usuario termina de editar (pierde el foco)
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText
if (newValue !== textValue) {
onSave(newValue)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
contentEditable
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
>
{textValue}
</span>
)
}
interface DatosPlan {
nombre?: string
}
function AsignaturaLayout() {
const location = useLocation()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
// Sincronizar cuando llegue la API
useEffect(() => {
if (asignaturaApi) {
setHeaderData({
codigo: asignaturaApi.codigo ?? '',
nombre: asignaturaApi.nombre,
creditos: asignaturaApi.creditos,
ciclo: asignaturaApi.numero_ciclo ?? 0,
})
}
}, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => {
const newData = { ...headerData, [key]: value }
setHeaderData(newData)
const patch: Record<string, any> =
key === 'ciclo'
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({
asignaturaId,
patch,
})
}
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
// Confetti al llegar desde creación IA
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title)
}
}, [location.state])
if (loadingAsig) {
return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
Cargando asignatura...
</div>
)
}
// Si no hay datos y no está cargando, algo falló
if (!asignaturaApi) return null
return (
<div>
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes/$planId/asignaturas"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="h-4 w-4" /> Volver al plan
</Link>
<div className="flex items-start justify-between gap-6">
<div className="space-y-3">
{/* CÓDIGO EDITABLE */}
<Badge className="border border-blue-700 bg-blue-900/50">
<EditableHeaderField
value={headerData.codigo}
onSave={(val) => handleUpdateHeader('codigo', val)}
/>
</Badge>
{/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre ?? ''}
</span>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
</span>
<span>créditos</span>
</Badge>
{/* SEMESTRE EDITABLE */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.ciclo}
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturaApi.tipo}</Badge>
</div>
</div>
</div>
</section>
{/* TABS */}
<nav className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<div className="flex justify-center gap-8">
{[
{ label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'asignaturaIa' },
{ label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' },
].map((tab) => {
const isActive =
tab.to === ''
? pathname === `/planes/${planId}/asignaturas/${asignaturaId}`
: pathname.includes(tab.to)
return (
<Link
key={tab.label}
to={
(tab.to === ''
? '/planes/$planId/asignaturas/$asignaturaId'
: `/planes/$planId/asignaturas/$asignaturaId/${tab.to}`) as any
}
from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }}
className={`border-b-2 py-3 text-sm font-medium ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
{tab.label}
</Link>
)
})}
</div>
</div>
</nav>
<div className="mx-auto max-w-7xl px-6 py-8">
<Outlet />
</div>
</div>
)
}

View File

@@ -1,3 +1,5 @@
import type { Tables } from './supabase'
export type PlanStatus = export type PlanStatus =
| 'borrador' | 'borrador'
| 'revision' | 'revision'
@@ -12,9 +14,9 @@ export type TipoPlan =
| 'Doctorado' | 'Doctorado'
| 'Especialidad' | 'Especialidad'
export type TipoAsignatura = 'obligatoria' | 'optativa' | 'troncal' export type TipoAsignatura = Tables<'asignaturas'>['tipo']
export type AsignaturaStatus = 'borrador' | 'revisada' | 'aprobada' export type AsignaturaStatus = Tables<'asignaturas'>['estado']
export interface Facultad { export interface Facultad {
id: string id: string

View File

@@ -1,4 +1,4 @@
export type Json = export type Json =
| string | string
| number | number
| boolean | boolean
@@ -7,11 +7,6 @@ export type Json =
| Array<Json> | Array<Json>
export type Database = { export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: '12.2.3 (519615d)'
}
graphql_public: { graphql_public: {
Tables: { Tables: {
[_ in never]: never [_ in never]: never
@@ -93,11 +88,11 @@ export type Database = {
asignatura_hash: string | null asignatura_hash: string | null
codigo: string | null codigo: string | null
contenido_tematico: Json contenido_tematico: Json
conversation_id: string | null
creado_en: string creado_en: string
creado_por: string | null creado_por: string | null
creditos: number creditos: number
datos: Json datos: Json
estado: Database['public']['Enums']['estado_asignatura']
estructura_id: string | null estructura_id: string | null
horas_academicas: number | null horas_academicas: number | null
horas_independientes: number | null horas_independientes: number | null
@@ -117,11 +112,11 @@ export type Database = {
asignatura_hash?: string | null asignatura_hash?: string | null
codigo?: string | null codigo?: string | null
contenido_tematico?: Json contenido_tematico?: Json
conversation_id?: string | null
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
creditos: number creditos: number
datos?: Json datos?: Json
estado?: Database['public']['Enums']['estado_asignatura']
estructura_id?: string | null estructura_id?: string | null
horas_academicas?: number | null horas_academicas?: number | null
horas_independientes?: number | null horas_independientes?: number | null
@@ -141,11 +136,11 @@ export type Database = {
asignatura_hash?: string | null asignatura_hash?: string | null
codigo?: string | null codigo?: string | null
contenido_tematico?: Json contenido_tematico?: Json
conversation_id?: string | null
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
creditos?: number creditos?: number
datos?: Json datos?: Json
estado?: Database['public']['Enums']['estado_asignatura']
estructura_id?: string | null estructura_id?: string | null
horas_academicas?: number | null horas_academicas?: number | null
horas_independientes?: number | null horas_independientes?: number | null
@@ -394,6 +389,138 @@ export type Database = {
}, },
] ]
} }
conversaciones_asignatura: {
Row: {
archivado_en: string | null
archivado_por: string | null
asignatura_id: string
conversacion_json: Json
creado_en: string
creado_por: string | null
estado: Database['public']['Enums']['estado_conversacion']
id: string
intento_archivado: number
openai_conversation_id: string
}
Insert: {
archivado_en?: string | null
archivado_por?: string | null
asignatura_id: string
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
openai_conversation_id: string
}
Update: {
archivado_en?: string | null
archivado_por?: string | null
asignatura_id?: string
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
openai_conversation_id?: string
}
Relationships: [
{
foreignKeyName: 'conversaciones_asignatura_archivado_por_fkey'
columns: ['archivado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_asignatura_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'asignaturas'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_asignatura_creado_por_fkey'
columns: ['creado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
]
}
conversaciones_plan: {
Row: {
archivado_en: string | null
archivado_por: string | null
conversacion_json: Json
creado_en: string
creado_por: string | null
estado: Database['public']['Enums']['estado_conversacion']
id: string
intento_archivado: number
nombre: string | null
openai_conversation_id: string
plan_estudio_id: string
}
Insert: {
archivado_en?: string | null
archivado_por?: string | null
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
nombre?: string | null
openai_conversation_id: string
plan_estudio_id: string
}
Update: {
archivado_en?: string | null
archivado_por?: string | null
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
nombre?: string | null
openai_conversation_id?: string
plan_estudio_id?: string
}
Relationships: [
{
foreignKeyName: 'conversaciones_plan_archivado_por_fkey'
columns: ['archivado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_plan_creado_por_fkey'
columns: ['creado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_plan_plan_estudio_id_fkey'
columns: ['plan_estudio_id']
isOneToOne: false
referencedRelation: 'planes_estudio'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_plan_plan_estudio_id_fkey'
columns: ['plan_estudio_id']
isOneToOne: false
referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id']
},
]
}
estados_plan: { estados_plan: {
Row: { Row: {
clave: string clave: string
@@ -677,7 +804,6 @@ export type Database = {
actualizado_en: string actualizado_en: string
actualizado_por: string | null actualizado_por: string | null
carrera_id: string carrera_id: string
conversation_id: string | null
creado_en: string creado_en: string
creado_por: string | null creado_por: string | null
datos: Json datos: Json
@@ -698,7 +824,6 @@ export type Database = {
actualizado_en?: string actualizado_en?: string
actualizado_por?: string | null actualizado_por?: string | null
carrera_id: string carrera_id: string
conversation_id?: string | null
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
datos?: Json datos?: Json
@@ -719,7 +844,6 @@ export type Database = {
actualizado_en?: string actualizado_en?: string
actualizado_por?: string | null actualizado_por?: string | null
carrera_id?: string carrera_id?: string
conversation_id?: string | null
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
datos?: Json datos?: Json
@@ -1085,10 +1209,25 @@ export type Database = {
} }
} }
Functions: { Functions: {
append_conversacion_asignatura: {
Args: { p_append: Json; p_id: string }
Returns: undefined
}
append_conversacion_plan: {
Args: { p_append: Json; p_id: string }
Returns: undefined
}
unaccent: { Args: { '': string }; Returns: string } unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string }
} }
Enums: { Enums: {
estado_asignatura:
| 'borrador'
| 'revisada'
| 'aprobada'
| 'generando'
| 'fallida'
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA' estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
fuente_cambio: 'HUMANO' | 'IA' fuente_cambio: 'HUMANO' | 'IA'
nivel_plan_estudio: nivel_plan_estudio:
@@ -1261,6 +1400,14 @@ export const Constants = {
}, },
public: { public: {
Enums: { Enums: {
estado_asignatura: [
'borrador',
'revisada',
'aprobada',
'generando',
'fallida',
],
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'], estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
fuente_cambio: ['HUMANO', 'IA'], fuente_cambio: ['HUMANO', 'IA'],
nivel_plan_estudio: [ nivel_plan_estudio: [