202 Commits

Author SHA1 Message Date
658b2e245c Merge pull request 'Que no haga scroll fix #193' (#199) from issue/193-que-no-haga-scroll into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
Reviewed-on: #199
2026-03-19 20:20:45 +00:00
30562fead0 Merge branch 'main' into issue/193-que-no-haga-scroll 2026-03-19 20:20:30 +00:00
2b91004129 Que no haga scroll #193 2026-03-19 14:18:21 -06:00
96a045dc67 Añadir staticwebapp.config.json
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m7s
2026-03-19 13:58:43 +00:00
a8229f12d5 Actualizar .gitea/workflows/deploy.yaml
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
2026-03-19 13:56:12 +00:00
dd4ac5374a Añadir .gitea/workflows/deploy.yaml
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 48s
2026-03-18 22:39:50 +00:00
670e0b1d14 Merge pull request 'Que se guarden las seriaciones fix #175 fix #151 fix #180' (#191) from issue/175-que-se-guarden-las-seriaciones into main
Reviewed-on: #191
2026-03-18 22:10:20 +00:00
93fe247a19 Merge branch 'main' into issue/175-que-se-guarden-las-seriaciones 2026-03-18 22:10:09 +00:00
32ebfde9ed Que se guarden las seriaciones
fix #175
fix #151
fix #180
2026-03-18 15:48:49 -06:00
32f0c4c4d4 fix #189: Se arregló un bug en el que no se podía poner espacios al editar la editorial de una referencia 2026-03-18 14:48:55 -06:00
6a520ef6b1 close #186: se agregó botón de Nueva Unidad al inicio del contenido temático 2026-03-17 15:45:36 -06:00
25d451839e hotfix: se mejoró UX modificando el tipo de cursor que se muestra al hacer hover sobre elementos interactuables y se restringió el input de horas estimadas a un rango de 0 a 200 pero permitiendo medias horas 2026-03-17 13:33:20 -06:00
fe8f1d4753 Merge pull request 'contenido es ordenable, botón de nueva unidad después de cada unidad, mejora de UX con unidades expandidas' (#185) from issue/182-funcionalidad-de-reacomodo-e-insercin-aleatoria-de into main
Reviewed-on: #185
2026-03-17 18:47:56 +00:00
518b1124d8 close #182: se implementó la lista de unidades como sortable con dnd-kit y se solucionó el parpadeo al reordenar.
Se convirtió la lista de unidades en un sortable controlado usando @dnd-kit/react; al arrastrar desde el GripVertical se reordenan las unidades en la UI y persiste el orden en la base de datos.
Se colocó el botón "Nueva unidad" como un overlay que aparece debajo de cada unidad al hacer hover (posición bottom) y su clic inserta una unidad entre las existentes o al final si se pulsa después de la última, sin desplazar el layout.
Se hizo que, al cargar el componente por primera vez, la primera unidad quede desplegada automáticamente; una vez que el usuario realiza cualquier modificación y se guarda, se empieza a respetar el conjunto de unidades que el usuario tenga expandidas (la bandera de inicialización se activa durante la persistencia).
Se arregló un bug en el que al reordenar la lista de unidades sucedía una recarga de la lista.
Por qué ocurría el parpadeo y cómo fue arreglado:
- Causa: tras el reorder optimista la UI quedaba actualizada, pero cuando la lista fresca llegaba del servidor un useEffect reasignaba IDs por posición (índice), provocando que React creyera que los elementos eran nuevos, se destruyeran y se volvieran a montar — de ahí el "parpadeo" y la pérdida del estado de los acordiones.
- Solución (Escudo Optimista): se añadieron dos defensas.
  1) Escudo de aborto temprano: si el payload actual (UI optimista) y el payload entrante del servidor son idénticos (JSON), se aborta el procesamiento del useEffect para evitar re-render innecesario.
  2) Reciclaje por contenido: cuando los datos difieren, las IDs locales se reciclan buscando coincidencias por contenido (título) en lugar de por posición, de modo que cada unidad conserva su ID real aunque cambie de lugar; así React mueve las tarjetas en vez de destruirlas.

Con esto, el reorder es estable, el overlay de inserción funciona sin alterar el flow visual y el estado de expansiones se preserva tras ediciones del usuario.
2026-03-17 12:36:14 -06:00
8bdaf935ca fix #181: al darle a siguiente desde estructura, se fuerza la regeneración de citas 2026-03-13 12:38:02 -06:00
0d636cbf3b Merge pull request 'Consistencia y mensajes del chat de la IA fix #179 fix #178' (#183) from issue/179-consistencia-y-mensajes-del-chat-de-la-ia into main
Reviewed-on: #183
2026-03-13 18:17:31 +00:00
82d047e1c2 Consistencia y mensajes del chat de la IA
fix #179
fix #178
2026-03-13 12:17:01 -06:00
674c8a6bee Merge pull request 'Propuesta de vista para elegir entre sugerencias de bibliografia encontradas en línea y coincidencias encontradas en la biblioteca' (#177) from issue/169-crear-vista-de-validacin-en-biblioteca-la-salle into main
Reviewed-on: #177
2026-03-12 22:18:51 +00:00
3acea813b6 close #169: Se actualizó el modal de nueva bibliografía y se añadió el paso "Biblioteca"
- Se unificó el stepper en cinco pasos y se configuró para omitir el paso "Biblioteca" cuando el método sea MANUAL.
- Se añadió el paso "Biblioteca" con un accordion múltiple para comparar cada sugerencia con alternativas de la biblioteca; se eliminaron los estados de "Buscando" y su badge.
- Se incorporaron tres conjuntos hardcodeados de coincidencias (0, 2 y 5) que se asignan al azar si la sugerencia no trae datos de biblioteca; si no hay coincidencias la sugerencia se marca automáticamente como mantenida.
- Se implementó BookSelectionAccordion para elegir conservar la sugerencia o sustituirla por una coincidencia; se preservó el estilo visual de las opciones.
- Se añadieron validaciones y comportamientos de navegación: bloqueo de avance si quedan accordions por revisar, apertura y scroll al primer accordion sin resolver, y salto del paso "Biblioteca" en modo MANUAL.
2026-03-12 16:17:58 -06:00
e68954e03c Merge pull request 'Que pueda generar el contenido temático Y el sistema de evaluación fix #174 fix #163 fix # 173' (#176) from issue/174-que-pueda-generar-el-contenido-temtico-y-el-sistem into main
Reviewed-on: #176
2026-03-12 22:05:41 +00:00
296fbfee79 Merge branch 'main' into issue/174-que-pueda-generar-el-contenido-temtico-y-el-sistem 2026-03-12 22:05:31 +00:00
a55910c226 Que pueda generar el contenido temático Y el sistema de evaluación fix #174
fix #163
fix # 173
2026-03-12 16:04:53 -06:00
88c6dc6b4d wip 2026-03-12 13:47:56 -06:00
03caa791ad hotfix: correccion de columnas de bibliografia 2026-03-12 12:08:22 -06:00
577daaff03 Actualizar src/data/api/subjects.api.ts 2026-03-12 15:06:04 +00:00
f75680e8dd Merge pull request 'Se homologa vista y funcionalidades de chat de asignatura ( Guardar cambios o mejora es decir aplicar mejora, crear conversaciones, renombrar conversaciones, archivar conversaciones visualizar modal de referencias)' (#172) from issue/160-chats-de-ia-en-segundo-plano-para-asignaturas into main
Reviewed-on: #172
2026-03-11 22:07:32 +00:00
0b7f45c150 Merge branch 'main' into issue/160-chats-de-ia-en-segundo-plano-para-asignaturas 2026-03-11 22:07:19 +00:00
56ac8c0155 Se homologa vista y funcionalidades de chat de asignatura ( Guardar cambios o mejora es decir aplicar mejora, crear conversaciones, renombrar conversaciones, archivar conversaciones visualizar modal de referencias) 2026-03-11 16:06:26 -06:00
8ecb0f205a Merge pull request 'Se añadieron validaciones y mejoras en el modal de nueva bibliografía, incluida la validacion de al menos tres caracteres para el query' (#171) from issue/170-validacin-de-mnimo-3-caracteres-en-la-bsqueda-de-b into main
Reviewed-on: #171
2026-03-11 22:04:26 +00:00
ea842ee46c close #170: se añadieron validaciones y mejoras en el modal de nueva bibliografía
-Se implementaron restricciones en SugerenciasStep: el campo de búsqueda se limitó a 200 caracteres y la generación quedó bloqueada si hay 20 o más referencias seleccionadas; se añadió tooltip en el botón de generar cuando la query tiene menos de 3 caracteres.
-Se reforzaron validaciones en FormatoYCitasStep y DatosBasicosManualStep: el título se trim-eó y se forzó a no quedar vacío (max 500 caracteres); si un título queda vacío se hace scroll al input/card, se muestra mensaje de error junto al label y se resalta el input; autores se limitó a 2000 caracteres; editorial a 300 caracteres; ISBN a 20 caracteres; el año se convirtió en input numérico permitiendo vacío o un año de 4 dígitos entre 1450 y el año actual +1.
-Se añadieron checkboxes "Año aproximado" y "En prensa" (mutuamente excluyentes): "En prensa" deshabilita el input de año y se marca el estado para citeproc; "Año aproximado" se envía como circa en issued.
-Al generar CSL se incluyeron las propiedades issued.circa y status ('in press') según los flags del ref.
-En ResumenStep se añadieron advertencias por referencia cuando falte autor(es), año (si no está "en prensa"), editorial o ISBN.
-Se corrigieron detalles de UX en edición de autores para preservar saltos de línea y se añadieron handlers para evitar errores de validación al mover entre pasos.
2026-03-11 16:03:05 -06:00
11369ce792 Límite de al menos 3 caracteres y tooltip en boton de generar sugerencias 2026-03-11 13:47:54 -06:00
78471c19d9 Merge pull request 'Observaciones corregidas' (#168) from issue/164-obervaciones-en-la-bibliografa into main
Reviewed-on: #168
2026-03-10 23:04:07 +00:00
3e8b8cd011 Merge pull request 'Se agrega funcionalidad de mensajes en segundo plano y webhook de la respuesta de ia se homologa vista como en planes de estudios' (#167) from issue/160-chats-de-ia-en-segundo-plano-para-asignaturas into main
Reviewed-on: #167
2026-03-10 22:09:42 +00:00
9eb7aae7d0 Merge branch 'main' into issue/160-chats-de-ia-en-segundo-plano-para-asignaturas 2026-03-10 22:09:36 +00:00
e5afaa0c7c Se agrega funcionalidad de mensajes en segundo plano y webhook de la respuesta de ia se homologa vista como en planes de estudios 2026-03-10 16:08:36 -06:00
06bae3ba3e Se añadieron enlaces a las páginas de los libros 2026-03-10 15:18:57 -06:00
614ef3ffaf El textarea de los autores ya te permite añadir más autores 2026-03-10 14:37:07 -06:00
2c0c9e0ba4 Se colocaron subtítulos y se editan los campos de la referencia individualmente 2026-03-10 14:34:56 -06:00
a07304c555 Merge pull request 'Se permite obtener resultados de un solo idioma, se obtienen también de Open Library, y se ordenan de manera descendente por año de publicación' (#162) from issue/150-propuesta-de-biliografas into main
Reviewed-on: #162
2026-03-09 23:06:28 +00:00
ab2510ba1c Integrada la búsqueda de bibliografía ahora también con Open Library y permitiendo obtener resultados de un idioma
- Se actualizó el contrato de búsqueda para enviar términos y parámetros por endpoint (Google y Open Library), y se consumió una respuesta unificada con origen por resultado.
- Se reemplazó el control de cantidad por un selector de idioma, y se mapearon los códigos a ISO 639-1 (Google) e ISO 639-2 (Open Library).
- Se forzó la obtención de resultados más recientes (orderBy="newest" y sort="new") y se ordenaron los resultados en frontend por año de publicación descendente, sin importar el endpoint.
- Se etiquetó cada sugerencia con un badge de origen (Google u Open Library).
2026-03-09 17:03:47 -06:00
4624c9add1 Merge pull request 'Chats de ia en segundo plano para asignaturas #160' (#161) from issue/160-chats-de-ia-en-segundo-plano-para-asignaturas into main
Reviewed-on: #161
2026-03-09 22:31:47 +00:00
1b178dd2a8 Chats de ia en segundo plano para asignaturas #160 2026-03-09 16:25:58 -06:00
203e8608a2 Merge pull request 'Que haya chat de la IA #149' (#159) from issue/149-que-haya-chat-de-la-ia into main
Reviewed-on: #159
2026-03-09 20:18:30 +00:00
a9f38e6d72 Se agrega realtime para chat de ia plan 2026-03-09 14:02:08 -06:00
2c594fb9f7 Hotfix: generación de citas en orden 2026-03-07 06:33:39 +00:00
98be1a0405 close #150: Se implementó el modal de “Agregar Bibliografía” con búsqueda en línea, generación de citas y tipado fuerte
- Se creó el modal de “Agregar Bibliografía” como ruta-modal y se enlazó desde el botón correspondiente con estilo consistente.
- Se implementó la búsqueda de sugerencias en línea mediante Edge Function y se conservó únicamente lo seleccionado al regenerar sugerencias.
- Se replicó el tooltip de “seleccionadas” con control total: se mostró solo en la primera generación y se permitió cerrarlo únicamente con el tache.
- Se integró la generación de citas con citeproc-js y se cargaron los recursos CSL/locale desde archivos locales en public/, usando BASE_URL.
- Se decodificaron entidades HTML en las citas generadas (p. ej., & → &).
- Se habilitó la regeneración forzada de citas por formato y se conservaron las citas (incluidas ediciones) al alternar formatos.
- Se mejoró la UI: se usó textarea autoajustable para citas y se estiró el select de tipo a ancho completo en sm+; se validó cantidad 1–40 o vacío (con deshabilitado del botón).
- Se tipó fuertemente la inserción a bibliografia_asignatura y se tiparon source/tipo en las referencias conforme a los tipos de Supabase.
2026-03-06 19:58:32 -06:00
2165d4a976 Generación de sugerencias y persistencia en BDD funcional. Falta afinar detalles 2026-03-06 17:58:40 -06:00
772f3b6750 Se prepara chat asignaturas para ia 2026-03-06 13:33:38 -06:00
e84e0abe8d Se agregan hooks para manejo de la ia en asignaturas 2026-03-05 09:09:39 -06:00
37fab3ead6 refactor: Update CriterioEvaluacionRow structure and related logic for consistency 2026-03-04 15:49:43 -06:00
fa200acbfd fix #148: Refactorización para limpieza y generalidad 2026-03-04 14:53:23 -06:00
020caf4e68 Sistema de Evaluación AHora está ligado a Criterios de evaluación
- En los datos generales se renderizan como texto plano los criterios de evaluación.
- Si le picas a editar los Criterios de evaluación te dirige a Sistema de evaluación y lo pone en modo de edición.
- La infocard de SIstema de evaluación se edita adecuadamente y persiste en la BDD
2026-03-04 14:15:22 -06:00
896c694a85 Merge pull request 'Documento de asignaturas #155' (#156) from issue/155-documento-de-asignaturas into main
Reviewed-on: #156
2026-03-04 17:05:49 +00:00
990daf5786 Documento de asignaturas
fix #155
2026-03-04 11:04:30 -06:00
c1197413db Merge pull request 'Se corrige bueg en hook de asignaturas en columna version' (#154) from issue/152-ajuste-de-vista-para-las-tablas into main
Reviewed-on: #154
2026-03-04 15:40:23 +00:00
bf2b8a9b6e Se corrige bug en version en hook de asignaturas 2026-03-04 09:39:31 -06:00
d6ecee7549 Merge pull request 'Ajuste de vista para las tablas #152' (#153) from issue/152-ajuste-de-vista-para-las-tablas into main
Reviewed-on: #153
2026-03-04 15:25:44 +00:00
66bbf8ae17 Ajuste de vista para las tablas
fix #152
2026-03-04 09:25:10 -06:00
6012d0ced8 Ajuste de vista para las tablas #152 2026-03-04 09:24:27 -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
846e3abf74 Propuesta de placeholders para descripción del enfoque académico e instrucciones adicionales para la IA. fix #23 2026-02-09 19:18:04 +00:00
e646125116 Merge pull request 'Que no sean INPUTS #72' (#86) from issue/72-que-no-sean-inputs into main
Reviewed-on: #86
2026-02-06 22:01:49 +00:00
417dec8c9b Merge branch 'main' into issue/72-que-no-sean-inputs 2026-02-06 22:01:38 +00:00
f5cab5139a Merge pull request 'Que el renderizado no dependa de los query params' (#81) from issue/80-deshacerse-de-todos-estos-query-params-de-la-url into main
Reviewed-on: #81
2026-02-06 22:01:24 +00:00
1caa5bef06 Merge branch 'main' into issue/80-deshacerse-de-todos-estos-query-params-de-la-url 2026-02-06 22:01:11 +00:00
581dc566bc Que no sean INPUTS
fix #72
2026-02-06 15:54:40 -06:00
31a47934e5 Se corrige limite de 200 y wrap en titulo validaciones en modal de adeicion de matria en mapa 2026-02-06 14:42:38 -06:00
958b558111 Se limitaron el número de caracteres y de digitos en los inputs de los wizards 2026-02-06 12:52:54 -06:00
1f78284fb6 Orden de listado de planes issue #71
Fix #71: ahora los planes se listan por orden de creación descendente (los más recientes primero)
2026-02-06 11:20:13 -06:00
b45aa4b59c Merge branch 'main' into issue/80-deshacerse-de-todos-estos-query-params-de-la-url 2026-02-06 10:31:58 -06:00
09d8392a28 Deshacerse de todos estos query params de la URL
fix #80
2026-02-06 10:29:08 -06:00
016f076e5e Merge pull request 'Guardado automático #53' (#68) from issue/53-guardado-automtico into main
Reviewed-on: #68
2026-02-06 13:34:51 +00:00
43aed3fb47 Merge branch 'main' into issue/53-guardado-automtico 2026-02-06 13:34:31 +00:00
a6a94fa42b WIP: Guardado automático
fix #53
fix #68
2026-02-05 14:09:55 -06:00
b1a233fa8c Feat: generación IA de asignaturas, navegación con confetti y ajustes de API
closes #63:
- Añadido AIGenerateSubjectInput y nueva implementación ai_generate_subject que envía FormData (soporta archivosAdjuntos) al Edge Function.
- Creado hook useGenerateSubjectAI (mutation) y usado en WizardControls de asignaturas para generar la asignatura vía IA.
- WizardControls (asignaturas) construye el payload IA, invoca la mutación y navega al detalle de la asignatura creada pasando state.showConfetti para lanzar confetti.
- Ajustes en subjects.api.ts (nombres de endpoint, tipos y envío de datos) y sincronización de tipos en WizardControls (plan y campos básicos).
- Ruta de detalle de asignatura ($asignaturaId) ahora lee location.state.showConfetti y dispara lateralConfetti al entrar.
- Eliminado el prop onCreate del modal de nueva asignatura (la creación IA se gestiona internamente).
2026-02-05 13:41:10 -06:00
f00fabeac5 Fix #63: mostrar mensaje real de error de Edge Function en UI
- Mejorar invokeEdge para parsear el body JSON de errores HTTP de las Edge Functions y extraer un message humano (soporta { error: { message } }, { error: "..." } y { message: "..." }).
- EdgeFunctionError ahora incluye status y details; se manejan también FunctionsRelayError y FunctionsFetchError con mensajes más descriptivos.
- Ajustes en el front: WizardControls muestra el mensaje real del error (no el genérico "Edge Function returned a non-2xx status code"), y se corrige navegación/logging tras crear plan IA (uso de `plan` en vez de `data` y `navigate` a `/planes/{plan.id}`).
- Actualización de types/API: renombrados campos en AIGeneratePlanInput para alinear nombres (descripcionEnfoqueAcademico, instruccionesAdicionalesIA).
2026-02-05 13:41:09 -06:00
c82fac52f7 Refactor: unifica wizards con WizardLayout/WizardResponsiveHeader y convierte asignaturas en layout con Outlet
- Se introdujo un layout genérico de wizard (WizardLayout) con headerSlot/footerSlot y se migraron los modales de Nuevo Plan y Nueva Asignatura a esta estructura usando defineStepper.
- Se creó y reutilizó WizardResponsiveHeader para un encabezado responsivo consistente (progreso en móvil y navegación en escritorio) en ambos wizards.
- Se homologó WizardControls del wizard de asignaturas para alinearlo al patrón del wizard de planes (props onPrev/onNext, flags de disable, manejo de error/loading y creación).
- Se mejoró la captura de datos en el wizard de asignatura: créditos como flotante con 2 decimales, placeholders/estilos en inputs/selects y uso de catálogo real de estructuras vía useSubjectEstructuras con qk.estructurasAsignatura.
- Se reorganizó la sección de asignaturas del detalle del plan: el contenido del antiguo index se movió a asignaturas.tsx como layout y se agregó <Outlet />; navegación a “nueva asignatura” ajustada al path correcto.
2026-02-05 13:41:09 -06:00
db5465032e Guardado automático
fix #53
2026-02-04 16:05:05 -06:00
fafe90e5e8 Merge pull request 'En el mapa curricular editar los nombres de las líneas curriculares #57' (#65) from issue/57-en-el-mapa-curricular-editar-los-nombres-de-las-ln into main
Reviewed-on: #65
2026-02-04 20:34:07 +00:00
0e9648d61a En el mapa curricular editar los nombres de las líneas curriculares fix #57 2026-02-04 14:29:46 -06:00
bd8bef142a Merge remote-tracking branch 'origin/issue/45-integrar-el-wizard-de-creacin-de-materia' into issue/42-que-tenga-persistencia-el-plan-de-estudios 2026-02-04 07:29:35 -06:00
261dec7fa9 Se agrega persistencia en tab de datos y mapa curricular
fix #42
fix #54
2026-02-03 16:05:05 -06:00
1acb18711f Reintegración con main. Corrección de errores de fetch. Sincronización con la base de datos remota 2026-02-03 15:10:09 -06:00
f046bdcc04 Refactorización de wizards para consistencia, reusabilidad y mantenibilidad 2026-02-03 13:13:44 -06:00
12c572a442 Reorganización de archivos y enlace a wizard de creación de asignatura 2026-02-03 13:13:44 -06:00
64d9aa336f Se agrega persistencia a planes en datos, se arregla bug de nombre de claves en asignaturas, se cambia en historial clves por los titulos corresppndientes 2026-01-30 15:51:43 -06:00
c27f05c5f6 Ahora solo se muestran los círculos con palomita si el archivo/repositorio está seleccionado
fix #61
2026-01-30 13:34:16 -06:00
efab8eb2e4 Colocar el nombre de la Facultad/Carrera en el Resumen de la creación de los planes
fix #56:
Ya se muestra el nombre de la facultad y de la carrera.
2026-01-30 13:21:11 -06:00
867ecc53e0 Que se pueda escribir en el campo de Instrucciones adicionales para la IA
fix #59:
El bug se debía a que en la funcion onChange se hacia referencia al valor a cambiar con 'I' mayúscula en vez de minúscula.
2026-01-30 12:50:31 -06:00
4d8f7d7b41 Merge branch 'main' of https://github.lci.ulsa.mx/Guillermo.Arrieta/acad-ia-2 2026-01-30 12:43:54 -06:00
36a369a207 Error de CORS ai-generate-plan
fix #60:
El problema se solucionó del lado del backend perimitiendo el header 'x-supabase-client-platform'. Adicionalmente, al crear un plan ya no se redirige a planes/$planId/datos, sino a planes/$planId.
2026-01-30 12:40:53 -06:00
2185901c7a Merge pull request 'Refactorizar a Materias' (#55) from issue/50-se-prohbe-usar-la-palabra-materia into main
Reviewed-on: #55
2026-01-30 15:44:17 +00:00
d0b05256b0 refactor: rename Materia to Asignatura across the codebase
- Updated type definitions and interfaces to replace 'Materia' with 'Asignatura'.
- Refactored components and routes to reflect the new naming convention.
- Adjusted related types and constants for consistency.
- Removed the old Materia type definition and added Asignatura type definition.
- Ensured all references in UI components and logic are updated accordingly.

fix #50
2026-01-30 08:13:30 -06:00
2c702d7d67 Se corrige bug en asignaturas 2026-01-29 14:45:21 -06:00
a67fd72cb7 Merge pull request 'Redirección gobernada por la estructura' (#52) from fix/Persistencia into main
Reviewed-on: #52
2026-01-29 15:52:28 +00:00
071f819341 Redirección gobernada por la estructura
fix #51
2026-01-29 09:33:50 -06:00
8786aaae25 Arreglado orden de renderizado de campos 2026-01-28 18:08:00 -06:00
9065899616 bugfix: el enlace a Datos generales aparecía como activo aunque no se estuviera en ese tab 2026-01-28 16:14:53 -06:00
9cad2a0f62 Merge branch 'fix/incidencias' into feat/not-found-pages 2026-01-28 14:25:54 -06:00
dc85e2c946 Redirección de plan de estudios
fix #22
2026-01-28 14:22:37 -06:00
4e00262ab0 Redirección de plan de estudios y arreglo de placeholders en datos
close #22:
Al darle clic a un plan te lleva al index de planes/$planId, el cual es ahora la tab de datos.
Al darle al enlace de volver al plan desde el detalle de la asignatura, ya te redirige a planes/$planId/materias.
Se cambió el estilo de los placeholders en la tab de datos del detalle de plan, y ahora solo se muestra el primer ejemplo.
2026-01-28 14:06:17 -06:00
35ea4caa39 Fallback elegante de vista no encontrada
close #44:
Se creó la NotFoundPage y se utiliza en __root con el notFoundComponent.
Se agregó la lógica del loader tanto de plan de estudios como de asignaturas.
Se agregó el NotFoundComponent para el detalle de plan de estudios y el de asignaturas
2026-01-28 12:58:50 -06:00
5224e632f8 Usar los titles de la definición de la estructura
fix #48
fix #49
2026-01-28 12:25:51 -06:00
ddb3a5023c Merge pull request 'fix/Incidencias' (#46) from fix/Incidencias into main
Reviewed-on: #46
2026-01-27 21:59:13 +00:00
b35dcf3b54 Merge branch 'main' into fix/Incidencias 2026-01-27 21:58:13 +00:00
9f23f047b1 Se cambian inputs por contentEditable
fix #33
2026-01-27 15:56:56 -06:00
c29ae4f953 Se corrige incidencia de flujo y estado
fix #28
2026-01-27 15:17:40 -06:00
7c890a1aca Merge branch 'main' of https://github.lci.ulsa.mx/Guillermo.Arrieta/acad-ia-2 2026-01-27 14:43:41 -06:00
8ec09389cf Merge branch 'fix/Incidencias' 2026-01-27 14:40:57 -06:00
0ab4c41f9e Se corrige incidencia
fix #30
2026-01-27 14:32:06 -06:00
67f11b94f5 Redirección de la IA
fix #31
2026-01-27 14:11:44 -06:00
9584cd0c04 Se cierran incidencias #10, #21, #24, #25; se añade generación manual de planes
close #10:
Al crear un plan de manera manual o con IA y redirigirse a planes/{$planId}/datos, sale el confetti.

close #21:
Los archivos que se adjuntan en el wizard ya no se pueden subir mas que una vez.

close #24:
El input de número de ciclos ahora solo permite enteros positivos mayores a 0.

close #25:
Se quitó el botón de generar borrador.
Al adjuntar el primer archivo al wizard, se hace scroll hasta el dropzone.
Los archivos añadidos se listan desde el más reciente al más antiguo.
Se indica claramente el número de archivos adjuntos y el número máximo de archivos que se pueden adjuntar.
2026-01-27 12:01:05 -06:00
80d875167a close #40 2026-01-27 12:00:31 -06:00
2b5e9e14f9 Iterar la definición de estructuras_plan #39
fix #39
2026-01-27 10:21:13 -06:00
01742a1a74 Se corrigen incidencias
fix #40
2026-01-27 07:32:42 -06:00
c15e2f941d Se corrigen incidencias 35, 36, 33, 32 2026-01-26 13:52:12 -06:00
105 changed files with 24271 additions and 6052 deletions

View File

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

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

@@ -0,0 +1 @@
Ignora los problemas de imports de eslint

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ count.txt
.nitro
.tanstack
.wrangler
diff.txt

1
.vscode/launch.json vendored
View File

@@ -2,6 +2,7 @@
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
// close #40
"version": "0.2.0",
"configurations": [
{

429
bun.lock
View File

@@ -4,9 +4,14 @@
"": {
"name": "acad-ia-2",
"dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -16,6 +21,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
@@ -23,22 +29,30 @@
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4",
"citeproc": "^2.4.63",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"motion": "^12.24.7",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
"vaul": "^1.1.2",
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/bun": "^1.3.6",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -52,6 +66,7 @@
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"supabase": "^2.72.2",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
@@ -60,7 +75,7 @@
},
},
"packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.30", "", {}, "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg=="],
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
@@ -68,23 +83,23 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
@@ -92,25 +107,25 @@
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
@@ -120,10 +135,22 @@
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.22", "", {}, "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.26", "", {}, "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -200,7 +227,7 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@exodus/bytes": ["@exodus/bytes@1.8.0", "", { "peerDependencies": { "@exodus/crypto": "^1.0.0-rc.4" }, "optionalPeers": ["@exodus/crypto"] }, "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ=="],
"@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
@@ -222,6 +249,8 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -234,21 +263,35 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@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-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-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-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "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-presence": "1.1.5", "@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" }, "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-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@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-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-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@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-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-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-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-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-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
@@ -256,14 +299,30 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "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-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "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-menu": "2.1.16", "@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-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@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-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-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-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=="],
@@ -272,18 +331,36 @@
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@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-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@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-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-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-select": ["@radix-ui/react-select@2.2.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-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-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.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", "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-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@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-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-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-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=="],
@@ -310,55 +387,55 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.56.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.56.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
@@ -378,7 +455,19 @@
"@stepperize/react": ["@stepperize/react@5.1.9", "", { "dependencies": { "@stepperize/core": "1.2.7" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yBgw1I5Tx6/qZB4xTdVBaPGfTqH5aYS1WFB5vtR8+fwPeqd3YNuOnQ1pJM6w/xV/gvryuy31hbFw080lZc+/hw=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.6.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw=="],
"@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/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
"@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/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
"@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=="],
"@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=="],
@@ -424,41 +513,41 @@
"@tanstack/eslint-config": ["@tanstack/eslint-config@0.3.4", "", { "dependencies": { "@eslint/js": "^9.37.0", "@stylistic/eslint-plugin": "^5.4.0", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-n": "^17.23.1", "globals": "^16.5.0", "typescript-eslint": "^8.46.0", "vue-eslint-parser": "^10.2.0" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0" } }, "sha512-5Ou1XWJRCTx5G8WoCbT7+6nQ4iNdsISzBAc4lXpFy2fEOO7xioOSPvcPIv+r9V0drPPETou2tr6oLGZZ909FKg=="],
"@tanstack/history": ["@tanstack/history@1.145.7", "", {}, "sha512-gMo/ReTUp0a3IOcZoI3hH6PLDC2R/5ELQ7P2yu9F6aEkA0wSQh+Q4qzMrtcKvF2ut0oE+16xWCGDo/TdYd6cEQ=="],
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.92.0", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.7.11", "", { "dependencies": { "@tanstack/devtools": "0.7.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-a2Lmz8x+JoDrsU6f7uKRcyyY+k8mA/n5mb9h7XJ3Fz/y3+sPV9t7vAW1s5lyNkQyyDt6V1Oim99faLthoJSxMw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.16", "", { "dependencies": { "@tanstack/query-core": "5.90.16" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.145.7", "", { "dependencies": { "@tanstack/history": "1.145.7", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.145.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-0O+a4TjJSPXd2BsvDPwDPBKRQKYqNIBg5TAg9NzCteqJ0NXRxwohyqCksHqCEEtJe/uItwqmHoqkK4q5MDhEsA=="],
"@tanstack/react-router": ["@tanstack/react-router@1.157.15", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.15", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-dVHX3Ann1rxLkXCrB9ctNKveGOrkmlKMo5fDIaaPCqqkDN/aC1gZ9O93i0OQVPUNekpkdXijmpHkxw12WqMTRQ=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.145.7", "", { "dependencies": { "@tanstack/router-devtools-core": "1.145.7" }, "peerDependencies": { "@tanstack/react-router": "^1.145.7", "@tanstack/router-core": "^1.145.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-crzHSQ/rcGX7RfuYsmm1XG5quurNMDTIApU7jfwDx5J9HnUxCOSJrbFX0L3w0o0VRCw5xhrL2EdCnW78Ic86hg=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.15", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.15" }, "peerDependencies": { "@tanstack/react-router": "^1.157.15", "@tanstack/router-core": "^1.157.15", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WNxsQaoVz1MDINKbWJ7xGYg0xyG9UAnRq7cYNFypDFyX6gqfiQUTxpFMVZfaw1sv+/fI/6E+hd7WChu1rrfBqQ=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/router-core": ["@tanstack/router-core@1.145.7", "", { "dependencies": { "@tanstack/history": "1.145.7", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-v6jx6JqVUBM0/FcBq1tX22xiPq8Ufc0PDEP582/4deYoq2/RYd+bZstANp3mGSsqdxE/luhoLYuuSQiwi/j1wA=="],
"@tanstack/router-core": ["@tanstack/router-core@1.157.15", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-KaYz6s+wYcg92kRQ7HXlTJLhBaBXOYiiqRBv5tsRbKRIqqhWNyeGz5+NfDwaYFHg5XLSDs3DvN0elMtxcj4dTg=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.145.7", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.145.7", "csstype": "^3.0.10", "solid-js": ">=1.9.5" }, "optionalPeers": ["csstype"] }, "sha512-oKeq/6QvN49THCh++FJyPv1X65i20qGS4aJHQTNsl4cu1piW1zWUhab2L3DZVr3G8C40FW3xb6hVw92N/fzZbQ=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.15", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.15", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-udqDYuJUtVfPmk/4yhtOZl1dYlze/rMqaj3v/jQRS8TeGqWYal48Q18hM3A5Bd2YqORvaAkOQsI7JWKYnvxCiQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.145.7", "", { "dependencies": { "@tanstack/router-core": "1.145.7", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-xg71c1WTku0ro0rgpJWh3Dt+ognV9qWe2KJHAPzrqfOYdUYu9sGq7Ri4jo8Rk0luXWZrWsrFdBP+9Jx6JH6zWA=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.15", "", { "dependencies": { "@tanstack/router-core": "1.157.15", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-zGac6tyRFz/X86fk9/CAmS6z8lyZf4p9lhAqLBCKVkFiFPmU4eAJp1ODvs81EtV0uJdRL1/rb+uvgHLGUsmQ0g=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.145.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.145.7", "@tanstack/router-generator": "1.145.7", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.145.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-Rimo0NragYKHwjoYX9JBLS8VkZD4D/LqzzLIlX9yz93lmWFRu/DbuS7fDZNqX1Ea8naNvo18DlySszYLzC8XDg=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.15", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.15", "@tanstack/router-generator": "1.157.15", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.15", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-EpRYRb35//sVJ8GPBhthqfPt9HNhx1xAaejiQ8i4vkG37et6qaSGAO+Woq91WjnpmxMYs4+sNJpGioPuVLBBqQ=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.143.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -472,6 +561,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -482,31 +575,35 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0" } }, "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0" } }, "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
@@ -606,18 +703,20 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],
"axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.11", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bin-links": ["bin-links@6.0.0", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -626,6 +725,8 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -636,7 +737,9 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -646,17 +749,23 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"citeproc": ["citeproc@2.4.63", "", {}, "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="],
"comment-parser": ["comment-parser@1.4.5", "", {}, "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -668,13 +777,15 @@
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
"cssstyle": ["cssstyle@5.3.6", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A=="],
"cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -682,6 +793,8 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -700,7 +813,7 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
@@ -708,7 +821,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -760,7 +873,7 @@
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
"eslint-plugin-n": ["eslint-plugin-n@17.23.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A=="],
"eslint-plugin-n": ["eslint-plugin-n@17.23.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-RhWBeb7YVPmNa2eggvJooiuehdL76/bbfj/OJewyoGT80qn5PXdz8zMOTO6YHOsI7byPt7+Ighh/i/4a5/v7hw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
@@ -794,6 +907,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -806,7 +921,9 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"framer-motion": ["framer-motion@12.24.7", "", { "dependencies": { "motion-dom": "^12.24.3", "motion-utils": "^12.23.28", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-EolFLm7NdEMhWO/VTMZ0LlR4fLHGDiJItTx3i8dlyQooOOBoYAaysK4paGD4PrwqnoDdeDOS+TxnSBIAnNHs3w=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"framer-motion": ["framer-motion@12.29.2", "", { "dependencies": { "motion-dom": "^12.29.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -862,12 +979,14 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -934,7 +1053,7 @@
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="],
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -998,7 +1117,7 @@
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
@@ -1014,11 +1133,15 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"motion": ["motion@12.24.7", "", { "dependencies": { "framer-motion": "^12.24.7", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-0jOoqFlQ7JBvAcRhRv28pwUgZ1xw9WS4+tCU6aqYjxgiNVZCVi34ED2cihW3EgjIIWPBoZJis5og1mx/LmQWVQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"motion-dom": ["motion-dom@12.24.3", "", { "dependencies": { "motion-utils": "^12.23.28" } }, "sha512-ZjMZCwhTglim0LM64kC1iFdm4o+2P9IKk3rl/Nb4RKsb5p4O9HJ1C2LWZXOFdsRtp6twpqWRXaFKOduF30ntow=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"motion-utils": ["motion-utils@12.23.28", "", {}, "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ=="],
"motion": ["motion@12.29.2", "", { "dependencies": { "framer-motion": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-jMpHdAzEDF1QQ055cB+1lOBLdJ6ialVWl6QQzpJI2OvmHequ7zFVHM2mx0HNAy+Tu4omUlApfC+4vnkX0geEOg=="],
"motion-dom": ["motion-dom@12.29.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1028,10 +1151,16 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
@@ -1076,14 +1205,18 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
"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-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
@@ -1098,6 +1231,8 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
@@ -1114,7 +1249,7 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
"rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -1128,9 +1263,9 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
@@ -1154,7 +1289,9 @@
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -1182,6 +1319,8 @@
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"supabase": ["supabase@2.72.8", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.3" }, "bin": { "supabase": "bin/supabase" } }, "sha512-3Wymv/QjmndLB9ACQA31VvJ7+KXmDqj7s8g7y+ldAcCaHBMbj+I7x0j/UBGkNbtSh0BG7kRicGA3Xc3jQlccNQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -1194,6 +1333,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
@@ -1244,7 +1385,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="],
"typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
@@ -1260,11 +1401,15 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-debounce": ["use-debounce@10.1.0", "", { "peerDependencies": { "react": "*" } }, "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"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=="],
"vite": ["vite@7.3.0", "", { "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-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"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-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=="],
@@ -1274,6 +1419,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"web-vitals": ["web-vitals@5.1.0", "", {}, "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
@@ -1292,23 +1439,25 @@
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"write-file-atomic": ["write-file-atomic@7.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
@@ -1318,62 +1467,34 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@radix-ui/react-arrow/@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-alert-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-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-checkbox/@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-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-collection/@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-avatar/@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-collection/@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-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@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-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-dismissable-layer/@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-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-focus-scope/@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-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-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popover/@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-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-popover/@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-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popper/@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-portal/@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-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@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-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-select/@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-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-select/@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-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@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-tabs/@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-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tooltip/@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-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-visually-hidden/@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=="],
"@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/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -1388,6 +1509,10 @@
"@tanstack/devtools/@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1398,6 +1523,10 @@
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cmdk/@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=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"eslint-compat-utils/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -1420,37 +1549,25 @@
"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=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"solid-js/seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
"solid-js/seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"vue-eslint-parser/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@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-checkbox/@radix-ui/react-primitive/@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-dismissable-layer/@radix-ui/react-primitive/@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-focus-scope/@radix-ui/react-primitive/@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-popper/@radix-ui/react-primitive/@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-portal/@radix-ui/react-primitive/@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-roving-focus/@radix-ui/react-primitive/@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-tabs/@radix-ui/react-primitive/@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-visually-hidden/@radix-ui/react-primitive/@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=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tanstack/devtools/@tanstack/devtools-client/@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.3.5", "", {}, "sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw=="],

View File

@@ -17,6 +17,7 @@
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
},
"dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -41,18 +42,23 @@
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4",
"citeproc": "^2.4.63",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"motion": "^12.24.7",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0"
"use-debounce": "^10.1.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",

View File

@@ -0,0 +1,757 @@
<?xml version="1.0" encoding="utf-8"?>
<locale xmlns="http://purl.org/net/xbiblio/csl" version="1.0" xml:lang="es-MX">
<info>
<translator>
<name>Juan Ignacio Flores Salgado</name>
<uri>https://www.mendeley.com/profiles/juan-ignacio-flores-salgado/</uri>
</translator>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
<updated>2025-10-16T03:24:00+00:00</updated>
</info>
<style-options punctuation-in-quote="false"/>
<date form="text">
<date-part name="day" prefix="el " suffix=" de "/>
<date-part name="month" suffix=" de "/>
<date-part name="year"/>
</date>
<date form="numeric">
<date-part name="day" form="numeric-leading-zeros" suffix="/"/>
<date-part name="month" form="numeric-leading-zeros" suffix="/"/>
<date-part name="year"/>
</date>
<terms>
<!-- LONG GENERAL TERMS -->
<term name="accessed">consultado</term>
<term name="advance-online-publication">advance online publication</term>
<term name="album">album</term>
<term name="and">y</term>
<term name="and others">et&#160;al.</term>
<term name="anonymous">anónimo</term>
<term name="at">en</term>
<term name="audio-recording">audio recording</term>
<term name="available at">disponible en</term>
<term name="by">de</term>
<term name="circa">circa</term>
<term name="cited">citado</term>
<term name="et-al">et&#160;al.</term>
<term name="film">film</term>
<term name="forthcoming">en preparación</term>
<term name="from">a partir de</term>
<term name="henceforth">henceforth</term>
<term name="ibid">ibid.</term>
<term name="in">en</term>
<term name="in press">en imprenta</term>
<term name="internet">internet</term>
<term name="letter">carta</term>
<term name="loc-cit">loc. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
<term name="no date">sin fecha</term>
<term name="no-place">no place</term>
<term name="no-publisher">no publisher</term> <!-- sine nomine -->
<term name="on">on</term>
<term name="online">en línea</term>
<term name="op-cit">op. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
<term name="original-work-published">obra original publicada en</term>
<term name="personal-communication">comunicación personal</term>
<term name="podcast">podcast</term>
<term name="podcast-episode">podcast episode</term>
<term name="preprint">preprint</term>
<term name="presented at">presentado en</term>
<term name="radio-broadcast">radio broadcast</term>
<term name="radio-series">radio series</term>
<term name="radio-series-episode">radio series episode</term>
<term name="reference">
<single>referencia</single>
<multiple>referencias</multiple>
</term>
<term name="retrieved">recuperado</term>
<term name="review-of">review of</term>
<term name="scale">escala</term>
<term name="special-issue">special issue</term>
<term name="special-section">special section</term>
<term name="television-broadcast">television broadcast</term>
<term name="television-series">television series</term>
<term name="television-series-episode">television series episode</term>
<term name="video">video</term>
<term name="working-paper">working paper</term>
<!-- SHORT GENERAL TERMS -->
<term name="anonymous" form="short">anón.</term>
<term name="circa" form="short">c.</term>
<term name="no date" form="short">s/f</term>
<term name="no-place" form="short">n.p.</term>
<term name="no-publisher" form="short">n.p.</term>
<term name="reference" form="short">
<single>ref.</single>
<multiple>refs.</multiple>
</term>
<term name="review-of" form="short">rev. of</term>
<!-- SYMBOLIC GENERAL FORMS -->
<!-- LONG ITEM TYPE FORMS -->
<term name="article">preprint</term>
<term name="article-journal">journal article</term>
<term name="article-magazine">magazine article</term>
<term name="article-newspaper">newspaper article</term>
<term name="bill">bill</term>
<!-- book is in the list of locator terms -->
<term name="broadcast">broadcast</term>
<!-- chapter is in the list of locator terms -->
<term name="classic">classic</term>
<term name="collection">collection</term>
<term name="dataset">dataset</term>
<term name="document">document</term>
<term name="entry">entry</term>
<term name="entry-dictionary">dictionary entry</term>
<term name="entry-encyclopedia">encyclopedia entry</term>
<term name="event">event</term>
<!-- figure is in the list of locator terms -->
<term name="graphic">graphic</term>
<term name="hearing">hearing</term>
<term name="interview">entrevista</term>
<term name="legal_case">legal case</term>
<term name="legislation">legislation</term>
<term name="manuscript">manuscript</term>
<term name="map">map</term>
<term name="motion_picture">video recording</term>
<term name="musical_score">musical score</term>
<term name="pamphlet">pamphlet</term>
<term name="paper-conference">conference paper</term>
<term name="patent">patent</term>
<term name="performance">performance</term>
<term name="periodical">periodical</term>
<term name="personal_communication">comunicación personal</term>
<term name="post">post</term>
<term name="post-weblog">blog post</term>
<term name="regulation">regulation</term>
<term name="report">report</term>
<term name="review">review</term>
<term name="review-book">book review</term>
<term name="software">software</term>
<term name="song">audio recording</term>
<term name="speech">presentation</term>
<term name="standard">standard</term>
<term name="thesis">thesis</term>
<term name="treaty">treaty</term>
<term name="webpage">webpage</term>
<!-- SHORT ITEM TYPE FORMS -->
<term name="article-journal" form="short">journal art.</term>
<term name="article-magazine" form="short">mag. art.</term>
<term name="article-newspaper" form="short">newspaper art.</term>
<!-- book is in the list of locator terms -->
<!-- chapter is in the list of locator terms -->
<term name="document" form="short">doc.</term>
<!-- figure is in the list of locator terms -->
<term name="graphic" form="short">graph.</term>
<term name="interview" form="short">interv.</term>
<term name="manuscript" form="short">MS</term>
<term name="motion_picture" form="short">video rec.</term>
<term name="report" form="short">rep.</term>
<term name="review" form="short">rev.</term>
<term name="review-book" form="short">bk. rev.</term>
<term name="song" form="short">audio rec.</term>
<!-- LONG VERB ITEM TYPE FORMS -->
<!-- Only where applicable -->
<term name="hearing" form="verb">testimony of</term>
<term name="review" form="verb">review of</term>
<term name="review-book" form="verb">review of the book</term>
<!-- SHORT VERB ITEM TYPE FORMS -->
<!-- HISTORICAL ERA TERMS -->
<term name="ad">d.&#160;C.</term>
<term name="bc">a.&#160;C.</term>
<term name="bce">BCE</term>
<term name="ce">CE</term>
<!-- PUNCTUATION -->
<term name="open-quote"></term>
<term name="close-quote"></term>
<term name="open-inner-quote"></term>
<term name="close-inner-quote"></term>
<term name="page-range-delimiter"></term>
<term name="colon">:</term>
<term name="comma">,</term>
<term name="semicolon">;</term>
<!-- ORDINALS -->
<term name="ordinal">a</term>
<term name="ordinal-01" gender-form="feminine" match="whole-number">a</term>
<term name="ordinal-01" gender-form="masculine" match="whole-number">o</term>
<!-- LONG ORDINALS -->
<term name="long-ordinal-01">primera</term>
<term name="long-ordinal-02">segunda</term>
<term name="long-ordinal-03">tercera</term>
<term name="long-ordinal-04">cuarta</term>
<term name="long-ordinal-05">quinta</term>
<term name="long-ordinal-06">sexta</term>
<term name="long-ordinal-07">séptima</term>
<term name="long-ordinal-08">octava</term>
<term name="long-ordinal-09">novena</term>
<term name="long-ordinal-10">décima</term>
<!-- LONG LOCATOR FORMS -->
<term name="act">
<single>act</single>
<multiple>acts</multiple>
</term>
<term name="appendix">
<single>appendix</single>
<multiple>appendices</multiple>
</term>
<term name="article-locator">
<single>article</single>
<multiple>articles</multiple>
</term>
<term name="book">
<single>libro</single>
<multiple>libros</multiple>
</term>
<term name="canon">
<single>canon</single>
<multiple>canons</multiple>
</term>
<term name="chapter">
<single>capítulo</single>
<multiple>capítulos</multiple>
</term>
<term name="column">
<single>columna</single>
<multiple>columnas</multiple>
</term>
<term name="elocation">
<single>location</single>
<multiple>locations</multiple>
</term>
<term name="equation">
<single>equation</single>
<multiple>equations</multiple>
</term>
<term name="figure">
<single>figura</single>
<multiple>figuras</multiple>
</term>
<term name="folio">
<single>folio</single>
<multiple>folios</multiple>
</term>
<term name="issue">
<single>número</single>
<multiple>números</multiple>
</term>
<term name="line">
<single>línea</single>
<multiple>líneas</multiple>
</term>
<term name="note">
<single>nota</single>
<multiple>notas</multiple>
</term>
<term name="opus">
<single>opus</single>
<multiple>opera</multiple>
</term>
<term name="page">
<single>página</single>
<multiple>páginas</multiple>
</term>
<term name="paragraph">
<single>párrafo</single>
<multiple>párrafos</multiple>
</term>
<term name="part">
<single>parte</single>
<multiple>partes</multiple>
</term>
<term name="rule">
<single>rule</single>
<multiple>rules</multiple>
</term>
<term name="scene">
<single>scene</single>
<multiple>scenes</multiple>
</term>
<term name="section">
<single>sección</single>
<multiple>secciones</multiple>
</term>
<term name="sub-verbo">
<single>sub voce</single>
<multiple>sub vocibus</multiple>
</term>
<term name="supplement">
<single>supplement</single>
<multiple>supplements</multiple>
</term>
<term name="table">
<single>table</single>
<multiple>tables</multiple>
</term>
<term name="timestamp"> <!-- generally blank -->
<single/>
<multiple/>
</term>
<term name="title-locator">
<single>title</single>
<multiple>titles</multiple>
</term>
<term name="verse">
<single>verso</single>
<multiple>versos</multiple>
</term>
<term name="volume">
<single>volumen</single>
<multiple>volúmenes</multiple>
</term>
<!-- SHORT LOCATOR FORMS -->
<term name="appendix" form="short">
<single>app.</single>
<multiple>apps.</multiple>
</term>
<term name="article-locator" form="short">
<single>art.</single>
<multiple>arts.</multiple>
</term>
<term name="book" form="short">
<single>lib.</single>
<multiple>libs.</multiple>
</term>
<term name="chapter" form="short">
<single>cap.</single>
<multiple>caps.</multiple>
</term>
<term name="column" form="short">
<single>col.</single>
<multiple>cols.</multiple>
</term>
<term name="elocation" form="short">
<single>loc.</single>
<multiple>locs.</multiple>
</term>
<term name="equation" form="short">
<single>eq.</single>
<multiple>eqs.</multiple>
</term>
<term name="figure" form="short">
<single>fig.</single>
<multiple>figs.</multiple>
</term>
<term name="folio" form="short">
<single>f.</single>
<multiple>ff.</multiple>
</term>
<term name="issue" form="short">
<single>núm.</single>
<multiple>núms.</multiple>
</term>
<term name="line" form="short">
<single>l.</single>
<multiple>ls.</multiple>
</term>
<term name="note" form="short">
<single>n.</single>
<multiple>nn.</multiple>
</term>
<term name="opus" form="short">
<single>op.</single>
<multiple>opp.</multiple>
</term>
<term name="page" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="paragraph" form="short">
<single>párr.</single>
<multiple>párrs.</multiple>
</term>
<term name="part" form="short">
<single>pt.</single>
<multiple>pts.</multiple>
</term>
<term name="rule" form="short">
<single>r.</single>
<multiple>rr.</multiple>
</term>
<term name="scene" form="short">
<single>sc.</single>
<multiple>scs.</multiple>
</term>
<term name="section" form="short">
<single>sec.</single>
<multiple>secs.</multiple>
</term>
<term name="sub-verbo" form="short">
<single>s.&#160;v.</single>
<multiple>s.&#160;vv.</multiple>
</term>
<term name="supplement" form="short">
<single>supp.</single>
<multiple>supps.</multiple>
</term>
<term name="table" form="short">
<single>tbl.</single>
<multiple>tbls.</multiple>
</term>
<term name="timestamp" form="short"> <!-- generally blank -->
<single/>
<multiple/>
</term>
<term name="title-locator" form="short">
<single>tit.</single>
<multiple>tits.</multiple>
</term>
<term name="verse" form="short">
<single>v.</single>
<multiple>vv.</multiple>
</term>
<term name="volume" form="short">
<single>vol.</single>
<multiple>vols.</multiple>
</term>
<!-- SYMBOLIC LOCATOR FORMS -->
<term name="paragraph" form="symbol">
<single></single>
<multiple></multiple>
</term>
<term name="section" form="symbol">
<single>§</single>
<multiple>§</multiple>
</term>
<!-- LONG NUMBER VARIABLE FORMS -->
<term name="chapter-number">
<single>chapter</single>
<multiple>chapters</multiple>
</term>
<term name="citation-number">
<single>citation</single>
<multiple>citations</multiple>
</term>
<term name="collection-number">
<single>número</single>
<multiple>números</multiple>
</term>
<term name="edition">
<single>edición</single>
<multiple>ediciones</multiple>
</term>
<term name="first-reference-note-number">
<single>reference</single>
<multiple>references</multiple>
</term>
<term name="number">
<single>number</single>
<multiple>numbers</multiple>
</term>
<term name="number-of-pages">
<single>página</single>
<multiple>páginas</multiple>
</term>
<term name="number-of-volumes">
<single>volume</single>
<multiple>volumes</multiple>
</term>
<term name="page-first">
<single>page</single>
<multiple>pages</multiple>
</term>
<term name="printing">
<single>printing</single>
<multiple>printings</multiple>
</term>
<term name="version">versión</term>
<!-- SHORT NUMBER VARIABLE FORMS -->
<term name="chapter-number" form="short">
<single>chap.</single>
<multiple>chaps.</multiple>
</term>
<term name="citation-number" form="short">
<single>cit.</single>
<multiple>cits.</multiple>
</term>
<term name="collection-number" form="short">
<single>núm.</single>
<multiple>núms.</multiple>
</term>
<term name="edition" form="short">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="first-reference-note-number" form="short">
<single>ref.</single>
<multiple>refs.</multiple>
</term>
<term name="number" form="short">
<single>no.</single>
<multiple>nos.</multiple>
</term>
<term name="number-of-pages" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="number-of-volumes" form="short">
<single>vol.</single>
<multiple>vols.</multiple>
</term>
<term name="page-first" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="printing" form="short">
<single>print.</single>
<multiple>prints.</multiple>
</term>
<!-- LONG ROLE FORMS -->
<term name="author"/> <!-- generally blank -->
<term name="chair">
<single>chair</single>
<multiple>chairs</multiple>
</term>
<term name="collection-editor">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="compiler">
<single>compiler</single>
<multiple>compilers</multiple>
</term>
<term name="composer"/> <!-- generally blank -->
<term name="container-author"/> <!-- generally blank -->
<term name="contributor">
<single>contributor</single>
<multiple>contributors</multiple>
</term>
<term name="curator">
<single>curator</single>
<multiple>curators</multiple>
</term>
<term name="director">
<single>director</single>
<multiple>directores</multiple>
</term>
<term name="editor">
<single>editor</single>
<multiple>editores</multiple>
</term>
<term name="editor-translator">
<single>editor y traductor</single>
<multiple>editores y traductores</multiple>
</term>
<term name="editortranslator">
<single>editor y traductor</single>
<multiple>editores y traductores</multiple>
</term>
<term name="editorial-director">
<single>coordinador</single>
<multiple>coordinadores</multiple>
</term>
<term name="executive-producer">
<single>executive producer</single>
<multiple>executive producers</multiple>
</term>
<term name="guest">
<single>guest</single>
<multiple>guests</multiple>
</term>
<term name="host">
<single>host</single>
<multiple>hosts</multiple>
</term>
<term name="illustrator">
<single>ilustrador</single>
<multiple>ilustradores</multiple>
</term>
<term name="interviewer"/> <!-- generally blank -->
<term name="narrator">
<single>narrator</single>
<multiple>narrators</multiple>
</term>
<term name="organizer">
<single>organizer</single>
<multiple>organizers</multiple>
</term>
<term name="original-author"/> <!-- generally blank -->
<term name="performer">
<single>performer</single>
<multiple>performers</multiple>
</term>
<term name="producer">
<single>producer</single>
<multiple>producers</multiple>
</term>
<term name="recipient"/> <!-- generally blank -->
<term name="reviewed-author"/> <!-- generally blank -->
<term name="script-writer">
<single>writer</single>
<multiple>writers</multiple>
</term>
<term name="series-creator">
<single>series creator</single>
<multiple>series creators</multiple>
</term>
<term name="translator">
<single>traductor</single>
<multiple>traductores</multiple>
</term>
<!-- SHORT ROLE FORMS -->
<term name="compiler" form="short">
<single>comp.</single>
<multiple>comps.</multiple>
</term>
<term name="contributor" form="short">
<single>contrib.</single>
<multiple>contribs.</multiple>
</term>
<term name="curator" form="short">
<single>cur.</single>
<multiple>curs.</multiple>
</term>
<term name="director" form="short">
<single>dir.</single>
<multiple>dirs.</multiple>
</term>
<term name="editor" form="short">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="editor-translator" form="short">
<single>ed. y trad.</single>
<multiple>eds. y trads.</multiple>
</term>
<term name="editortranslator" form="short">
<single>ed. y trad.</single>
<multiple>eds. y trads.</multiple>
</term>
<term name="editorial-director" form="short">
<single>coord.</single>
<multiple>coords.</multiple>
</term>
<term name="executive-producer" form="short">
<single>exec. prod.</single>
<multiple>exec. prods.</multiple>
</term>
<term name="illustrator" form="short">
<single>ilust.</single>
<multiple>ilusts.</multiple>
</term>
<term name="narrator" form="short">
<single>narr.</single>
<multiple>narrs.</multiple>
</term>
<term name="organizer" form="short">
<single>org.</single>
<multiple>orgs.</multiple>
</term>
<term name="performer" form="short">
<single>perf.</single>
<multiple>perfs.</multiple>
</term>
<term name="producer" form="short">
<single>prod.</single>
<multiple>prods.</multiple>
</term>
<term name="script-writer" form="short">
<single>writ.</single>
<multiple>writs.</multiple>
</term>
<term name="series-creator" form="short">
<single>cre.</single>
<multiple>cres.</multiple>
</term>
<term name="translator" form="short">
<single>trad.</single>
<multiple>trads.</multiple>
</term>
<!-- VERB ROLE FORMS -->
<term name="chair" form="verb">chaired by</term>
<term name="collection-editor" form="verb">edited by</term>
<term name="compiler" form="verb">compiled by</term>
<term name="container-author" form="verb">de</term>
<term name="contributor" form="verb">with</term>
<term name="curator" form="verb">curated by</term>
<term name="director" form="verb">dirigido por</term>
<term name="editor" form="verb">editado por</term>
<term name="editor-translator" form="verb">editado y traducido por</term>
<term name="editortranslator" form="verb">editado y traducido por</term>
<term name="editorial-director" form="verb">coordinado por</term>
<term name="executive-producer" form="verb">executive produced by</term>
<term name="guest" form="verb">with guest</term>
<term name="host" form="verb">hosted by</term>
<term name="illustrator" form="verb">ilustrado por</term>
<term name="interviewer" form="verb">entrevistado por</term>
<term name="narrator" form="verb">narrated by</term>
<term name="organizer" form="verb">organized by</term>
<term name="performer" form="verb">performed by</term>
<term name="producer" form="verb">produced by</term>
<term name="recipient" form="verb">a</term>
<term name="reviewed-author" form="verb">por</term>
<term name="script-writer" form="verb">written by</term>
<term name="series-creator" form="verb">created by</term>
<term name="translator" form="verb">traducido por</term>
<!-- SHORT VERB ROLE FORMS -->
<term name="collection-editor" form="verb-short">ed. by</term>
<term name="compiler" form="verb-short">comp. by</term>
<term name="contributor" form="verb-short">w.</term>
<term name="curator" form="verb-short">cur. by</term>
<term name="director" form="verb-short">dir.</term>
<term name="editor" form="verb-short">ed.</term>
<term name="editor-translator" form="verb-short">ed. y trad.</term>
<term name="editortranslator" form="verb-short">ed. y trad.</term>
<term name="editorial-director" form="verb-short">coord.</term>
<term name="executive-producer" form="verb-short">exec. prod. by</term>
<term name="guest" form="verb-short">w. guest</term>
<term name="host" form="verb-short">hosted by</term>
<term name="illustrator" form="verb-short">ilust.</term>
<term name="narrator" form="verb-short">narr. by</term>
<term name="organizer" form="verb-short">org. by</term>
<term name="performer" form="verb-short">perf. by</term>
<term name="producer" form="verb-short">prod. by</term>
<term name="script-writer" form="verb-short">writ. by</term>
<term name="series-creator" form="verb-short">cre. by</term>
<term name="translator" form="verb-short">trad.</term>
<!-- LONG MONTH FORMS -->
<term name="month-01">enero</term>
<term name="month-02">febrero</term>
<term name="month-03">marzo</term>
<term name="month-04">abril</term>
<term name="month-05">mayo</term>
<term name="month-06">junio</term>
<term name="month-07">julio</term>
<term name="month-08">agosto</term>
<term name="month-09">septiembre</term>
<term name="month-10">octubre</term>
<term name="month-11">noviembre</term>
<term name="month-12">diciembre</term>
<!-- SHORT MONTH FORMS -->
<term name="month-01" form="short">ene.</term>
<term name="month-02" form="short">feb.</term>
<term name="month-03" form="short">mar.</term>
<term name="month-04" form="short">abr.</term>
<term name="month-05" form="short">may</term>
<term name="month-06" form="short">jun.</term>
<term name="month-07" form="short">jul.</term>
<term name="month-08" form="short">ago.</term>
<term name="month-09" form="short">sep.</term>
<term name="month-10" form="short">oct.</term>
<term name="month-11" form="short">nov.</term>
<term name="month-12" form="short">dic.</term>
<!-- SEASONS -->
<term name="season-01">primavera</term>
<term name="season-02">verano</term>
<term name="season-03">otoño</term>
<term name="season-04">invierno</term>
</terms>
</locale>

2273
public/csl/styles/apa.csl Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

519
public/csl/styles/ieee.csl Normal file
View File

@@ -0,0 +1,519 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0" demote-non-dropping-particle="sort-only">
<info>
<title>IEEE Reference Guide version 11.29.2023</title>
<title-short>Institute of Electrical and Electronics Engineers</title-short>
<id>http://www.zotero.org/styles/ieee</id>
<link href="http://www.zotero.org/styles/ieee" rel="self"/>
<link href="https://journals.ieeeauthorcenter.ieee.org/your-role-in-article-production/ieee-editorial-style-manual/" rel="documentation"/>
<author>
<name>Michael Berkowitz</name>
<email>mberkowi@gmu.edu</email>
</author>
<contributor>
<name>Julian Onions</name>
<email>julian.onions@gmail.com</email>
</contributor>
<contributor>
<name>Rintze Zelle</name>
<uri>http://twitter.com/rintzezelle</uri>
</contributor>
<contributor>
<name>Stephen Frank</name>
<uri>http://www.zotero.org/sfrank</uri>
</contributor>
<contributor>
<name>Sebastian Karcher</name>
</contributor>
<contributor>
<name>Giuseppe Silano</name>
<email>g.silano89@gmail.com</email>
<uri>http://giuseppesilano.net</uri>
</contributor>
<contributor>
<name>Patrick O'Brien</name>
</contributor>
<contributor>
<name>Brenton M. Wiernik</name>
</contributor>
<contributor>
<name>Oliver Couch</name>
<email>oliver.couch@gmail.com</email>
</contributor>
<contributor>
<name>Andrew Dunning</name>
<uri>https://orcid.org/0000-0003-0464-5036</uri>
</contributor>
<category citation-format="numeric"/>
<category field="engineering"/>
<category field="generic-base"/>
<summary>IEEE style as per the 2023 guidelines.</summary>
<updated>2024-03-27T11:41:27+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<locale xml:lang="en">
<date form="text">
<date-part name="month" form="short" suffix=" "/>
<date-part name="day" form="numeric-leading-zeros" suffix=", "/>
<date-part name="year"/>
</date>
<terms>
<term name="chapter" form="short">ch.</term>
<term name="chapter-number" form="short">ch.</term>
<term name="presented at">presented at the</term>
<term name="available at">available</term>
<!-- always use three-letter abbreviations for months -->
<term name="month-06" form="short">Jun.</term>
<term name="month-07" form="short">Jul.</term>
<term name="month-09" form="short">Sep.</term>
</terms>
</locale>
<!-- Macros -->
<macro name="status">
<choose>
<if variable="page issue volume" match="none">
<text variable="status" text-case="capitalize-first" suffix="" font-weight="bold"/>
</if>
</choose>
</macro>
<macro name="edition">
<choose>
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference report song" match="any">
<choose>
<if is-numeric="edition">
<group delimiter=" ">
<number variable="edition" form="ordinal"/>
<text term="edition" form="short"/>
</group>
</if>
<else>
<text variable="edition" text-case="capitalize-first" suffix="."/>
</else>
</choose>
</if>
</choose>
</macro>
<macro name="issued">
<choose>
<if type="article-journal report" match="any">
<date variable="issued">
<date-part name="month" form="short" suffix=" "/>
<date-part name="year" form="long"/>
</date>
</if>
<else-if type="bill book chapter graphic legal_case legislation song thesis" match="any">
<date variable="issued">
<date-part name="year" form="long"/>
</date>
</else-if>
<else-if type="paper-conference" match="any">
<date variable="issued">
<date-part name="month" form="short"/>
<date-part name="year" prefix=" "/>
</date>
</else-if>
<else-if type="motion_picture" match="any">
<date variable="issued" form="text" prefix="(" suffix=")"/>
</else-if>
<else>
<date variable="issued" form="text"/>
</else>
</choose>
</macro>
<macro name="author">
<names variable="author">
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
<label form="short" prefix=", " text-case="capitalize-first"/>
<et-al font-style="italic"/>
<substitute>
<names variable="editor"/>
<names variable="translator"/>
<text macro="director"/>
</substitute>
</names>
</macro>
<macro name="editor">
<names variable="editor">
<name initialize-with=". " delimiter=", " and="text"/>
<label form="short" prefix=", " text-case="capitalize-first"/>
</names>
</macro>
<macro name="director">
<names variable="director">
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
<et-al font-style="italic"/>
</names>
</macro>
<macro name="locators">
<group delimiter=", ">
<text macro="edition"/>
<group delimiter=" ">
<text term="volume" form="short"/>
<number variable="volume" form="numeric"/>
</group>
<group delimiter=" ">
<number variable="number-of-volumes" form="numeric"/>
<text term="volume" form="short" plural="true"/>
</group>
<group delimiter=" ">
<text term="issue" form="short"/>
<number variable="issue" form="numeric"/>
</group>
</group>
</macro>
<macro name="title">
<choose>
<if type="bill book graphic legal_case legislation motion_picture song standard software" match="any">
<text variable="title" font-style="italic"/>
</if>
<else>
<text variable="title" quotes="true"/>
</else>
</choose>
</macro>
<macro name="publisher">
<choose>
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference song" match="any">
<group delimiter=": ">
<text variable="publisher-place"/>
<text variable="publisher"/>
</group>
</if>
<else>
<group delimiter=", ">
<text variable="publisher"/>
<text variable="publisher-place"/>
</group>
</else>
</choose>
</macro>
<macro name="event">
<choose>
<!-- Published Conference Paper -->
<if type="paper-conference speech" match="any">
<choose>
<if variable="container-title" match="any">
<group delimiter=" ">
<text term="in"/>
<text variable="container-title" font-style="italic"/>
</group>
</if>
<!-- Unpublished Conference Paper -->
<else>
<group delimiter=" ">
<text term="presented at"/>
<text variable="event"/>
</group>
</else>
</choose>
</if>
</choose>
</macro>
<macro name="access">
<choose>
<if type="webpage post post-weblog" match="any">
<!-- https://url.com/ (accessed Mon. DD, YYYY). -->
<choose>
<if variable="URL">
<group delimiter=". " prefix=" ">
<group delimiter=": ">
<text term="accessed" text-case="capitalize-first"/>
<date variable="accessed" form="text"/>
</group>
<text term="online" prefix="[" suffix="]" text-case="capitalize-first"/>
<group delimiter=": ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</group>
</if>
</choose>
</if>
<else-if match="any" variable="DOI">
<!-- doi: 10.1000/xyz123. -->
<text variable="DOI" prefix=" doi: " suffix="."/>
</else-if>
<else-if variable="URL">
<!-- Accessed: Mon. DD, YYYY. [Medium]. Available: https://URL.com/ -->
<group delimiter=". " prefix=" " suffix=". ">
<!-- Accessed: Mon. DD, YYYY. -->
<group delimiter=": ">
<text term="accessed" text-case="capitalize-first"/>
<date variable="accessed" form="text"/>
</group>
<!-- [Online Video]. -->
<group prefix="[" suffix="]" delimiter=" ">
<choose>
<if variable="medium" match="any">
<text variable="medium" text-case="capitalize-first"/>
</if>
<else>
<text term="online" text-case="capitalize-first"/>
<choose>
<if type="motion_picture">
<text term="video" text-case="capitalize-first"/>
</if>
</choose>
</else>
</choose>
</group>
</group>
<!-- Available: https://URL.com/ -->
<group delimiter=": " prefix=" ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</else-if>
</choose>
</macro>
<macro name="page">
<choose>
<if type="article-journal" variable="number" match="all">
<group delimiter=" ">
<text value="Art."/>
<text term="issue" form="short"/>
<text variable="number"/>
</group>
</if>
<else>
<group delimiter=" ">
<label variable="page" form="short"/>
<text variable="page"/>
</group>
</else>
</choose>
</macro>
<macro name="citation-locator">
<group delimiter=" ">
<choose>
<if locator="page">
<label variable="locator" form="short"/>
</if>
<else>
<label variable="locator" form="short" text-case="capitalize-first"/>
</else>
</choose>
<text variable="locator"/>
</group>
</macro>
<macro name="geographic-location">
<group delimiter=", " suffix=".">
<choose>
<if variable="publisher-place">
<text variable="publisher-place" text-case="title"/>
</if>
<else-if variable="event-place">
<text variable="event-place" text-case="title"/>
</else-if>
</choose>
</group>
</macro>
<!-- Series -->
<macro name="collection">
<choose>
<if variable="collection-title" match="any">
<text term="in" suffix=" "/>
<group delimiter=", " suffix=". ">
<text variable="collection-title"/>
<text variable="collection-number" prefix="no. "/>
<text variable="volume" prefix="vol. "/>
</group>
</if>
</choose>
</macro>
<!-- Citation -->
<citation>
<sort>
<key variable="citation-number"/>
</sort>
<layout delimiter=", ">
<group prefix="[" suffix="]" delimiter=", ">
<text variable="citation-number"/>
<text macro="citation-locator"/>
</group>
</layout>
</citation>
<!-- Bibliography -->
<bibliography entry-spacing="0" second-field-align="flush">
<layout>
<!-- Citation Number -->
<text variable="citation-number" prefix="[" suffix="]"/>
<!-- Author(s) -->
<text macro="author" suffix=", "/>
<!-- Rest of Citation -->
<choose>
<!-- Specific Formats -->
<if type="article-journal">
<group delimiter=", ">
<text macro="title"/>
<text variable="container-title" font-style="italic" form="short"/>
<text macro="locators"/>
<text macro="page"/>
<text macro="issued"/>
<text macro="status"/>
</group>
<choose>
<if variable="URL DOI" match="none">
<text value="."/>
</if>
<else>
<text value=","/>
</else>
</choose>
<text macro="access"/>
</if>
<else-if type="paper-conference speech" match="any">
<group delimiter=", " suffix=", ">
<text macro="title"/>
<text macro="event"/>
<text macro="editor"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="issued"/>
<text macro="page"/>
<text macro="status"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="chapter">
<group delimiter=", " suffix=".">
<text macro="title"/>
<group delimiter=" ">
<text term="in" suffix=" "/>
<text variable="container-title" font-style="italic"/>
</group>
<text macro="locators"/>
<text macro="editor"/>
<text macro="collection"/>
<text macro="publisher"/>
<text macro="issued"/>
<group delimiter=" ">
<label variable="chapter-number" form="short"/>
<text variable="chapter-number"/>
</group>
<text macro="page"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="report">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="publisher"/>
<group delimiter=" ">
<text variable="genre"/>
<text variable="number"/>
</group>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="thesis">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="genre"/>
<text macro="publisher"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="software">
<group delimiter=". " suffix=".">
<text macro="title"/>
<text macro="issued" prefix="(" suffix=")"/>
<text variable="genre"/>
<text macro="publisher"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="article">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="issued"/>
<group delimiter=": ">
<text macro="publisher" font-style="italic"/>
<text variable="number"/>
</group>
</group>
<text macro="access"/>
</else-if>
<else-if type="webpage post-weblog post" match="any">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="container-title"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="patent">
<group delimiter=", ">
<text macro="title"/>
<text variable="number"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<!-- Online Video -->
<else-if type="motion_picture">
<text macro="geographic-location" suffix=". "/>
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="standard">
<group delimiter=", " suffix=".">
<text macro="title"/>
<group delimiter=" ">
<text variable="genre"/>
<text variable="number"/>
</group>
<text macro="geographic-location"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<!-- Generic/Fallback Formats -->
<else-if type="bill book graphic legal_case legislation report song" match="any">
<group delimiter=", " suffix=". ">
<text macro="title"/>
<text macro="locators"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="issued"/>
<text macro="page"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="article-magazine article-newspaper broadcast interview manuscript map patent personal_communication song speech thesis webpage" match="any">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="container-title" font-style="italic"/>
<text macro="locators"/>
<text macro="publisher"/>
<text macro="page"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else>
<group delimiter=", " suffix=". ">
<text macro="title"/>
<text variable="container-title" font-style="italic"/>
<text macro="locators"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="page"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else>
</choose>
</layout>
</bibliography>
</style>

View File

@@ -0,0 +1,520 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" delimiter-precedes-last="always" demote-non-dropping-particle="sort-only" initialize-with="" initialize-with-hyphen="false" name-as-sort-order="all" name-delimiter=", " names-delimiter=", " page-range-format="minimal" sort-separator=" " version="1.0">
<!-- This file was generated by the Style Variant Builder <https://github.com/citation-style-language/style-variant-builder>. To contribute changes, modify the template and regenerate variants. -->
<info>
<title>NLM/Vancouver: Citing Medicine 2nd edition (citation-sequence)</title>
<title-short>National Library of Medicine, ANSI/NISO Z39.29-2005 (R2010), ICMJE Recommendations/URMs (C-S)</title-short>
<id>http://www.zotero.org/styles/nlm-citation-sequence</id>
<link href="http://www.zotero.org/styles/nlm-citation-sequence" rel="self"/>
<link href="https://www.nlm.nih.gov/citingmedicine" rel="documentation"/>
<link href="https://www.nlm.nih.gov/bsd/uniform_requirements.html" rel="documentation"/>
<link href="https://www.icmje.org/recommendations/" rel="documentation"/>
<author>
<name>Michael Berkowitz</name>
<email>mberkowi@gmu.edu</email>
</author>
<author>
<name>Andrew Dunning</name>
<uri>https://orcid.org/0000-0003-0464-5036</uri>
</author>
<contributor>
<name>Petr Hlustik</name>
<uri>https://orcid.org/0000-0002-1951-0671</uri>
</contributor>
<contributor>
<name>Sebastian Karcher</name>
<uri>https://orcid.org/0000-0001-8249-7388</uri>
</contributor>
<contributor>
<name>Charles Parnot</name>
<uri>https://orcid.org/0000-0002-7346-5883</uri>
</contributor>
<contributor>
<name>Sean Takats</name>
<uri>https://orcid.org/0000-0002-7851-5069</uri>
</contributor>
<category citation-format="numeric"/>
<category field="generic-base"/>
<category field="medicine"/>
<category field="science"/>
<summary>Citing Medicine: The NLM Style Guide for Authors, Editors, and Publishers, 2nd edition (2015), based on ANSI/NISO Z39.29-2005 (R2010); citation-sequence system.</summary>
<updated>2026-02-18T15:24:08+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<locale xml:lang="en">
<date delimiter=" " form="text">
<date-part name="year"/>
<date-part form="short" name="month" strip-periods="true"/>
<date-part name="day"/>
</date>
<terms>
<term name="available at">available from</term>
<term name="collection-editor">
<single>editor</single>
<multiple>editors</multiple>
</term>
<term form="short" name="month-06">Jun.</term>
<term form="short" name="month-07">Jul.</term>
<term form="short" name="month-09">Sep.</term>
<term name="presented at">presented at</term>
<term form="short" name="section">
<single>sect.</single>
<multiple>sects.</multiple>
</term>
<term form="short" name="supplement">
<single>suppl.</single>
<multiple>suppls.</multiple>
</term>
</terms>
</locale>
<locale xml:lang="fr">
<date delimiter=" " form="text">
<date-part name="day"/>
<date-part form="short" name="month" strip-periods="true"/>
<date-part name="year"/>
</date>
</locale>
<!-- Variable labels -->
<macro name="label-collection-number">
<group delimiter=" ">
<choose>
<if is-numeric="collection-number">
<label form="short" variable="collection-number"/>
</if>
</choose>
<text variable="collection-number"/>
</group>
</macro>
<macro name="label-edition">
<group delimiter=" ">
<choose>
<if is-numeric="edition">
<number form="ordinal" variable="edition"/>
<label form="short" variable="edition"/>
</if>
<else>
<text variable="edition"/>
</else>
</choose>
</group>
</macro>
<macro name="label-number-of-pages">
<group delimiter=" ">
<text variable="number-of-pages"/>
<choose>
<if is-numeric="number-of-pages">
<label form="short" plural="never" variable="number-of-pages"/>
</if>
</choose>
</group>
</macro>
<macro name="label-page">
<group delimiter=" ">
<label form="short" plural="never" variable="page"/>
<text variable="page"/>
</group>
</macro>
<macro name="label-part-number-capitalized">
<group delimiter=" ">
<choose>
<if is-numeric="part-number">
<!-- TODO: Replace with `part-number` label when CSL provides one -->
<text form="short" term="part" text-case="capitalize-first"/>
</if>
</choose>
<text variable="part-number"/>
</group>
</macro>
<macro name="label-supplement-number">
<group delimiter=" ">
<choose>
<if is-numeric="supplement-number">
<!-- TODO: Replace with `supplement-number` label when CSL provides one -->
<text form="short" strip-periods="true" term="supplement" text-case="capitalize-first"/>
</if>
</choose>
<text text-case="capitalize-first" variable="supplement-number"/>
</group>
</macro>
<macro name="label-volume-capitalized">
<group delimiter=" ">
<choose>
<if is-numeric="volume">
<label form="short" text-case="capitalize-first" variable="volume"/>
</if>
</choose>
<text variable="volume"/>
</group>
</macro>
<macro name="author">
<names variable="author">
<label prefix=", "/>
<substitute>
<names variable="editor-translator"/>
<names variable="editor translator"/>
<names variable="editor"/>
<names variable="collection-editor"/>
</substitute>
</names>
</macro>
<macro name="title">
<choose>
<if type="webpage" variable="container-title">
<!-- `webpage` listed under `container-title` (Citing Medicine, ch. 25) -->
<text variable="container-title"/>
</if>
<else>
<text variable="title"/>
</else>
</choose>
</macro>
<macro name="content-type">
<text variable="genre"/>
</macro>
<macro name="type-of-medium">
<choose>
<if variable="medium">
<text text-case="capitalize-first" variable="medium"/>
</if>
<else-if match="any" type="chapter entry-dictionary entry-encyclopedia paper-conference"/>
<else-if variable="URL">
<text term="internet" text-case="capitalize-first"/>
</else-if>
</choose>
</macro>
<macro name="container-preposition">
<choose>
<if match="any" type="chapter paper-conference entry-dictionary entry-encyclopedia">
<text term="in" text-case="capitalize-first"/>
</if>
</choose>
</macro>
<macro name="secondary-authors">
<names variable="editor">
<label prefix=", "/>
</names>
</macro>
<macro name="container-title">
<group delimiter=", ">
<choose>
<if type="webpage"/>
<else-if variable="container-title">
<group delimiter=". ">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal review review-book">
<text form="short" strip-periods="true" variable="container-title"/>
</if>
<else>
<text variable="container-title"/>
</else>
</choose>
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else-if variable="URL">
<text prefix="[" suffix="]" term="internet" text-case="capitalize-first"/>
</else-if>
</choose>
</group>
<text macro="label-edition"/>
</group>
</else-if>
<!-- TODO: add `event-name` and `event-place` -->
<else-if match="any" type="bill legislation">
<group delimiter=". ">
<text variable="container-title"/>
<group delimiter=" ">
<text form="short" term="section" text-case="capitalize-first"/>
<text variable="section"/>
</group>
</group>
<text variable="number"/>
</else-if>
<else-if type="speech">
<group delimiter=": ">
<group delimiter=" ">
<text text-case="capitalize-first" variable="genre"/>
<text term="presented at"/>
</group>
<text variable="event"/>
</group>
</else-if>
<else>
<group delimiter=", ">
<text macro="label-volume-capitalized"/>
<text variable="volume-title"/>
</group>
<group delimiter=", ">
<text macro="label-part-number-capitalized"/>
<text variable="part-title"/>
</group>
</else>
</choose>
</group>
</macro>
<macro name="place-of-publication">
<choose>
<if type="thesis">
<text prefix="[" suffix="]" variable="publisher-place"/>
</if>
<else-if type="speech"/>
<else>
<text variable="publisher-place"/>
</else>
</choose>
</macro>
<macro name="publisher">
<choose>
<!-- discard publisher for serial publications -->
<if match="none" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
<group delimiter=": ">
<text macro="place-of-publication"/>
<text variable="publisher"/>
</group>
</if>
</choose>
</macro>
<macro name="date">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
<group delimiter=":">
<group delimiter=" ">
<date form="text" variable="issued"/>
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else>
<text macro="date-of-citation"/>
</else>
</choose>
</group>
<choose>
<if type="article-newspaper">
<text variable="page"/>
</if>
</choose>
</group>
</if>
<else-if match="any" type="bill legislation">
<date form="text" variable="issued"/>
</else-if>
<else-if type="report">
<date date-parts="year-month" form="text" variable="issued"/>
<text macro="date-of-citation"/>
</else-if>
<else-if type="patent">
<group delimiter=", ">
<text variable="number"/>
<date date-parts="year" form="numeric" variable="issued"/>
</group>
<text macro="date-of-citation"/>
</else-if>
<else-if type="speech">
<group delimiter="; ">
<group delimiter=" ">
<date form="text" variable="issued"/>
<text macro="date-of-citation"/>
</group>
<text variable="event-place"/>
</group>
</else-if>
<else>
<date date-parts="year" form="numeric" variable="issued"/>
<text macro="date-of-citation"/>
</else>
</choose>
</group>
</macro>
<macro name="identifier-serial">
<choose>
<if match="any" type="article-journal article-magazine periodical post-weblog review review-book">
<group delimiter=":">
<group>
<text variable="collection-title"/>
<text variable="volume"/>
<group delimiter=" " prefix="(" suffix=")">
<text variable="issue"/>
<text macro="label-supplement-number"/>
</group>
</group>
<text macro="location-pagination-serial"/>
</group>
</if>
</choose>
</macro>
<macro name="date-of-citation">
<choose>
<if variable="URL">
<group delimiter=" " prefix="[" suffix="]">
<text term="cited"/>
<date form="text" variable="accessed"/>
</group>
</if>
</choose>
</macro>
<macro name="location-pagination-monographic">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper review review-book"/>
<else-if type="book">
<text macro="label-number-of-pages"/>
</else-if>
<else>
<text macro="label-page"/>
</else>
</choose>
</group>
</macro>
<macro name="location-pagination-serial">
<choose>
<if variable="number">
<text variable="number"/>
</if>
<else>
<text variable="page"/>
</else>
</choose>
</macro>
<macro name="webpage-part">
<choose>
<if type="webpage" variable="container-title">
<text variable="title"/>
</if>
</choose>
</macro>
<macro name="series">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book"/>
<else-if variable="collection-title">
<group delimiter=". " prefix="(" suffix=")">
<names variable="collection-editor">
<label prefix=", "/>
</names>
<group delimiter="; ">
<text variable="collection-title"/>
<text macro="label-collection-number"/>
</group>
</group>
</else-if>
</choose>
</macro>
<macro name="report-number">
<choose>
<if type="report">
<group delimiter=": ">
<group delimiter=" ">
<text term="report" text-case="capitalize-first"/>
<label form="short" text-case="capitalize-first" variable="number"/>
</group>
<text variable="number"/>
</group>
</if>
</choose>
</macro>
<macro name="availability">
<group delimiter=". ">
<group delimiter=": ">
<text text-case="capitalize-first" value="located at"/>
<group delimiter="; ">
<group delimiter=", ">
<text variable="archive_collection"/>
<text variable="archive"/>
<text variable="archive-place"/>
</group>
<text variable="archive_location"/>
</group>
</group>
<group delimiter=" ">
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else>
<group delimiter=": ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</else>
</choose>
<text prefix="doi:" variable="DOI"/>
</group>
</group>
</macro>
<macro name="notes">
<group delimiter=". " suffix=".">
<group delimiter="; ">
<group delimiter=": ">
<text value="PubMed PMID"/>
<text variable="PMID"/>
</group>
<group delimiter=": ">
<text value="PubMed Central PMCID"/>
<text variable="PMCID"/>
</group>
</group>
<text variable="references"/>
</group>
</macro>
<citation collapse="citation-number">
<sort>
<key variable="citation-number"/>
</sort>
<layout delimiter="," prefix="(" suffix=")">
<text variable="citation-number"/>
</layout>
</citation>
<macro name="bibliography">
<group delimiter=" ">
<group delimiter=". " suffix=".">
<text macro="author"/>
<group delimiter=" ">
<text macro="title"/>
<text macro="content-type" prefix="[" suffix="]"/>
<choose>
<if type="webpage" variable="container-title">
<text macro="type-of-medium" prefix="[" suffix="]"/>
</if>
<else-if match="none" variable="container-title">
<text macro="type-of-medium" prefix="[" suffix="]"/>
</else-if>
</choose>
</group>
<choose>
<if match="none" variable="container-title">
<text macro="label-edition"/>
</if>
</choose>
<group delimiter=": ">
<text macro="container-preposition"/>
<group delimiter=". ">
<text macro="secondary-authors"/>
<text macro="container-title"/>
</group>
</group>
<group delimiter="; ">
<text macro="publisher"/>
<group delimiter=";">
<text macro="date"/>
<text macro="identifier-serial"/>
</group>
</group>
<text macro="location-pagination-monographic"/>
<text macro="webpage-part"/>
<text macro="series"/>
<text macro="report-number"/>
</group>
<text macro="availability"/>
<text macro="notes"/>
</group>
</macro>
<bibliography et-al-min="7" et-al-use-first="6" second-field-align="flush">
<layout>
<text suffix="." variable="citation-number"/>
<text macro="bibliography"/>
</layout>
</bibliography>
</style>

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

@@ -18,11 +18,7 @@ export default function Header() {
</button>
<h1 className="ml-4 text-xl font-semibold">
<Link to="/">
<img
src="/tanstack-word-logo-white.svg"
alt="TanStack Logo"
className="h-10"
/>
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
</Link>
</h1>
</header>

View File

@@ -0,0 +1,870 @@
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { Minus, Pencil, Plus, Sparkles } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
}
export interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export interface AsignaturaDatos {
[key: string]: string
}
export interface AsignaturaResponse {
datos: AsignaturaDatos
}
type CriterioEvaluacionRow = {
criterio: string
porcentaje: number
}
type CriterioEvaluacionRowDraft = {
id: string
criterio: string
porcentaje: string // allow empty while editing
}
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: AsignaturaDetailPage,
})
export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(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,
},
})
}
/* ---------- sincronizar API ---------- */
useEffect(() => {
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi])
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
}
function DatosGenerales({
onPersistDato,
}: {
onPersistDato: (clave: string, value: string) => void
}) {
const { asignaturaId, planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const navigate = useNavigate()
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
const updateAsignatura = useUpdateAsignatura()
const evaluationCardRef = useRef<HTMLDivElement | null>(null)
const [evaluationForceEditToken, setEvaluationForceEditToken] =
useState<number>(0)
const [evaluationHighlightToken, setEvaluationHighlightToken] =
useState<number>(0)
// 1. Extraemos la definición de la estructura (los metadatos)
const definicionRaw = data?.estructuras_asignatura?.definicion
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)
const datosRaw = data?.datos
const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>)
: {}
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
const raw = (data as any)?.criterios_de_evaluacion
console.log(raw)
if (!Array.isArray(raw)) return []
const rows: Array<CriterioEvaluacionRow> = []
for (const item of raw) {
if (!isRecord(item)) continue
const criterio = typeof item.criterio === 'string' ? item.criterio : ''
const porcentajeNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!criterio.trim()) continue
if (!Number.isFinite(porcentajeNum)) continue
const porcentaje = Math.trunc(porcentajeNum)
if (porcentaje < 1 || porcentaje > 100) continue
rows.push({ criterio: criterio.trim(), porcentaje: porcentaje })
}
return rows
}, [data])
const openEvaluationEditor = () => {
evaluationCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
const now = Date.now()
setEvaluationForceEditToken(now)
setEvaluationHighlightToken(now)
}
const persistCriteriosEvaluacion = async (
rows: Array<CriterioEvaluacionRow>,
) => {
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: {
criterios_de_evaluacion: rows,
} as any,
})
}
if (isLoading) return <p>Cargando información...</p>
return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
{/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Datos Generales
</h2>
<p className="mt-1 text-slate-500">
Información oficial estructurada bajo los lineamientos de la SEP.
</p>
</div>
</div>
{/* Grid de Información */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2">
{Object.entries(structureProps).map(
([key, config]: [string, any]) => {
const cardTitle = config.title || key
const description = config.description || ''
const xColumn =
typeof config?.['x-column'] === 'string'
? config['x-column']
: undefined
// Obtenemos el placeholder del arreglo 'examples' de la estructura
const placeholder =
config.examples && config.examples.length > 0
? config.examples[0]
: ''
const valActual = valoresActuales[key]
let currentContent = valActual ?? ''
if (xColumn) {
const rawValue = (data as any)?.[xColumn]
const parser = columnParsers[xColumn]
currentContent = parser
? parser(rawValue)
: String(rawValue ?? '')
}
return (
<InfoCard
asignaturaId={asignaturaId}
key={key}
clave={key}
title={cardTitle}
initialContent={currentContent}
placeholder={placeholder}
description={description}
onPersist={({ clave, value }) =>
onPersistDato(String(clave ?? key), String(value ?? ''))
}
onClickEditButton={({ startEditing }) => {
switch (xColumn) {
case 'contenido_tematico': {
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
params: { planId, asignaturaId },
})
return
}
case 'criterios_de_evaluacion': {
openEvaluationEditor()
return
}
default: {
startEditing()
}
}
}}
/>
)
},
)}
</div>
{/* Columna Lateral (Información Secundaria) */}
<div className="space-y-6">
<div className="space-y-6">
{/* Tarjeta de Requisitos */}
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/>
{/* Tarjeta de Evaluación */}
<InfoCard
title="Sistema de Evaluación"
type="evaluation"
initialContent={criteriosEvaluacion}
containerRef={evaluationCardRef}
forceEditToken={evaluationForceEditToken}
highlightToken={evaluationHighlightToken}
onPersist={({ value }) => persistCriteriosEvaluacion(value)}
/>
</div>
</div>
</div>
</div>
)
}
interface InfoCardProps {
asignaturaId?: string
clave?: string
title: string
initialContent: any
placeholder?: string
description?: string
required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void
onPersist?: (payload: {
type: NonNullable<InfoCardProps['type']>
clave?: string
value: any
}) => void | Promise<void>
onClickEditButton?: (helpers: { startEditing: () => void }) => void
containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number
highlightToken?: number
}
function InfoCard({
asignaturaId,
clave,
title,
initialContent,
placeholder,
description,
required,
type = 'text',
onPersist,
onClickEditButton,
containerRef,
forceEditToken,
highlightToken,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent)
const [evalRows, setEvalRows] = useState<Array<CriterioEvaluacionRowDraft>>(
[],
)
const navigate = useNavigate()
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
useEffect(() => {
setData(initialContent)
setTempText(initialContent)
if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw
.map((r: any): CriterioEvaluacionRowDraft | null => {
const criterio = typeof r?.criterio === 'string' ? r.criterio : ''
const porcentajeNum =
typeof r?.porcentaje === 'number'
? r.porcentaje
: typeof r?.porcentaje === 'string'
? Number(r.porcentaje)
: NaN
const porcentaje = Number.isFinite(porcentajeNum)
? String(Math.trunc(porcentajeNum))
: ''
return {
id: crypto.randomUUID(),
criterio,
porcentaje,
}
})
.filter(Boolean) as Array<CriterioEvaluacionRowDraft>
setEvalRows(rows)
}
}, [initialContent, type])
useEffect(() => {
if (!forceEditToken) return
setIsEditing(true)
}, [forceEditToken])
useEffect(() => {
if (!highlightToken) return
setIsHighlighted(true)
const t = window.setTimeout(() => setIsHighlighted(false), 900)
return () => window.clearTimeout(t)
}, [highlightToken])
const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = []
for (const r of evalRows) {
const criterio = String(r.criterio).trim()
const porcentajeStr = String(r.porcentaje).trim()
if (!criterio) continue
if (!porcentajeStr) continue
const n = Number(porcentajeStr)
if (!Number.isFinite(n)) continue
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) continue
cleaned.push({ criterio, porcentaje })
}
setData(cleaned)
setEvalRows(
cleaned.map((x) => ({
id: crypto.randomUUID(),
criterio: x.criterio,
porcentaje: String(x.porcentaje),
})),
)
setIsEditing(false)
void onPersist?.({ type, clave, value: cleaned })
return
}
setData(tempText)
setIsEditing(false)
if (type === 'text') {
void onPersist?.({ type, clave, value: String(tempText ?? '') })
}
}
const handleIARequest = (campoClave: string) => {
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({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! },
state: {
activeTab: 'ia',
prefillCampo: campoClave,
prefillContenido: data,
_ts: Date.now(),
} as any,
})
}
const evaluationTotal = useMemo(() => {
if (type !== 'evaluation') return 0
return evalRows.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const n = Number(v)
if (!Number.isFinite(n)) return acc
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return acc
return acc + porcentaje
}, 0)
}, [type, evalRows])
return (
<div ref={containerRef as any}>
<Card
className={
'overflow-hidden transition-all hover:border-slate-300 ' +
(isHighlighted ? 'ring-primary/40 ring-2' : '')
}
>
<TooltipProvider>
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
{title}
</CardTitle>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{description || 'Información del campo'}
</TooltipContent>
</Tooltip>
{required && (
<span
className="text-sm font-bold text-red-500"
title="Requerido"
>
*
</span>
)}
</div>
{!isEditing && (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => clave && handleIARequest(clave)}
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => {
const startEditing = () => setIsEditing(true)
if (onClickEditButton) {
onClickEditButton({ startEditing })
return
}
startEditing()
}}
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Editar campo</TooltipContent>
</Tooltip>
</div>
)}
</div>
</CardHeader>
</TooltipProvider>
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
{type === 'evaluation' ? (
<div className="space-y-3">
<div className="space-y-2">
{evalRows.map((row) => (
<div
key={row.id}
className="grid grid-cols-[2fr_1fr_1ch_32px] items-center gap-2"
>
<Input
value={row.criterio}
placeholder="Criterio"
onChange={(e) => {
const nextCriterio = e.target.value
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? { ...r, criterio: nextCriterio }
: r,
),
)
}}
/>
<Input
value={row.porcentaje}
placeholder="%"
type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
onChange={(e) => {
const raw = e.target.value
// Solo permitir '' o dígitos
if (raw !== '' && !/^\d+$/.test(raw)) return
if (raw === '') {
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: '',
}
: r,
),
)
return
}
const n = Number(raw)
if (!Number.isFinite(n)) return
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return
// No permitir suma > 100
setEvalRows((prev) => {
const next = prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: raw,
}
: r,
)
const total = next.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const nn = Number(v)
if (!Number.isFinite(nn)) return acc
const vv = Math.trunc(nn)
if (vv < 1 || vv > 100) return acc
return acc + vv
}, 0)
return total > 100 ? prev : next
})
}}
/>
<div
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
aria-hidden
>
%
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() => {
setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id),
)
}}
aria-label="Quitar renglón"
title="Quitar"
>
<Minus className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="flex items-center justify-between">
<span
className={
'text-sm ' +
(evaluationTotal === 100
? 'text-muted-foreground'
: 'text-destructive font-semibold')
}
>
Total: {evaluationTotal}/100
</span>
<Button
variant="ghost"
size="sm"
className="text-emerald-700 hover:bg-emerald-50"
onClick={() => {
// Agregar una fila vacía (siempre permitido)
setEvalRows((prev) => [
...prev,
{
id: crypto.randomUUID(),
criterio: '',
porcentaje: '',
},
])
}}
>
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button>
</div>
</div>
) : (
<Textarea
value={tempText}
placeholder={placeholder}
onChange={(e) => setTempText(e.target.value)}
className="min-h-30 text-sm leading-relaxed"
/>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsEditing(false)
if (type === 'evaluation') {
const raw = Array.isArray(data) ? data : []
setEvalRows(
raw.map((r: CriterioEvaluacionRow) => ({
id: crypto.randomUUID(),
criterio:
typeof r.criterio === 'string' ? r.criterio : '',
porcentaje:
typeof r.porcentaje === 'number'
? String(Math.trunc(r.porcentaje))
: typeof r.porcentaje === 'string'
? String(Math.trunc(Number(r.porcentaje)))
: '',
})),
)
}
}}
>
Cancelar
</Button>
<Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
disabled={type === 'evaluation' && evaluationTotal > 100}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
<p className="whitespace-pre-wrap">{data}</p>
) : (
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
</div>
)}
</CardContent>
</Card>
</div>
)
}
// Vista de Requisitos
function RequirementsView({ items }: { items: Array<any> }) {
return (
<div className="space-y-3">
{items.map((req, i) => (
<div
key={i}
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
>
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
{req.type}
</p>
<p className="text-sm font-medium text-slate-700">
{req.code} {req.name}
</p>
</div>
))}
</div>
)
}
// Vista de Evaluación
function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
const porcentajeTotal = items.reduce(
(total, item) => total + Number(item.porcentaje),
0,
)
return (
<div className="space-y-2">
{items.map((item, i) => (
<div
key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
>
<span className="text-slate-500">{item.criterio}</span>
<span className="font-bold text-blue-600">{item.porcentaje}%</span>
</div>
))}
{porcentajeTotal < 100 && (
<p className="text-destructive text-sm font-medium">
El porcentaje total es menor a 100%.
</p>
)}
</div>
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
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 blocks.join('\n\n').trimEnd()
}
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}

View File

@@ -1,13 +1,9 @@
import {
Plus,
Search,
BookOpen,
Trash2,
Library,
Edit3,
Save,
} from 'lucide-react'
import { useEffect, useState } from 'react'
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useNavigate, useParams } from '@tanstack/react-router'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useState } from 'react'
import {
AlertDialog,
@@ -38,40 +34,13 @@ import {
SelectValue,
} from '@/components/ui/select'
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 { toast } from 'sonner';
// import { mockLibraryResources } from '@/data/mockMateriaData';
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 ---
export interface BibliografiaEntry {
@@ -84,79 +53,77 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any
}
interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export function BibliographyItem() {
const navigate = useNavigate()
const { planId, asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
export function BibliographyItem({
bibliografia,
id,
onSave,
isSaving,
}: BibliografiaTabProps) {
console.log(id)
// --- 1. Única fuente de verdad: La Query ---
const { data: bibliografia = [], isLoading } =
useSubjectBibliografia(asignaturaId)
const { data: bibliografia2, isLoading: loadinmateria } =
useSubjectBibliografia(id)
const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
// --- 2. Mutaciones ---
const { mutate: crearBibliografia } = useCreateBibliografia()
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
// --- 3. Estados de UI (Solo para diálogos y edición) ---
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
'BASICA',
)
useEffect(() => {
if (bibliografia2 && Array.isArray(bibliografia2)) {
setEntries(bibliografia2)
} else if (bibliografia) {
// 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(
console.log('Datos actuales en el front:', bibliografia)
// --- 4. Derivación de datos (Se calculan en cada render) ---
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = bibliografia.filter(
(e) => e.tipo === 'COMPLEMENTARIA',
)
console.log(bibliografia2)
const handleAddManual = (cita: string) => {
const newEntry: BibliografiaEntry = {
id: `manual-${Date.now()}`,
tipo: newEntryType,
cita,
}
setEntries([...entries, newEntry])
setIsAddDialogOpen(false)
// toast.success('Referencia manual añadida');
}
// --- Handlers Conectados a la Base de Datos ---
const handleAddFromLibrary = (
resource: any,
tipo: 'BASICA' | 'COMPLEMENTARIA',
) => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
const newEntry: BibliografiaEntry = {
id: `lib-ref-${Date.now()}`,
tipo,
cita,
fuenteBibliotecaId: resource.id,
fuenteBiblioteca: resource,
}
setEntries([...entries, newEntry])
setIsLibraryDialogOpen(false)
// toast.success('Añadido desde biblioteca');
crearBibliografia(
{
asignatura_id: asignaturaId,
tipo,
cita,
tipo_fuente: 'BIBLIOTECA',
biblioteca_item_id: resource.id,
},
{
onSuccess: () => setIsLibraryDialogOpen(false),
},
)
}
const handleUpdateCita = (id: string, cita: string) => {
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
const handleUpdateCita = (id: string, nuevaCita: string) => {
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 (
<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">
@@ -184,34 +151,27 @@ export function BibliographyItem({
</DialogTrigger>
<DialogContent className="max-w-2xl">
<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}
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
/>
</DialogContent>
</Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" /> Añadir manual
</Button>
</DialogTrigger>
<DialogContent>
<AddManualDialog
tipo={newEntryType}
onTypeChange={setNewEntryType}
onAdd={handleAddManual}
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
existingIds={bibliografia.map(
(e) => e.biblioteca_item_id || '',
)}
/>
</DialogContent>
</Dialog>
<Button
onClick={() => onSave(entries)}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
onClick={() =>
navigate({
to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/nueva`,
resetScroll: false,
})
}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
</Button>
</div>
</div>
@@ -274,13 +234,7 @@ export function BibliographyItem({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setEntries(entries.filter((e) => e.id !== deleteId))
setDeleteId(null)
}}
className="bg-red-600"
>
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
@@ -390,57 +344,16 @@ function BibliografiaCard({
)
}
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
const [cita, setCita] = useState('')
return (
<div className="space-y-4 py-4">
<DialogHeader>
<DialogTitle>Referencia Manual</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Tipo
</label>
<Select value={tipo} onValueChange={onTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Cita APA
</label>
<Textarea
value={cita}
onChange={(e) => setCita(e.target.value)}
placeholder="Autor, A. (Año). Título..."
className="min-h-[120px]"
/>
</div>
<Button
onClick={() => onAdd(cita)}
disabled={!cita.trim()}
className="w-full bg-blue-600"
>
Añadir a la lista
</Button>
</div>
)
}
function LibrarySearchDialog({ onSelect, existingIds }: any) {
function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
const filtered = mockLibraryResources.filter(
(r) =>
const filtered = (resources || []).filter(
(r: any) =>
!existingIds.includes(r.id) &&
r.titulo.toLowerCase().includes(search.toLowerCase()),
r.titulo?.toLowerCase().includes(search.toLowerCase()),
)
console.log(filtered)
console.log(resources)
return (
<div className="space-y-4 py-2">
@@ -468,7 +381,7 @@ function LibrarySearchDialog({ onSelect, existingIds }: any) {
</Select>
</div>
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map((res) => (
{filtered.map((res: any) => (
<div
key={res.id}
onClick={() => onSelect(res, tipo)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,6 @@
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-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 {
AlertDialog,
AlertDialogAction,
@@ -22,54 +12,34 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import type {
DocumentoMateria,
Materia,
MateriaStructure,
} from '@/types/materia'
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';
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps {
documento: DocumentoMateria | null
materia: Materia
estructura: MateriaStructure
datosGenerales: Record<string, any>
pdfUrl: string | null
isLoading: boolean
onDownload: () => void
onRegenerate: () => void
isRegenerating: boolean
}
export function DocumentoSEPTab({
documento,
materia,
estructura,
datosGenerales,
pdfUrl,
isLoading,
onDownload,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
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 = () => {
setShowConfirmDialog(false)
onRegenerate()
//toast.success('Regenerando documento...');
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
@@ -77,28 +47,24 @@ export function DocumentoSEPTab({
Documento SEP
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial para la SEP
Previsualización del documento oficial generado
</p>
</div>
<div className="flex items-center gap-2">
{documento?.estado === 'listo' && (
<Button
variant="outline"
onClick={
() =>
console.log('descargando') /*toast.info('Descarga iniciada')*/
}
>
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating || !isComplete}>
<Button disabled={isRegenerating}>
{isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -107,15 +73,16 @@ export function DocumentoSEPTab({
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription>
Se creará una nueva versión del documento con los datos
actuales de la materia. La versión anterior quedará en el
historial.
Se generará una nueva versión del documento con la información
actual.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRegenerate}>
@@ -127,307 +94,24 @@ export function DocumentoSEPTab({
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Document preview */}
<div className="lg:col-span-2">
<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 - {materia.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">
{materia.nombre}
</h1>
<p className="text-muted-foreground text-sm">
Clave: {materia.clave} | Créditos:{' '}
{materia.creditos || 'N/A'}
</p>
</div>
{/* Datos de la institución */}
<div className="space-y-1 text-sm">
<p>
<strong>Carrera:</strong> {materia.carrera}
</p>
<p>
<strong>Facultad:</strong> {materia.facultad}
</p>
<p>
<strong>Plan de estudios:</strong> {materia.planNombre}
</p>
{materia.ciclo && (
<p>
<strong>Ciclo:</strong> {materia.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="text-center">
<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 className="flex h-full items-center justify-center">
<div className="max-w-sm text-center">
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<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
className={cn(
'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>
)}
</CardContent>
</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>
{/* PDF Preview */}
<Card className="h-[800px] overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
) : pdfUrl ? (
<iframe
src={`${pdfUrl}#toolbar=0`}
className="h-full w-full border-none"
title="Documento SEP"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center">
No se pudo cargar el documento.
</div>
)}
</Card>
</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 { es } from 'date-fns/locale'
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
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)

View File

@@ -0,0 +1,795 @@
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from '@tanstack/react-router'
import {
Sparkles,
Send,
Target,
UserCheck,
Lightbulb,
FileText,
GraduationCap,
BookOpen,
Check,
X,
MessageSquarePlus,
Archive,
History,
Edit2, // Agregado
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useAISubjectChat,
useConversationBySubject,
useMessagesBySubjectChat,
useSubject,
useUpdateSubjectConversationName,
useUpdateSubjectConversationStatus,
} from '@/data'
import { cn } from '@/lib/utils'
interface SelectedField {
key: string
label: string
value: string
}
interface IAAsignaturaTabProps {
asignatura?: Record<string, any>
onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void
}
export function IAAsignaturaTab({
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
const queryClient = useQueryClient()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
// --- ESTADOS ---
const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined,
)
const [showArchived, setShowArchived] = useState(false)
const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [isSending, setIsSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
// --- DATA QUERIES ---
const { data: datosGenerales } = useSubject(asignaturaId)
const { data: todasConversaciones, isLoading: loadingConv } =
useConversationBySubject(asignaturaId)
const { data: rawMessages } = useMessagesBySubjectChat(activeChatId, {
enabled: !!activeChatId,
})
const { mutateAsync: sendMessage } = useAISubjectChat()
const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
const hasInitialSelected = useRef(false)
const { mutate: updateName } = useUpdateSubjectConversationName()
const [editingId, setEditingId] = useState<string | null>(null)
const [tempName, setTempName] = useState('')
const [openIA, setOpenIA] = useState(false)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
// Cálculo del total para el Badge del botón
const totalReferencias =
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
const isAiThinking = useMemo(() => {
if (isSending) return true
if (!rawMessages || rawMessages.length === 0) return false
// Verificamos si el último mensaje está en estado de procesamiento
const lastMessage = rawMessages[rawMessages.length - 1]
return (
lastMessage.estado === 'PROCESANDO' || lastMessage.estado === 'PENDIENTE'
)
}, [isSending, rawMessages])
// --- AUTO-SCROLL ---
useEffect(() => {
const viewport = scrollRef.current?.querySelector(
'[data-radix-scroll-area-viewport]',
)
if (viewport) {
viewport.scrollTop = viewport.scrollHeight
}
}, [rawMessages, isSending])
// --- FILTRADO DE CHATS ---
const { activeChats, archivedChats } = useMemo(() => {
const chats = todasConversaciones || []
return {
activeChats: chats.filter((c: any) => c.estado === 'ACTIVA'),
archivedChats: chats.filter((c: any) => c.estado === 'ARCHIVADA'),
}
}, [todasConversaciones])
const availableFields = useMemo(() => {
// 1. Obtenemos los campos dinámicos de la DB
const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
estructuraProps[key]?.title ||
key.replace(/_/g, ' ').toUpperCase(),
value: String(datosGenerales.datos[key] || ''),
}
})
: []
// 2. Definimos tus campos manuales (hardcoded)
const hardcodedFields = [
{
key: 'contenido_tematico',
label: 'Contenido temático',
value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera
},
{
key: 'criterios_de_evaluacion',
label: 'Criterios de evaluación',
value: '',
},
]
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
const combined = [...dynamicFields]
hardcodedFields.forEach((hf) => {
if (!combined.some((f) => f.key === hf.key)) {
combined.push(hf)
}
})
return combined
}, [datosGenerales])
// --- PROCESAMIENTO DE MENSAJES ---
// --- PROCESAMIENTO DE MENSAJES ---
const messages = useMemo(() => {
const msgs: Array<any> = []
// 1. Mensajes existentes de la DB
if (rawMessages) {
rawMessages.forEach((m) => {
// Mensaje del usuario
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
// Respuesta de la IA (si existe)
if (m.respuesta) {
const sugerencias =
m.propuesta?.recommendations?.map((rec: any, index: number) => ({
id: `${m.id}-sug-${index}`,
messageId: m.id,
campoKey: rec.campo_afectado,
campoNombre: rec.campo_afectado.replace(/_/g, ' '),
valorSugerido: rec.texto_mejora,
aceptada: rec.aplicada,
})) || []
msgs.push({
id: `${m.id}-ai`,
role: 'assistant',
content: m.respuesta,
sugerencias: sugerencias,
})
}
})
}
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
if (isSending && input.trim()) {
msgs.push({
id: 'optimistic-user-msg',
role: 'user',
content: input,
})
}
return msgs
}, [rawMessages, isSending, input])
// Auto-selección inicial
useEffect(() => {
// Si ya hay un chat, o si el usuario ya interactuó (hasInitialSelected), abortamos.
if (activeChatId || hasInitialSelected.current) return
if (activeChats.length > 0 && !loadingConv) {
setActiveChatId(activeChats[0].id)
hasInitialSelected.current = true
}
}, [activeChats, loadingConv])
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
return availableFields.filter(
(f) =>
f.label.toLowerCase().includes(query) ||
f.key.toLowerCase().includes(query),
)
}, [availableFields, input, showSuggestions])
// 2. Efecto para cerrar con ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowSuggestions(false)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field])
}
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
setInput(nuevoTexto)
}
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
}
const handleSaveName = (id: string) => {
if (tempName.trim()) {
updateName({ id, nombre: tempName })
}
setEditingId(null)
}
const handleSend = async (promptOverride?: string) => {
const text = promptOverride || input
if (!text.trim() && selectedFields.length === 0) return
setIsSending(true)
try {
const response = await sendMessage({
subjectId: asignaturaId as any, // Importante: se usa para crear la conv si activeChatId es undefined
content: text,
campos: selectedFields.map((f) => f.key),
conversacionId: activeChatId, // Si es undefined, la mutación crea el chat automáticamente
})
// IMPORTANTE: Después de la respuesta, actualizamos el ID activo con el que creó el backend
if (response.conversacionId) {
setActiveChatId(response.conversacionId)
}
setInput('')
// setSelectedFields([])
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
queryClient.invalidateQueries({
queryKey: ['conversation-by-subject', asignaturaId],
})
} catch (error) {
console.error('Error al enviar mensaje:', error)
} finally {
setIsSending(false)
}
}
const toggleField = (field: SelectedField) => {
setSelectedFields((prev) =>
prev.find((f) => f.key === field.key)
? prev.filter((f) => f.key !== field.key)
: [...prev, field],
)
}
const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
setInput('')
setSelectedFields([])
// Opcional: podrías forzar el foco al textarea aquí con una ref
}
const PRESETS = [
{
id: 'mejorar-obj',
label: 'Mejorar objetivo',
icon: Target,
prompt: 'Mejora la redacción del objetivo...',
},
{
id: 'sugerir-cont',
label: 'Sugerir contenido',
icon: BookOpen,
prompt: 'Genera un desglose de temas...',
},
{
id: 'actividades',
label: 'Actividades',
icon: GraduationCap,
prompt: 'Sugiere actividades prácticas...',
},
]
return (
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* PANEL IZQUIERDO */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
<History size={14} /> Historial
</h2>
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8',
showArchived && 'bg-teal-50 text-teal-600',
)}
onClick={() => setShowArchived(!showArchived)}
>
<Archive size={16} />
</Button>
</div>
<Button
onClick={() => {
setActiveChatId(undefined)
hasInitialSelected.current = true
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
queryClient.setQueryData(['subject-messages', undefined], [])
}}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 hover:border-teal-500"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id
? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100',
)}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
>
{editingId === chat.id ? (
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
}}
/>
</div>
) : (
<>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span
onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
>
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
const nuevoEstado =
chat.estado === 'ACTIVA'
? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={14} />
) : (
<History size={14} className="scale-x-[-1]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
)}
</div>
))}
</div>
</ScrollArea>
</div>
{/* PANEL CENTRAL */}
<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="flex shrink-0 items-center justify-between border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA
</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"
>
<FileText size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="animate-in zoom-in 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 className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-8 p-6">
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
'flex gap-4',
msg.role === 'user' ? 'flex-row-reverse' : 'flex-row',
)}
>
<Avatar
className={cn(
'h-9 w-9 shrink-0 border shadow-sm',
msg.role === 'assistant'
? 'bg-teal-600 text-white'
: 'bg-slate-100',
)}
>
<AvatarFallback>
{msg.role === 'assistant' ? (
<Sparkles size={16} />
) : (
<UserCheck size={16} />
)}
</AvatarFallback>
</Avatar>
<div
className={cn(
'flex max-w-[85%] flex-col gap-3',
msg.role === 'user' ? 'items-end' : 'items-start',
)}
>
<div
className={cn(
'relative overflow-hidden rounded-2xl border shadow-sm',
msg.role === 'user'
? 'rounded-tr-none border-teal-700 bg-teal-600 px-4 py-3 text-white'
: 'w-full rounded-tl-none border-slate-200 bg-white text-slate-800',
)}
>
{/* Texto del mensaje principal */}
<div
style={{ whiteSpace: 'pre-line' }}
className={cn(
'text-sm leading-relaxed',
msg.role === 'assistant' && 'p-4',
)}
>
{msg.content}
</div>
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
{msg.role === 'assistant' &&
msg.sugerencias &&
msg.sugerencias.length > 0 && (
<div className="space-y-3 border-t bg-slate-50/50 p-3">
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles:
</p>
{msg.sugerencias.map((sug: any) => (
<ImprovementCard
key={sug.id}
sug={sug}
asignaturaId={asignaturaId}
onApplied={(campoFinalizado) => {
// Filtramos el array para conservar todos MENOS el que se aplicó
console.log(campoFinalizado)
console.log('campos:', selectedFields)
setSelectedFields((prev) =>
prev.filter((fieldObj) => {
// Accedemos a .key porque fieldObj es { key: "...", label: "..." }
return fieldObj.key !== campoFinalizado
}),
)
}}
/>
))}
</div>
)}
</div>
</div>
</div>
))}
{isAiThinking && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}
{/* Espacio extra al final para que el scroll no tape el último mensaje */}
<div className="h-4" />
</div>
</ScrollArea>
</div>
{/* INPUT */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button
key={field.key}
onClick={() => handleSelectField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
>
<div className="flex flex-col">
<span className="font-medium text-slate-700">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
</div>
</div>
)}
<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">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
>
<Target size={10} />
{field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
if (e.target.value.endsWith(':')) setShowSuggestions(true)
else if (showSuggestions && !e.target.value.includes(':'))
setShowSuggestions(false)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder='Escribe ":" para referenciar un campo...'
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) || isSending
}
size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
</div>
</div>
</div>
</div>
</div>
{/* PANEL DERECHO ACCIONES */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Atajos
</h4>
<div className="space-y-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:bg-teal-50"
>
<div className="rounded-lg bg-slate-100 p-2 group-hover:bg-teal-100 group-hover:text-teal-600">
<preset.icon size={16} />
</div>
<span className="font-medium text-slate-700">{preset.label}</span>
</button>
))}
</div>
</div>
{/* --- DRAWER DE REFERENCIAS --- */}
<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">
<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 hover:text-slate-600"
>
<X size={18} />
</button>
</div>
<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>
)
}

View File

@@ -1,357 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Sparkles, Bot, User, Check, X, RefreshCw, Lightbulb, Wand2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import type { IAMessage, IASugerencia, CampoEstructura } from '@/types/materia';
import { cn } from '@/lib/utils';
//import { toast } from 'sonner';
interface IAMateriaTabProps {
campos: CampoEstructura[];
datosGenerales: Record<string, any>;
messages: IAMessage[];
onSendMessage: (message: string, campoId?: string) => void;
onAcceptSuggestion: (sugerencia: IASugerencia) => void;
onRejectSuggestion: (messageId: string) => void;
}
const quickActions = [
{ id: 'mejorar-objetivos', label: 'Mejorar objetivos', icon: Wand2, prompt: 'Mejora el :objetivo_general para que sea más específico y medible' },
{ id: 'generar-contenido', label: 'Generar contenido temático', icon: Lightbulb, prompt: 'Sugiere un contenido temático completo basado en los objetivos y competencias' },
{ id: 'alinear-perfil', label: 'Alinear con perfil de egreso', icon: RefreshCw, prompt: 'Revisa las :competencias y alinéalas con el perfil de egreso del plan' },
{ id: 'ajustar-biblio', label: 'Recomendar bibliografía', icon: Sparkles, prompt: 'Recomienda bibliografía actualizada basándote en el contenido temático' },
];
export function IAMateriaTab({ campos, datosGenerales, messages, onSendMessage, onAcceptSuggestion, onRejectSuggestion }: IAMateriaTabProps) {
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showFieldSelector, setShowFieldSelector] = useState(false);
const [fieldSelectorPosition, setFieldSelectorPosition] = useState({ top: 0, left: 0 });
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const pos = e.target.selectionStart;
setInput(value);
setCursorPosition(pos);
// Check for : character to trigger field selector
const lastChar = value.charAt(pos - 1);
if (lastChar === ':') {
const rect = textareaRef.current?.getBoundingClientRect();
if (rect) {
setFieldSelectorPosition({ top: rect.bottom + 8, left: rect.left });
setShowFieldSelector(true);
}
} else if (showFieldSelector && (lastChar === ' ' || !value.includes(':'))) {
setShowFieldSelector(false);
}
};
const insertFieldMention = (campoId: string) => {
const beforeCursor = input.slice(0, cursorPosition);
const afterCursor = input.slice(cursorPosition);
const lastColonIndex = beforeCursor.lastIndexOf(':');
const newInput = beforeCursor.slice(0, lastColonIndex) + `:${campoId}` + afterCursor;
setInput(newInput);
setShowFieldSelector(false);
textareaRef.current?.focus();
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
// Extract field mention if any
const fieldMatch = input.match(/:(\w+)/);
const campoId = fieldMatch ? fieldMatch[1] : undefined;
setIsLoading(true);
onSendMessage(input, campoId);
setInput('');
// Simulate AI response delay
setTimeout(() => {
setIsLoading(false);
}, 1500);
};
const handleQuickAction = (prompt: string) => {
setInput(prompt);
textareaRef.current?.focus();
};
const renderMessageContent = (content: string) => {
// Render field mentions as styled badges
return content.split(/(:[\w_]+)/g).map((part, i) => {
if (part.startsWith(':')) {
const campo = campos.find(c => c.id === part.slice(1));
return (
<span key={i} className="field-mention mx-0.5">
{campo?.nombre || part}
</span>
);
}
return part;
});
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-accent" />
IA de la materia
</h2>
<p className="text-sm text-muted-foreground mt-1">
Usa <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">:</kbd> para mencionar campos específicos
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chat area */}
<Card className="lg:col-span-2 card-elevated flex flex-col h-[600px]">
<CardHeader className="pb-2 border-b">
<CardTitle className="text-sm font-medium text-muted-foreground">
Conversación
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-4">
{messages.length === 0 ? (
<div className="text-center py-12">
<Bot className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
Inicia una conversación para mejorar tu materia con IA
</p>
</div>
) : (
messages.map((message) => (
<div key={message.id} className={cn(
"flex gap-3",
message.role === 'user' ? "justify-end" : "justify-start"
)}>
{message.role === 'assistant' && (
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-accent" />
</div>
)}
<div className={cn(
"max-w-[80%] rounded-lg px-4 py-3",
message.role === 'user'
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
<p className="text-sm whitespace-pre-wrap">
{renderMessageContent(message.content)}
</p>
{message.sugerencia && !message.sugerencia.aceptada && (
<div className="mt-3 p-3 bg-background/80 rounded-md border">
<p className="text-xs font-medium text-muted-foreground mb-2">
Sugerencia para: {message.sugerencia.campoNombre}
</p>
<div className="text-sm text-foreground bg-accent/10 p-2 rounded mb-3 max-h-32 overflow-y-auto">
{message.sugerencia.valorSugerido}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => onAcceptSuggestion(message.sugerencia!)}
className="bg-success hover:bg-success/90 text-success-foreground"
>
<Check className="w-3 h-3 mr-1" />
Aplicar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onRejectSuggestion(message.id)}
>
<X className="w-3 h-3 mr-1" />
Rechazar
</Button>
</div>
</div>
)}
{message.sugerencia?.aceptada && (
<Badge className="mt-2 badge-library">
<Check className="w-3 h-3 mr-1" />
Sugerencia aplicada
</Badge>
)}
</div>
{message.role === 'user' && (
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-primary-foreground" />
</div>
)}
</div>
))
)}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-accent animate-pulse" />
</div>
<div className="bg-muted rounded-lg px-4 py-3">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="p-4 border-t">
<div className="relative">
<Textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Escribe tu mensaje... Usa : para mencionar campos"
className="min-h-[80px] pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="sm"
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="absolute bottom-3 right-3 h-8 w-8 p-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
{/* Field selector popover */}
{showFieldSelector && (
<div className="absolute z-50 mt-1 w-64 bg-popover border rounded-lg shadow-lg">
<Command>
<CommandInput placeholder="Buscar campo..." />
<CommandList>
<CommandEmpty>No se encontró el campo</CommandEmpty>
<CommandGroup heading="Campos disponibles">
{campos.map((campo) => (
<CommandItem
key={campo.id}
value={campo.id}
onSelect={() => insertFieldMention(campo.id)}
className="cursor-pointer"
>
<span className="font-mono text-xs text-accent mr-2">
:{campo.id}
</span>
<span>{campo.nombre}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</CardContent>
</Card>
{/* Sidebar with quick actions and fields */}
<div className="space-y-4">
{/* Quick actions */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Acciones rápidas</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
variant="outline"
className="w-full justify-start text-left h-auto py-3"
onClick={() => handleQuickAction(action.prompt)}
>
<Icon className="w-4 h-4 mr-2 text-accent flex-shrink-0" />
<span className="text-sm">{action.label}</span>
</Button>
);
})}
</CardContent>
</Card>
{/* Available fields */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Campos de la materia</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[280px]">
<div className="space-y-2">
{campos.map((campo) => {
const hasValue = !!datosGenerales[campo.id];
return (
<div
key={campo.id}
className={cn(
"p-2 rounded-md border cursor-pointer transition-colors hover:bg-muted/50",
hasValue ? "border-success/30" : "border-warning/30"
)}
onClick={() => {
setInput(prev => prev + `:${campo.id} `);
textareaRef.current?.focus();
}}
>
<div className="flex items-center justify-between">
<span className="text-xs font-mono text-accent">:{campo.id}</span>
{hasValue ? (
<Badge variant="outline" className="text-xs text-success border-success/30">
Completo
</Badge>
) : (
<Badge variant="outline" className="text-xs text-warning border-warning/30">
Vacío
</Badge>
)}
</div>
<p className="text-sm text-foreground mt-1">{campo.nombre}</p>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,543 +0,0 @@
import {
createFileRoute,
Link,
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 { ContenidoTematico } from './ContenidoTematico'
import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
import { IAMateriaTab } from './IAMateriaTab'
import type { CampoEstructura, IAMessage, IASugerencia } from '@/types/materia'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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 { useSubject } from '@/data/hooks/useSubjects'
import {
mockMateria,
mockEstructura,
mockDocumentoSep,
} from '@/data/mockMateriaData'
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
}
export interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export interface AsignaturaDatos {
[key: string]: string
}
export interface AsignaturaResponse {
datos: AsignaturaDatos
}
function EditableHeaderField({
value,
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
return (
<input
type="text"
value={String(value)}
onChange={(e) => onSave(e.target.value)}
onBlur={(e) => onSave(e.target.value)}
className={` w-[${String(value).length || 1}ch] max-w-[6ch] border-none bg-transparent text-center outline-none focus:ring-2 focus:ring-blue-400 ${className ?? ''} `}
/>
)
}
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: MateriaDetailPage,
})
export default function MateriaDetailPage() {
const routerState = useRouterState()
const state = routerState.location.state as any
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
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>>([])
// Dentro de MateriaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
// 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 ---------- */
useEffect(() => {
if (asignaturasApi?.datos) {
setDatosGenerales(asignaturasApi.datos)
}
}, [asignaturasApi])
// 2. Funciones de manejo para la IA
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"
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" />
{asignaturasApi?.planes_estudio?.datos?.nombre}
</span>
<span>
{asignaturasApi?.planes_estudio?.carreras?.facultades?.nombre}
</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 defaultValue="datos">
<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 materia</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} />
</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">
<IAMateriaTab
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}
materia={mockMateria}
estructura={mockEstructura}
datosGenerales={datosGenerales}
onRegenerate={handleRegenerateDocument}
isRegenerating={isRegenerating}
/>
</TabsContent>
<TabsContent value="historial">
<HistorialTab asignaturaId={asignaturaId} />
</TabsContent>
</Tabs>
</div>
</section>
</div>
)
}
/* ================= TAB CONTENT ================= */
interface DatosGeneralesProps {
data: AsignaturaDatos
isLoading: boolean
}
function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
const formatTitle = (key: string): string =>
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
{/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Datos Generales
</h2>
<p className="mt-1 text-slate-500">
Información oficial estructurada bajo los lineamientos de la SEP.
</p>
</div>
</div>
{/* Grid de Información */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2">
{isLoading && <p>Cargando información...</p>}
{!isLoading &&
Object.entries(data).map(([key, value]) => (
<InfoCard
key={key}
title={formatTitle(key)}
initialContent={value}
onEnhanceAI={(contenido) => {
console.log('Llevar a IA:', contenido)
// Aquí tu lógica: setPestañaActiva('mejorar-con-ia');
}}
/>
))}
</div>
{/* Columna Lateral (Información Secundaria) */}
<div className="space-y-6">
<div className="space-y-6">
{/* Tarjeta de Requisitos */}
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/>
{/* Tarjeta de Evaluación */}
<InfoCard
title="Sistema de Evaluación"
type="evaluation"
initialContent={[
{ label: 'Exámenes parciales', value: '30%' },
{ label: 'Proyecto integrador', value: '35%' },
{ label: 'Prácticas de laboratorio', value: '20%' },
{ label: 'Participación', value: '15%' },
]}
/>
</div>
</div>
</div>
</div>
)
}
interface InfoCardProps {
title: string
initialContent: any
type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void // Nueva prop para la acción de IA
}
function InfoCard({
title,
initialContent,
type = 'text',
onEnhanceAI,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(
type === 'text' ? initialContent : JSON.stringify(initialContent, null, 2),
)
const handleSave = () => {
setData(tempText)
setIsEditing(false)
}
return (
<Card className="transition-all hover:border-slate-300">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-bold text-slate-700">
{title}
</CardTitle>
{!isEditing && (
<div className="flex gap-1">
{/* NUEVO: Botón de Mejorar con IA */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-50 hover:text-blue-600"
onClick={() => onEnhanceAI?.(data)} // Enviamos la data actual a la IA
title="Mejorar con IA"
>
<Sparkles className="h-4 w-4" />
</Button>
{/* Botón de Editar original */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => setIsEditing(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
)}
</CardHeader>
<CardContent>
{isEditing ? (
<div className="space-y-3">
<Textarea
value={tempText}
onChange={(e) => setTempText(e.target.value)}
className="min-h-[100px] text-xs"
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancelar
</Button>
<Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="text-sm">
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />}
{type === 'text' && <p className="text-slate-600">{data}</p>}
</div>
)}
</CardContent>
</Card>
)
}
// Vista de Requisitos
function RequirementsView({ items }: { items: Array<any> }) {
return (
<div className="space-y-3">
{items.map((req, i) => (
<div
key={i}
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
>
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
{req.type}
</p>
<p className="text-sm font-medium text-slate-700">
{req.code} {req.name}
</p>
</div>
))}
</div>
)
}
// Vista de Evaluación
function EvaluationView({ items }: { items: Array<any> }) {
return (
<div className="space-y-2">
{items.map((item, i) => (
<div
key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
>
<span className="text-slate-500">{item.label}</span>
<span className="font-bold text-blue-600">{item.value}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
import { useState } from 'react'
import type { IASugerencia } from '@/types/asignatura'
import { Button } from '@/components/ui/button'
import {
useUpdateAsignatura,
useSubject,
useUpdateSubjectRecommendation,
} from '@/data'
import { cn } from '@/lib/utils'
interface ImprovementCardProps {
sug: IASugerencia
asignaturaId: string
onApplied: (campoKey: string) => void
}
export function ImprovementCard({
sug,
asignaturaId,
onApplied,
}: ImprovementCardProps) {
const { data: asignatura } = useSubject(asignaturaId)
const updateAsignatura = useUpdateAsignatura()
const updateRecommendation = useUpdateSubjectRecommendation()
const [isApplying, setIsApplying] = useState(false)
const handleApply = async () => {
if (!asignatura) return
setIsApplying(true)
try {
// 1. Identificar a qué columna debe ir el guardado
let patchData = {}
if (sug.campoKey === 'contenido_tematico') {
// Se guarda directamente en la columna contenido_tematico
patchData = { contenido_tematico: sug.valorSugerido }
} else if (sug.campoKey === 'criterios_de_evaluacion') {
// Se guarda directamente en la columna criterios_de_evaluacion
patchData = { criterios_de_evaluacion: sug.valorSugerido }
} else {
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
patchData = {
datos: {
...asignatura.datos,
[sug.campoKey]: sug.valorSugerido,
},
}
}
// 2. Ejecutar la actualización con la estructura correcta
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: patchData as any,
})
// 3. Marcar la recomendación como aplicada
await updateRecommendation.mutateAsync({
mensajeId: sug.messageId,
campoAfectado: sug.campoKey,
})
console.log(sug.campoKey)
onApplied(sug.campoKey)
} catch (error) {
console.error('Error al aplicar mejora:', error)
} finally {
setIsApplying(false)
}
}
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
const renderContenido = (valor: any) => {
// Si no es un array, es texto simple
if (!Array.isArray(valor)) {
return <p className="italic">"{String(valor)}"</p>
}
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
if (valor[0]?.hasOwnProperty('unidad')) {
return (
<div className="space-y-3">
{valor.map((u: any, idx: number) => (
<div
key={idx}
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
>
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
</div>
<ul className="space-y-1">
{u.temas?.map((t: any, tidx: number) => (
<li
key={tidx}
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
>
<span className="leading-tight"> {t.nombre}</span>
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
<Clock size={10} /> {t.horasEstimadas}h
</span>
</li>
))}
</ul>
</div>
))}
</div>
)
}
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
if (valor[0]?.hasOwnProperty('criterio')) {
return (
<div className="space-y-2">
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
<ListChecks size={12} /> Desglose de evaluación
</div>
{valor.map((c: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
>
<span className="text-[11px] leading-tight text-slate-700">
{c.criterio}
</span>
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
{c.porcentaje}%
</div>
</div>
))}
{/* Opcional: Suma total para verificar que de 100% */}
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
Total:{' '}
{valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
0,
)}
%
</div>
</div>
)
}
// Caso por defecto (Array genérico)
return (
<pre className="text-[10px]">
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
</pre>
)
}
// --- ESTADO APLICADO ---
if (sug.aceptada) {
return (
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-slate-800">
{sug.campoNombre}
</span>
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
<Check size={14} />
Aplicado
</div>
</div>
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}
// --- ESTADO PENDIENTE ---
return (
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
{sug.campoNombre}
</span>
<Button
size="sm"
disabled={isApplying || !asignatura}
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
onClick={handleApply}
>
{isApplying ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<Check size={14} className="mr-1.5" />
)}
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
</Button>
</div>
<div
className={cn(
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)}
>
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}

View File

@@ -1,155 +0,0 @@
import type {
NewSubjectWizardState,
TipoAsignatura,
} from '@/features/asignaturas/nueva/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ESTRUCTURAS_SEP,
TIPOS_MATERIA,
} from '@/features/asignaturas/nueva/catalogs'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
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"
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="clave">Clave (Opcional)</Label>
<Input
id="clave"
placeholder="Ej. MAT-101"
value={wizard.datosBasicos.clave || ''}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label>
<Select
value={wizard.datosBasicos.tipo}
onValueChange={(val) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura },
}))
}
>
<SelectTrigger
id="tipo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue />
</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="number"
min={0}
value={wizard.datosBasicos.creditos}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: Number(e.target.value || 0),
},
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horas">Horas / Semana</Label>
<Input
id="horas"
type="number"
min={0}
value={wizard.datosBasicos.horasSemana || 0}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasSemana: Number(e.target.value || 0),
},
}))
}
/>
</div>
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId}
onValueChange={(val) =>
onChange((w) => ({
...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_SEP.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
</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

@@ -1,286 +0,0 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
ARCHIVOS_SISTEMA_MOCK,
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/nueva/catalogs'
export function PasoConfiguracionPanel({
wizard,
onChange,
onGenerarIA,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
onGenerarIA: () => void
}) {
if (wizard.modoCreacion === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Configuración Manual</CardTitle>
<CardDescription>
La asignatura se creará vacía. Podrás editar el contenido detallado
en la siguiente pantalla.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.modoCreacion === 'IA') {
return (
<div className="grid gap-4">
<div className="grid gap-1">
<Label>Descripción del enfoque</Label>
<Textarea
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
value={wizard.iaConfig?.descripcionEnfoque}
onChange={(e) =>
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoque: e.target.value,
},
}))
}
className="min-h-25"
/>
</div>
<div className="grid gap-1">
<Label>Notas adicionales</Label>
<Textarea
placeholder="Restricciones, bibliografía sugerida, etc."
value={wizard.iaConfig?.notasAdicionales}
onChange={(e) =>
onChange((w) => ({
...w,
iaConfig: { ...w.iaConfig!, notasAdicionales: e.target.value },
}))
}
/>
</div>
<div className="grid gap-2">
<Label>Archivos de contexto (Opcional)</Label>
<div className="flex flex-col gap-2 rounded-md border p-3">
{ARCHIVOS_SISTEMA_MOCK.map((file) => (
<div key={file.id} className="flex items-center gap-2">
<input
type="checkbox"
id={file.id}
checked={wizard.iaConfig?.archivosExistentesIds.includes(
file.id,
)}
onChange={(e) => {
const checked = e.target.checked
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosExistentesIds: checked
? [
...(w.iaConfig?.archivosExistentesIds || []),
file.id,
]
: w.iaConfig?.archivosExistentesIds.filter(
(id) => id !== file.id,
) || [],
},
}))
}}
/>
<Label htmlFor={file.id} className="font-normal">
{file.name}
</Label>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<Button onClick={onGenerarIA} disabled={wizard.isLoading}>
{wizard.isLoading ? (
<>
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
<>
<Icons.Sparkles className="mr-2 h-4 w-4" /> Generar Preview
</>
)}
</Button>
</div>
{wizard.resumen.previewAsignatura && (
<Card className="bg-muted/50 border-dashed">
<CardHeader>
<CardTitle className="text-base">Vista previa generada</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
<p>
<strong>Objetivo:</strong>{' '}
{wizard.resumen.previewAsignatura.objetivo}
</p>
<p className="mt-2">
Se detectaron {wizard.resumen.previewAsignatura.unidades}{' '}
unidades temáticas y{' '}
{wizard.resumen.previewAsignatura.bibliografiaCount} fuentes
bibliográficas.
</p>
</CardContent>
</Card>
)}
</div>
)
}
if (wizard.subModoClonado === 'INTERNO') {
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3">
<div>
<Label>Facultad</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, facultadId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Plan</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, planOrigenId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{PLANES_MOCK.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Buscar</Label>
<Input placeholder="Nombre..." />
</div>
</div>
<div className="grid max-h-75 gap-2 overflow-y-auto">
{MATERIAS_MOCK.map((m) => (
<div
key={m.id}
onClick={() =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
wizard.clonInterno?.asignaturaOrigenId === m.id
? 'border-primary bg-primary/5 ring-primary ring-1'
: ''
}`}
>
<div>
<div className="font-medium">{m.nombre}</div>
<div className="text-muted-foreground text-xs">
{m.clave} {m.creditos} créditos
</div>
</div>
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
)}
</div>
))}
</div>
</div>
)
}
if (wizard.subModoClonado === 'TRADICIONAL') {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center">
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
<h3 className="mb-1 text-sm font-medium">
Sube el Word de la asignatura
</h3>
<p className="text-muted-foreground mb-4 text-xs">
Arrastra el archivo o haz clic para buscar (.doc, .docx)
</p>
<Input
type="file"
accept=".doc,.docx"
className="mx-auto max-w-xs"
onChange={(e) =>
onChange((w) => ({
...w,
clonTradicional: {
...w.clonTradicional!,
archivoWordAsignaturaId:
e.target.files?.[0]?.name || 'mock_file',
},
}))
}
/>
</div>
{wizard.clonTradicional?.archivoWordAsignaturaId && (
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
<Icons.FileText className="h-4 w-4" />
Archivo cargado listo para procesar.
</div>
)}
</div>
)
}
return null
}

View File

@@ -0,0 +1,459 @@
import * as Icons from 'lucide-react'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
import {
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/nueva/catalogs'
export function PasoDetallesPanel({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const { data: estructurasAsignatura } = useSubjectEstructuras()
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
if (wizard.tipoOrigen === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Configuración Manual</CardTitle>
<CardDescription>
La asignatura se creará vacía. Podrás editar el contenido detallado
en la siguiente pantalla.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.tipoOrigen === 'IA_SIMPLE') {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label>Descripción del enfoque académico</Label>
<Textarea
placeholder="Describe el enfoque, alcance y público objetivo. Ej.: Teórica-práctica enfocada en patrones de diseño, con proyectos semanales..."
maxLength={7000}
value={wizard.iaConfig?.descripcionEnfoqueAcademico}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoqueAcademico: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="flex flex-col gap-1">
<Label>
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Textarea
placeholder="Opcional: restricciones y preferencias. Ej.: incluye bibliografía en español, evita contenido avanzado, prioriza evaluación por proyectos..."
maxLength={7000}
value={wizard.iaConfig?.instruccionesAdicionalesIA}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
instruccionesAdicionalesIA: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((a) => a !== id)
return {
...w,
iaConfig: {
...w.iaConfig!,
archivosReferencia: next,
},
}
})
}
onToggleRepositorio={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((r) => r !== id)
return {
...w,
iaConfig: {
...w.iaConfig!,
repositoriosReferencia: next,
},
}
})
}
onFilesChange={(files: Array<UploadedFile>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosAdjuntos: files,
},
}),
)
}
/>
</div>
)
}
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') {
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3">
<div>
<Label>Facultad</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, facultadId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Plan</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, planOrigenId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{PLANES_MOCK.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Buscar</Label>
<Input placeholder="Nombre..." />
</div>
</div>
<div className="grid max-h-75 gap-2 overflow-y-auto">
{MATERIAS_MOCK.map((m) => (
<div
key={m.id}
role="button"
tabIndex={0}
onClick={() =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}}
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
wizard.clonInterno?.asignaturaOrigenId === m.id
? 'border-primary bg-primary/5 ring-primary ring-1'
: ''
}`}
>
<div>
<div className="font-medium">{m.nombre}</div>
<div className="text-muted-foreground text-xs">
{m.clave} {m.creditos} créditos
</div>
</div>
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
)}
</div>
))}
</div>
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center">
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
<h3 className="mb-1 text-sm font-medium">
Sube el Word de la asignatura
</h3>
<p className="text-muted-foreground mb-4 text-xs">
Arrastra el archivo o haz clic para buscar (.doc, .docx)
</p>
<Input
type="file"
accept=".doc,.docx"
className="mx-auto max-w-xs"
onChange={(e) =>
onChange((w) => ({
...w,
clonTradicional: {
...w.clonTradicional!,
archivoWordAsignaturaId:
e.target.files?.[0]?.name || 'mock_file',
},
}))
}
/>
</div>
{wizard.clonTradicional?.archivoWordAsignaturaId && (
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
<Icons.FileText className="h-4 w-4" />
Archivo cargado listo para procesar.
</div>
)}
</div>
)
}
return null
}

View File

@@ -1,10 +1,6 @@
import * as Icons from 'lucide-react'
import type {
ModoCreacion,
NewSubjectWizardState,
SubModoClonado,
} from '@/features/asignaturas/nueva/types'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import {
Card,
@@ -21,19 +17,33 @@ export function PasoMetodoCardGroup({
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
wizard.tipoOrigen === modo
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key
if (
key === 'Enter' ||
key === ' ' ||
key === 'Spacebar' ||
key === 'Space'
) {
e.preventDefault()
e.stopPropagation()
cb()
}
}
return (
<div className="grid gap-4 sm:grid-cols-3">
<Card
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w) => ({
...w,
modoCreacion: 'MANUAL',
subModoClonado: undefined,
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'MANUAL',
}),
)
}
role="button"
tabIndex={0}
@@ -51,11 +61,12 @@ export function PasoMetodoCardGroup({
<Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w) => ({
...w,
modoCreacion: 'IA',
subModoClonado: undefined,
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA',
}),
)
}
role="button"
tabIndex={0}
@@ -66,11 +77,94 @@ export function PasoMetodoCardGroup({
</CardTitle>
<CardDescription>Generar contenido automático.</CardDescription>
</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
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
onClick={() =>
onChange(
(w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'CLONADO' }),
)
}
role="button"
tabIndex={0}
>
@@ -80,51 +174,79 @@ export function PasoMetodoCardGroup({
</CardTitle>
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
</CardHeader>
{wizard.modoCreacion === 'CLONADO' && (
<CardContent>
<div className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
}}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSubSelected('INTERNO')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Database className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Del sistema</span>
<span className="text-xs opacity-70">
Buscar en otros planes
</span>
</div>
{(wizard.tipoOrigen === 'CLONADO' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<CardContent className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
),
)
}
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('CLONADO_INTERNO')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Database className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Del sistema</span>
<span className="text-xs opacity-70">
Buscar en otros planes
</span>
</div>
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
}}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSubSelected('TRADICIONAL')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Upload className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Desde archivos</span>
<span className="text-xs opacity-70">
Subir Word existente
</span>
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
),
)
}
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('CLONADO_TRADICIONAL')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Upload className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Desde archivos</span>
<span className="text-xs opacity-70">Subir Word existente</span>
</div>
</div>
</CardContent>

View File

@@ -9,9 +9,45 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: estructuras } = useSubjectEstructuras()
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
const estructuraNombre = (() => {
const estructuraId = wizard.datosBasicos.estructuraId
if (!estructuraId) return '—'
const hit = estructuras?.find((e) => e.id === estructuraId)
return hit?.nombre ?? estructuraId
})()
const modoLabel = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)'
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_TRADICIONAL') return 'Clonada (Archivo)'
return '—'
})()
const creditosText =
typeof wizard.datosBasicos.creditos === 'number' &&
Number.isFinite(wizard.datosBasicos.creditos)
? wizard.datosBasicos.creditos.toFixed(2)
: '—'
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? []
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 (
<Card>
<CardHeader>
@@ -20,54 +56,238 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
Verifica los datos antes de crear la asignatura.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Nombre:</span>
<div className="font-medium">{wizard.datosBasicos.nombre}</div>
</div>
<div>
<span className="text-muted-foreground">Tipo:</span>
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
</div>
<div>
<span className="text-muted-foreground">Créditos:</span>
<div className="font-medium">{wizard.datosBasicos.creditos}</div>
</div>
<div>
<span className="text-muted-foreground">Estructura:</span>
<div className="font-medium">
{
ESTRUCTURAS_SEP.find(
(e) => e.id === wizard.datosBasicos.estructuraId,
)?.label
}
<CardContent>
<div className="grid gap-4 text-sm">
<div className="grid gap-2">
<div>
<span className="text-muted-foreground">Plan de estudios: </span>
<span className="font-medium">
{plan?.nombre || wizard.plan_estudio_id || '—'}
</span>
</div>
{plan?.carreras?.nombre ? (
<div>
<span className="text-muted-foreground">Carrera: </span>
<span className="font-medium">{plan.carreras.nombre}</span>
</div>
) : null}
</div>
</div>
<div className="bg-muted rounded-md p-3">
<span className="text-muted-foreground">Modo de creación:</span>
<div className="flex items-center gap-2 font-medium">
{wizard.modoCreacion === 'MANUAL' && (
<>
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía)
</>
)}
{wizard.modoCreacion === 'IA' && (
<>
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
</>
)}
{wizard.modoCreacion === 'CLONADO' && (
<>
<Icons.Copy className="h-4 w-4" /> Clonada
{wizard.subModoClonado === 'INTERNO'
? ' (Sistema)'
: ' (Archivo)'}
</>
)}
<div className="bg-muted rounded-md p-3">
<span className="text-muted-foreground">Tipo de origen: </span>
<span className="inline-flex items-center gap-2 font-medium">
{wizard.tipoOrigen === 'MANUAL' && (
<Icons.Pencil className="h-4 w-4" />
)}
{(wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
<Icons.Sparkles className="h-4 w-4" />
)}
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<Icons.Copy className="h-4 w-4" />
)}
{modoLabel}
</span>
</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="col-span-2">
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
{wizard.datosBasicos.nombre || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Código: </span>
<span className="font-medium">
{wizard.datosBasicos.codigo || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Tipo: </span>
<span className="font-medium">
{wizard.datosBasicos.tipo || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Créditos: </span>
<span className="font-medium">{creditosText}</span>
</div>
<div>
<span className="text-muted-foreground">Estructura: </span>
<span className="font-medium">{estructuraNombre}</span>
</div>
<div>
<span className="text-muted-foreground">
Horas académicas:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.horasAcademicas ?? '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Horas independientes:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.horasIndependientes ?? '—'}
</span>
</div>
</div>
<div className="bg-muted/50 rounded-md p-3">
<div className="font-medium">Configuración IA</div>
<div className="mt-2 grid gap-2">
<div>
<span className="text-muted-foreground">
Enfoque académico:{' '}
</span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Instrucciones adicionales:{' '}
</span>
<span className="font-medium">
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
</span>
</div>
<div className="mt-2">
<div className="font-medium">Archivos de referencia</div>
{archivosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{archivosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">
Repositorios de referencia
</div>
{repositoriosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">Archivos adjuntos</div>
{adjuntos.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => (
<li key={f.id}>
<span className="text-foreground">
{f.file.name}
</span>{' '}
<span>· {formatFileSize(f.file.size)}</span>
</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>

View File

@@ -1,66 +1,477 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AISubjectUnifiedInput } from '@/data'
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 {
supabaseBrowser,
useGenerateSubjectAI,
qk,
useCreateSubjectManual,
subjects_get_maybe,
} from '@/data'
export function WizardControls({
Wizard,
methods,
wizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
onCreate,
setWizard,
errorMessage,
onPrev,
onNext,
disablePrev,
disableNext,
disableCreate,
isLastStep,
}: {
Wizard: any
methods: any
wizard: NewSubjectWizardState
canContinueDesdeMetodo: boolean
canContinueDesdeBasicos: boolean
canContinueDesdeConfig: boolean
onCreate: () => void
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
errorMessage?: string | null
onPrev: () => void
onNext: () => void
disablePrev: boolean
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
}) {
const idx = Wizard.utils.getIndex(methods.current.id)
const isLast = idx >= Wizard.steps.length - 1
const navigate = useNavigate()
const qc = useQueryClient()
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 () => {
setWizard((w) => ({
...w,
isLoading: true,
errorMessage: null,
}))
let startedWaiting = false
try {
if (wizard.tipoOrigen === 'IA_SIMPLE') {
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,
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
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: {
descripcionEnfoqueAcademico:
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosAdjuntos,
},
}
console.log(
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
)
await generateSubjectAI.mutateAsync(payload as any)
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
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({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
resetScroll: false,
})
}
} catch (err: any) {
setIsSpinningIA(false)
stopSubjectWatch()
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura',
}))
} finally {
if (!startedWaiting) {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
}
}
}
return (
<div className="flex-none border-t bg-white p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
{wizard.errorMessage && (
<span className="text-destructive text-sm font-medium">
{wizard.errorMessage}
</span>
)}
</div>
<div className="flex grow items-center justify-between">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => methods.prev()}
disabled={idx === 0 || wizard.isLoading}
>
Anterior
</Button>
{!isLast ? (
<Button
onClick={() => methods.next()}
disabled={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeConfig)
}
>
Siguiente
</Button>
) : (
<Button onClick={onCreate} disabled={wizard.isLoading}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
</Button>
)}
</div>
<div className="mx-2 flex-1">
{(errorMessage ?? wizard.errorMessage) && (
<span className="text-destructive text-sm font-medium">
{errorMessage ?? wizard.errorMessage}
</span>
)}
</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 ? (
<Button onClick={handleCreate} disabled={disableCreate}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
</Button>
) : (
<Button onClick={onNext} disabled={disableNext}>
Siguiente
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
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,
dbMessageId,
currentDatos,
activeChatId,
onApplySuccess,
}: {
suggestions: Array<any>
onApply?: (key: string, value: string) => void
planId: string
currentDatos: any
dbMessageId: string
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)
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) {
updateAppliedStatus.mutate({
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
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

@@ -34,7 +34,7 @@ export function PasoBasicosForm({
const estructurasPlanList = catalogos?.estructurasPlan ?? []
const filteredCarreras = rawCarreras.filter((c: any) => {
const facId = wizard.datosBasicos.facultadId
const facId = wizard.datosBasicos.facultad.id
if (!facId) return true
// soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local)
return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId
@@ -50,6 +50,7 @@ export function PasoBasicosForm({
id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas (2026)"
value={wizard.datosBasicos.nombrePlan}
maxLength={200}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewPlanWizardState => ({
@@ -68,15 +69,20 @@ export function PasoBasicosForm({
<div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label>
<Select
value={wizard.datosBasicos.facultadId}
value={wizard.datosBasicos.facultad.id}
onValueChange={(value) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: value,
carreraId: '',
facultad: {
id: value,
nombre:
facultadesList.find((f) => f.id === value)?.nombre ||
'',
},
carrera: { id: '', nombre: '' },
},
}),
)
@@ -86,7 +92,7 @@ export function PasoBasicosForm({
id="facultad"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.facultadId
!wizard.datosBasicos.facultad.id
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
@@ -106,22 +112,30 @@ export function PasoBasicosForm({
<div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label>
<Select
value={wizard.datosBasicos.carreraId}
value={wizard.datosBasicos.carrera.id}
onValueChange={(value) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, carreraId: value },
datosBasicos: {
...w.datosBasicos,
carrera: {
id: value,
nombre:
filteredCarreras.find((c) => c.id === value)?.nombre ||
'',
},
},
}),
)
}
disabled={!wizard.datosBasicos.facultadId}
disabled={!wizard.datosBasicos.facultad.id}
>
<SelectTrigger
id="carrera"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.carreraId
!wizard.datosBasicos.carrera.id
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
@@ -215,7 +229,16 @@ export function PasoBasicosForm({
id="numCiclos"
type="number"
min={1}
max={99}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.numCiclos ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewPlanWizardState => ({
@@ -223,10 +246,16 @@ export function PasoBasicosForm({
datosBasicos: {
...w.datosBasicos,
// Keep undefined when the input is empty so the field stays optional
numCiclos:
e.target.value === ''
? undefined
: Number(e.target.value),
numCiclos: (() => {
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, 99)
return capped
})(),
},
}),
)

View File

@@ -75,7 +75,7 @@ export function TemplateSelectorCard({
const handleTemplateChange = (value: string) => {
const template = templatesData.find((t) => t.id === value)
const firstVersion = template?.versions?.[0] ?? ''
const firstVersion = template?.versions[0] ?? ''
if (onChange) {
onChange({ templateId: value, version: firstVersion })
} else {

View File

@@ -18,6 +18,7 @@ interface FileDropzoneProps {
maxFiles?: number
title?: string
description?: string
autoScrollToDropzone?: boolean
}
export function FileDropzone({
@@ -27,39 +28,71 @@ export function FileDropzone({
maxFiles = 5,
title = 'Arrastra archivos aquí',
description = 'o haz clic para seleccionar',
autoScrollToDropzone = false,
}: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const bottomRef = useRef<HTMLDivElement>(null)
const prevFilesLengthRef = useRef(files.length)
const addFiles = useCallback(
(newFiles: Array<File>) => {
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file,
}))
setFiles((prev) => {
const room = Math.max(0, maxFiles - prev.length)
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
return next
(incomingFiles: Array<File>) => {
console.log(
'incoming files:',
incomingFiles.map((file) => file.name),
)
setFiles((previousFiles) => {
console.log(
'previous files',
previousFiles.map((f) => f.file.name),
)
// Evitar duplicados por nombre (comprobación global en los archivos existentes)
const existingFileNames = new Set(
previousFiles.map((uploaded) => uploaded.file.name),
)
const uniqueNewFiles = incomingFiles.filter(
(incomingFile) => !existingFileNames.has(incomingFile.name),
)
// Convertir archivos a objetos con ID único para manejo en React
const filesToUpload: Array<UploadedFile> = uniqueNewFiles.map(
(incomingFile) => ({
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file: incomingFile,
}),
)
// Calcular espacio disponible respetando el límite máximo
const room = Math.max(0, maxFiles - previousFiles.length)
const nextFiles = [
...previousFiles,
...filesToUpload.slice(0, room),
].slice(0, maxFiles)
return nextFiles
})
},
[maxFiles],
)
// Manejador para cuando se arrastran archivos sobre la zona
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
// Manejador para cuando se sale de la zona de arrastre
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
// Manejador para cuando se sueltan los archivos
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
@@ -70,33 +103,68 @@ export function FileDropzone({
[addFiles],
)
// Manejador para la selección de archivos mediante el input nativo
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
addFiles(selectedFiles)
// Corrección de bug: Limpiar el valor para permitir seleccionar el mismo archivo nuevamente si fue eliminado
e.target.value = ''
}
},
[addFiles],
)
// Función para eliminar un archivo específico por su ID
const removeFile = useCallback((fileId: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.id !== fileId)
return next
setFiles((previousFiles) => {
console.log(
'previous files',
previousFiles.map((f) => f.file.name),
)
const remainingFiles = previousFiles.filter(
(uploadedFile) => uploadedFile.id !== fileId,
)
return remainingFiles
})
}, [])
// Keep latest callback in a ref to avoid retriggering effect on identity change
// Mantener la referencia actualizada de la función callback externa para evitar loops en useEffect
useEffect(() => {
onFilesChangeRef.current = onFilesChange
}, [onFilesChange])
// Only emit when files actually change to avoid parent update loops
// Notificar al componente padre cuando cambia la lista de archivos
useEffect(() => {
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
}, [files])
// Scroll automático hacia abajo solo cuando se pasa de 0 a 1 o más archivos
useEffect(() => {
if (
autoScrollToDropzone &&
prevFilesLengthRef.current === 0 &&
files.length > 0
) {
// Usar un pequeño timeout para asegurar que el renderizado se complete
const timer = setTimeout(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}, 100)
// Actualizar la referencia
prevFilesLengthRef.current = files.length
return () => clearTimeout(timer)
}
// Mantener sincronizada la referencia en otros casos
prevFilesLengthRef.current = files.length
}, [files.length, autoScrollToDropzone])
// Determinar el icono a mostrar según la extensión del archivo
const getFileIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'pdf':
@@ -111,13 +179,19 @@ export function FileDropzone({
return (
<div className="space-y-3">
{/* Elemento invisible para referencia de scroll */}
<div ref={bottomRef} />
{/* Área principal de dropzone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
isDragging && 'active',
'cursor-pointer rounded-xl border-2 border-dashed p-7 text-center transition-all duration-300',
// Siempre usar borde por defecto a menos que se esté arrastrando
'border-border hover:border-primary/50',
isDragging && 'ring-primary ring-2 ring-offset-2',
)}
>
<input
@@ -127,6 +201,7 @@ export function FileDropzone({
onChange={handleFileInput}
className="hidden"
id="file-upload"
disabled={files.length >= maxFiles}
/>
<label
htmlFor="file-upload"
@@ -146,9 +221,9 @@ export function FileDropzone({
</div>
<div className="text-center">
<p className="text-foreground text-sm font-medium">{title}</p>
<p className="text-muted-foreground mt-1 text-xs">
{/* <p className="text-muted-foreground mt-1 text-xs">
{description}
</p>
</p> */}
<p className="text-muted-foreground mt-1 text-xs">
Formatos:{' '}
{acceptedTypes
@@ -156,46 +231,63 @@ export function FileDropzone({
.toUpperCase()
.replace(/,/g, ', ')}
</p>
<div className="mt-2 flex items-center justify-center gap-1.5">
<span
className={cn(
'text-primary text-xl font-bold',
files.length >= maxFiles ? 'text-destructive' : '',
)}
>
{files.length}
</span>
<span
className={cn(
'text-sm font-medium transition-colors',
files.length >= maxFiles
? 'text-destructive'
: 'text-muted-foreground/80',
)}
>
/ {maxFiles} archivos (máximo)
</span>
</div>
</div>
</div>
</label>
</div>
{/* Uploaded files list */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((item) => (
<div
key={item.id}
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
>
{getFileIcon(item.file.type)}
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{item.file.name}
</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(item.file.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeFile(item.id)}
{/* Lista de archivos subidos (Orden inverso: más recientes primero) */}
<div className="h-56 overflow-y-auto">
{files.length > 0 && (
<div className="space-y-2">
{[...files].reverse().map((uploadedFile) => (
<div
key={uploadedFile.id}
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{files.length >= maxFiles && (
<p className="text-warning text-center text-xs">
Máximo de {maxFiles} archivos alcanzado
</p>
)}
{getFileIcon(uploadedFile.file.type)}
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{uploadedFile.file.name}
</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(uploadedFile.file.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeFile(uploadedFile.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -4,10 +4,8 @@ import ReferenciasParaIA from './ReferenciasParaIA'
import type { UploadedFile } from './FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
@@ -46,18 +44,19 @@ export function PasoDetallesPanel({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="desc">Descripción del enfoque</Label>
<Label htmlFor="desc">Descripción del enfoque académico</Label>
<textarea
id="desc"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Describe el enfoque del programa…"
value={wizard.iaConfig?.descripcionEnfoque || ''}
placeholder="Define el perfil de egreso, visión pedagógica y sector profesional. Ej.: Programa semestral orientado a la Industria 4.0, con enfoque en competencias directivas y emprendimiento tecnológico..."
maxLength={7000}
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
descripcionEnfoque: e.target.value,
descripcionEnfoqueAcademico: e.target.value,
},
}))
}
@@ -66,7 +65,7 @@ export function PasoDetallesPanel({
<div className="flex flex-col gap-1">
<Label htmlFor="notas">
Notas adicionales
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
@@ -74,14 +73,15 @@ export function PasoDetallesPanel({
<textarea
id="notas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Lineamientos institucionales, restricciones, etc."
value={wizard.iaConfig?.notasAdicionales || ''}
placeholder="Opcional: Estándares, estructura y limitaciones. Ej.: Estructura de 9 ciclos, carga pesada en ciencias básicas, sigue normativa CACEI, incluye 15% de materias optativas..."
maxLength={7000}
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
notasAdicionales: e.target.value,
instruccionesAdicionalesIA: e.target.value,
},
}))
}
@@ -92,7 +92,7 @@ export function PasoDetallesPanel({
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w) => {
onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
@@ -107,7 +107,7 @@ export function PasoDetallesPanel({
})
}
onToggleRepositorio={(id, checked) =>
onChange((w) => {
onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
@@ -133,38 +133,6 @@ export function PasoDetallesPanel({
)
}
/>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante.
</div>
<Button disabled={isLoading}>
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
</Button>
</div>
{wizard.resumen.previewPlan && (
<Card>
<CardHeader>
<CardTitle>Preview IA</CardTitle>
<CardDescription>
Asignaturas aprox.:{' '}
{wizard.resumen.previewPlan.numAsignaturasAprox}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-muted-foreground list-disc pl-5 text-sm">
{wizard.resumen.previewPlan.secciones?.map((s) => (
<li key={s.id}>
<span className="text-foreground font-medium">
{s.titulo}:
</span>{' '}
{s.resumen}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
TabsContents,
} from '@/components/ui/motion-tabs'
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils'
const ReferenciasParaIA = ({
selectedArchivoIds = [],
@@ -76,7 +77,7 @@ const ReferenciasParaIA = ({
placeholder="Buscar archivo existente..."
className="m-1 mb-1.5"
/>
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
{archivosFiltrados.map((archivo) => (
<Label
key={archivo.id}
@@ -87,7 +88,10 @@ const ReferenciasParaIA = ({
onCheckedChange={(checked) =>
onToggleArchivo?.(archivo.id, !!checked)
}
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className={cn(
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
selectedArchivoIds.includes(archivo.id) ? '' : 'invisible',
)}
/>
<FileText className="text-muted-foreground h-4 w-4" />
@@ -123,7 +127,7 @@ const ReferenciasParaIA = ({
placeholder="Buscar repositorio..."
className="m-1 mb-1.5"
/>
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
{repositoriosFiltrados.map((repositorio) => (
<Label
key={repositorio.id}
@@ -134,7 +138,12 @@ const ReferenciasParaIA = ({
onCheckedChange={(checked) =>
onToggleRepositorio?.(repositorio.id, !!checked)
}
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className={cn(
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
selectedRepositorioIds.includes(repositorio.id)
? ''
: 'invisible',
)}
/>
<FolderOpen className="text-muted-foreground h-4 w-4" />
@@ -163,12 +172,13 @@ const ReferenciasParaIA = ({
icon: Upload,
content: (
<div>
<div className="p-1">
<FileDropzone
persistentFiles={uploadedFiles}
onFilesChange={onFilesChange}
title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación"
autoScrollToDropzone={true}
/>
</div>
),
@@ -177,7 +187,12 @@ const ReferenciasParaIA = ({
return (
<div className="flex w-full flex-col gap-1">
<Label>Referencias para la IA</Label>
<Label>
Referencias para la IA{' '}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Tabs defaultValue="archivos-existentes" className="gap-4">
<TabsList className="w-full">

View File

@@ -45,8 +45,8 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
Facultad/Carrera:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.facultadId || '—'} /{' '}
{wizard.datosBasicos.carreraId || '—'}
{wizard.datosBasicos.facultad.nombre || '—'} /{' '}
{wizard.datosBasicos.carrera.nombre || '—'}
</span>
</div>
<div>
@@ -116,13 +116,13 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
<div>
<span className="text-muted-foreground">Enfoque: </span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoque || '—'}
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Notas: </span>
<span className="font-medium">
{wizard.iaConfig?.notasAdicionales || '—'}
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
</span>
</div>
{archivosRef.length > 0 && (

View File

@@ -1,9 +1,21 @@
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 { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
// import type { Database } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button'
import { useGeneratePlanAI } from '@/data/hooks/usePlans'
import { plans_get_maybe } from '@/data/api/plans.api'
import {
useCreatePlanManual,
useDeletePlanEstudio,
useGeneratePlanAI,
} from '@/data/hooks/usePlans'
import { supabaseBrowser } from '@/data/supabase/client'
export function WizardControls({
errorMessage,
@@ -28,7 +40,153 @@ export function WizardControls({
}) {
const navigate = useNavigate()
const generatePlanAI = useGeneratePlanAI()
// const persistPlanFromAI = usePersistPlanFromAI()
const createPlanManual = useCreatePlanManual()
const deletePlan = useDeletePlanEstudio()
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 () => {
// Start loading
@@ -49,19 +207,21 @@ export function WizardControls({
? wizard.datosBasicos.numCiclos
: 1
const aiInput = {
const aiInput: AIGeneratePlanInput = {
datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carreraId,
facultadId: wizard.datosBasicos.facultadId || undefined,
carreraId: wizard.datosBasicos.carrera.id,
facultadId: wizard.datosBasicos.facultad.id,
nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
},
iaConfig: {
descripcionEnfoque: wizard.iaConfig?.descripcionEnfoque || '',
notasAdicionales: wizard.iaConfig?.notasAdicionales || '',
descripcionEnfoqueAcademico:
wizard.iaConfig?.descripcionEnfoqueAcademico || '',
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA || '',
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
@@ -70,59 +230,84 @@ export function WizardControls({
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
const data = await generatePlanAI.mutateAsync(aiInput as any)
console.log(`${new Date().toISOString()} - Plan IA generado`, data)
setIsSpinningIA(true)
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({ to: `/planes/${data.plan.id}` })
if (!planId) {
throw new Error('No se pudo obtener el id del plan generado por IA')
}
// Inicia realtime; los efectos navegan o marcan error.
beginPlanWatch(String(planId))
return
}
// Fallback mocks for non-IA origins
await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
)
return 'plan_new_clone_001'
return 'plan_new_import_001'
})()
navigate({ to: `/planes/${nuevoId}` })
if (wizard.tipoOrigen === 'MANUAL') {
// Crear plan vacío manualmente usando el hook
const plan = await createPlanManual.mutateAsync({
carreraId: wizard.datosBasicos.carrera.id,
estructuraId: wizard.datosBasicos.estructuraPlanId as string,
nombre: wizard.datosBasicos.nombrePlan,
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
tipoCiclo: wizard.datosBasicos.tipoCiclo as TipoCiclo,
numCiclos: (wizard.datosBasicos.numCiclos as number) || 1,
datos: {},
})
// Navegar al nuevo plan
navigate({
to: `/planes/${plan.id}`,
state: { showConfetti: true },
})
return
}
} catch (err: any) {
setIsSpinningIA(false)
stopPlanWatch()
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan',
}))
} finally {
setWizard((w) => ({ ...w, isLoading: false }))
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
}
}
return (
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex grow items-center justify-between">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
<div className="mx-2 flex-1">
{errorMessage && (
<span className="text-destructive text-sm font-medium">
{errorMessage}
</span>
)}
</div>
<div className="flex gap-4">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
{isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}>
Crear plan
</Button>
) : (
<Button onClick={onNext} disabled={disableNext}>
Siguiente
</Button>
)}
<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 ? (
<Button onClick={handleCreate} disabled={disableCreate}>
Crear plan
</Button>
) : (
<Button onClick={onNext} disabled={disableNext}>
Siguiente
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { Link, useRouter } from '@tanstack/react-router'
import { FileQuestion, Home, ArrowLeft } from 'lucide-react'
import { Button } from './button'
interface NotFoundPageProps {
title?: string
message?: string
children?: React.ReactNode
}
export function NotFoundPage({
title = 'Página no encontrada',
message = 'Lo sentimos, no pudimos encontrar lo que buscabas. Es posible que la página haya sido movida o eliminada.',
children,
}: NotFoundPageProps) {
const router = useRouter()
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center p-4 text-center">
<div className="bg-muted mb-6 rounded-full p-6">
<FileQuestion className="text-muted-foreground h-12 w-12" />
</div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mb-8 max-w-125">{message}</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => router.history.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Regresar
</Button>
<Button asChild>
<Link to="/">
<Home className="mr-2 h-4 w-4" />
Ir al inicio
</Link>
</Button>
{children}
</div>
</div>
)
}

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

@@ -0,0 +1,48 @@
// src/components/ui/lateral-confetti.tsx
import confetti from 'canvas-confetti'
export function lateralConfetti() {
// 1. Reset para limpiar cualquier configuración vieja pegada en memoria
confetti.reset()
const duration = 1500
const end = Date.now() + duration
// 2. Colores vibrantes (cálidos primero)
const vibrantColors = [
'#FF0000', // Rojo puro
'#fcff42', // Amarillo
'#88ff5a', // Verde
'#26ccff', // Azul
'#a25afd', // Morado
]
;(function frame() {
const commonSettings = {
particleCount: 5,
spread: 55,
// origin: { x: 0.5 }, // No necesario si definimos origin abajo, pero útil en otros contextos
colors: vibrantColors,
zIndex: 99999,
}
// Cañón izquierdo
confetti({
...commonSettings,
angle: 60,
origin: { x: 0, y: 0.6 },
})
// Cañón derecho
confetti({
...commonSettings,
angle: 120,
origin: { x: 1, y: 0.6 },
})
if (Date.now() < end) {
requestAnimationFrame(frame)
}
})()
}

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -1,18 +1,24 @@
import * as React from "react"
import * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
)
}
})
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export function StepWithTooltip({
title,
desc,
}: {
title: string
desc: string
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<TooltipProvider delayDuration={0}>
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger asChild>
<span
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
onClick={(e) => {
e.stopPropagation()
setIsOpen((prev) => !prev)
}}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-50 text-xs">
<p>{desc}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,52 @@
import * as Icons from 'lucide-react'
import { CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent } from '@/components/ui/dialog'
export function WizardLayout({
title,
onClose,
headerSlot,
footerSlot,
children,
}: {
title: string
onClose: () => void
headerSlot?: React.ReactNode
footerSlot?: React.ReactNode
children: React.ReactNode
}) {
return (
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => {
e.preventDefault()
}}
>
<div className="z-10 flex-none border-b bg-white">
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
<CardTitle>{title}</CardTitle>
<button
onClick={onClose}
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
>
<Icons.X className="h-4 w-4" />
<span className="sr-only">Cerrar</span>
</button>
</CardHeader>
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
</div>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
{children}
</div>
{footerSlot ? (
<div className="flex-none border-t bg-white p-6">{footerSlot}</div>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,75 @@
import { CircularProgress } from '@/components/CircularProgress'
import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
export function WizardResponsiveHeader({
wizard,
methods,
titleOverrides,
hiddenStepIds,
}: {
wizard: any
methods: any
titleOverrides?: Record<string, string>
hiddenStepIds?: Array<string>
}) {
const hidden = new Set(hiddenStepIds ?? [])
const visibleSteps = (wizard.steps as Array<any>).filter(
(s) => s && !hidden.has(s.id),
)
const idx = visibleSteps.findIndex((s) => s.id === methods.current.id)
const safeIdx = idx >= 0 ? idx : 0
const totalSteps = visibleSteps.length
const currentIndex = Math.min(safeIdx + 1, totalSteps)
const hasNextStep = safeIdx < totalSteps - 1
const nextStep = visibleSteps[safeIdx + 1]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
return (
<>
<div className="block sm:hidden">
<div className="flex items-center gap-5">
<CircularProgress current={currentIndex} total={totalSteps} />
<div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900">
<StepWithTooltip
title={resolveTitle(methods.current)}
desc={methods.current.description}
/>
</h2>
{hasNextStep && nextStep ? (
<p className="text-sm text-slate-400">
Siguiente: {resolveTitle(nextStep)}
</p>
) : (
<p className="text-sm font-medium text-green-500">
¡Último paso!
</p>
)}
</div>
</div>
</div>
<div className="hidden sm:block">
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{visibleSteps.map((step: any, visibleIdx: number) => (
<wizard.Stepper.Step
key={step.id}
of={step.id}
icon={visibleIdx + 1}
className="whitespace-nowrap"
>
<wizard.Stepper.Title>
<StepWithTooltip
title={resolveTitle(step)}
desc={step.description}
/>
</wizard.Stepper.Title>
</wizard.Stepper.Step>
))}
</wizard.Stepper.Navigation>
</div>
</>
)
}

View File

@@ -1,45 +1,56 @@
import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
import type { Database } from '../types/database'
import type {
PostgrestError,
AuthError,
SupabaseClient,
} from '@supabase/supabase-js'
export class ApiError extends Error {
constructor(
message: string,
public readonly code?: string,
public readonly details?: unknown,
public readonly hint?: string
public readonly hint?: string,
) {
super(message);
this.name = "ApiError";
super(message)
this.name = 'ApiError'
}
}
export function throwIfError(error: PostgrestError | AuthError | null): void {
if (!error) return;
const anyErr = error as any;
if (!error) return
const anyErr = error as any
throw new ApiError(
anyErr.message ?? "Error inesperado",
anyErr.message ?? 'Error inesperado',
anyErr.code,
anyErr.details,
anyErr.hint
);
anyErr.hint,
)
}
export function requireData<T>(data: T | null | undefined, message = "Respuesta vacía"): T {
if (data === null || data === undefined) throw new ApiError(message);
return data;
export function requireData<T>(
data: T | null | undefined,
message = 'Respuesta vacía',
): T {
if (data === null || data === undefined) throw new ApiError(message)
return data
}
export async function getUserIdOrThrow(supabase: SupabaseClient<Database>): Promise<string> {
const { data, error } = await supabase.auth.getUser();
throwIfError(error);
if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth).");
return data.user.id;
export async function getUserIdOrThrow(
supabase: SupabaseClient<Database>,
): Promise<string> {
const { data, error } = await supabase.auth.getUser()
throwIfError(error)
if (!data?.user?.id) throw new ApiError('No hay sesión activa (auth).')
return data.user.id
}
export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } {
if (!limit) return {};
const from = Math.max(0, offset ?? 0);
const to = from + Math.max(1, limit) - 1;
return { from, to };
export function buildRange(
limit?: number,
offset?: number,
): { from?: number; to?: number } {
if (!limit) return {}
const from = Math.max(0, offset ?? 0)
const to = from + Math.max(1, limit) - 1
return { from, to }
}

View File

@@ -1,81 +1,377 @@
import { invokeEdge } from "../supabase/invokeEdge";
import type { InteraccionIA, UUID } from "../types/domain";
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
import type { InteraccionIA, UUID } from '../types/domain'
const EDGE = {
ai_plan_improve: "ai_plan_improve",
ai_plan_chat: "ai_plan_chat",
ai_subject_improve: "ai_subject_improve",
ai_subject_chat: "ai_subject_chat",
ai_plan_improve: 'ai_plan_improve',
ai_plan_chat: 'ai_plan_chat',
ai_subject_improve: 'ai_subject_improve',
ai_subject_chat: 'ai_subject_chat',
library_search: "library_search",
} as const;
library_search: 'library_search',
} as const
export async function ai_plan_improve(payload: {
planId: UUID;
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna
prompt: string;
context?: Record<string, any>;
planId: UUID
sectionKey: string // ej: "perfil_de_egreso" o tu key interna
prompt: string
context?: Record<string, any>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): 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: {
planId: UUID;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
planId: UUID
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): 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: {
subjectId: UUID;
sectionKey: string;
prompt: string;
context?: Record<string, any>;
subjectId: UUID
sectionKey: string
prompt: string
context?: Record<string, any>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): 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: {
subjectId: UUID;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
subjectId: UUID
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): 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) */
export type LibraryItem = {
id: string;
titulo: string;
autor?: string;
isbn?: string;
citaSugerida?: string;
disponibilidad?: string;
};
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> {
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
id: string
titulo: string
autor?: string
isbn?: string
citaSugerida?: string
disponibilidad?: string
}
export async function 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/plan/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/plan/${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 getMessagesByConversation(conversationId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('plan_mensajes_ia')
.select('*')
.eq('conversacion_plan_id', conversationId)
.order('fecha_creacion', { ascending: true }) // Ascendente para que el chat fluya en orden cronológico
if (error) {
console.error('Error al obtener mensajes:', error.message)
throw error
}
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(
mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente
campoAfectado: string,
) {
const supabase = supabaseBrowser()
// 1. Obtener la propuesta actual de ese mensaje específico
const { data: msgData, error: fetchError } = await supabase
.from('plan_mensajes_ia')
.select('propuesta')
.eq('id', mensajeId)
.single()
if (fetchError) throw fetchError
if (!msgData?.propuesta)
throw new Error('No se encontró la propuesta en el mensaje')
const propuestaActual = msgData.propuesta as any
// 2. Modificar el array de recommendations dentro de la propuesta
// Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto
const nuevaPropuesta = {
...propuestaActual,
recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
),
}
// 3. Actualizar la base de datos con el nuevo objeto JSON
const { error: updateError } = await supabase
.from('plan_mensajes_ia')
.update({ propuesta: nuevaPropuesta })
.eq('id', mensajeId)
if (updateError) throw updateError
return true
}
// --- FUNCIONES DE ASIGNATURA ---
export async function create_subject_conversation(subjectId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/asignatura/conversations', // Ruta corregida
{
method: 'POST',
body: {
asignatura_id: subjectId,
instanciador: 'alex',
},
},
)
if (error) throw error
return data // Retorna { conversation_asignatura: { id, ... } }
}
export async function ai_subject_chat_v2(payload: {
conversacionId: string
content: string
campos?: Array<string>
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/asignatura/${payload.conversacionId}/messages`, // Ruta corregida
{
method: 'POST',
body: {
content: payload.content,
campos: payload.campos || [],
},
},
)
if (error) throw error
return data
}
export async function getConversationBySubject(subjectId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura') // Tabla corregida
.select('*')
.eq('asignatura_id', subjectId)
.order('creado_en', { ascending: false })
if (error) throw error
return data ?? []
}
export async function getMessagesBySubjectConversation(conversationId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignatura_mensajes_ia' as any)
.select('*')
.eq('conversacion_asignatura_id', conversationId)
.order('fecha_creacion', { ascending: true })
if (error) throw error
return data ?? []
}
export async function update_subject_recommendation_applied(
mensajeId: string,
campoAfectado: string,
) {
const supabase = supabaseBrowser()
// 1. Obtener propuesta actual
const { data: msgData, error: fetchError } = await supabase
.from('asignatura_mensajes_ia')
.select('propuesta')
.eq('id', mensajeId)
.single()
if (fetchError) throw fetchError
const propuestaActual = msgData?.propuesta as any
// 2. Marcar como aplicada
const nuevaPropuesta = {
...propuestaActual,
recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
),
}
// 3. Update
const { error: updateError } = await supabase
.from('asignatura_mensajes_ia')
.update({ propuesta: nuevaPropuesta })
.eq('id', mensajeId)
if (updateError) throw updateError
return true
}
export async function update_subject_conversation_status(
conversacionId: string,
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ estado: nuevoEstado })
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}
export async function update_subject_conversation_name(
conversacionId: string,
nuevoNombre: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}

View File

@@ -3,9 +3,15 @@
const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
const DOCUMENT_PDF_ASIGNATURA_URL =
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
interface GeneratePdfParams {
plan_estudio_id: string
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
}
export async function fetchPlanPdf({
plan_estudio_id,
@@ -25,3 +31,22 @@ export async function fetchPlanPdf({
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}
export async function fetchAsignaturaPdf({
asignatura_id,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asignatura_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}

View File

@@ -1,32 +1,32 @@
import { invokeEdge } from "../supabase/invokeEdge";
import type { UUID } from "../types/domain";
import { invokeEdge } from '../supabase/invokeEdge'
import type { UUID } from '../types/domain'
/**
* Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase)
* Se apoya en tu tabla `archivos`.
*/
export type AppFile = {
id: UUID; // id interno (tabla archivos)
openai_file_id: string; // id OpenAI
nombre: string;
mime_type: string | null;
bytes: number | null;
id: UUID // id interno (tabla archivos)
openai_file_id: string // id OpenAI
nombre: string
mime_type: string | null
bytes: number | null
// espejo Supabase para preview/descarga
ruta_storage: string | null; // "bucket/path"
signed_url?: string | null;
ruta_storage: string | null // "bucket/path"
signed_url?: string | null
// auditoría/evidencia
temporal: boolean;
notas?: string | null;
temporal: boolean
notas?: string | null
subido_en: string;
};
subido_en: string
}
const EDGE = {
upload: "openai_files_upload",
remove: "openai_files_delete",
} as const;
upload: 'openai_files_upload',
remove: 'openai_files_delete',
} as const
/**
* Sube archivo a OpenAI y (opcional) crea espejo en Storage
@@ -37,28 +37,28 @@ export async function openai_files_upload(payload: {
* Si tu Edge soporta multipart: manda File/Blob directo.
* Si no, manda base64/bytes (según tu implementación).
*/
file: File;
file: File
/** “temporal” = evidencia usada para generar plan/materia */
temporal?: boolean;
/** “temporal” = evidencia usada para generar plan/asignatura */
temporal?: boolean
/** contexto para auditoría */
contexto?: {
planId?: UUID;
asignaturaId?: UUID;
motivo?: "WIZARD_PLAN" | "WIZARD_MATERIA" | "ADHOC";
};
planId?: UUID
asignaturaId?: UUID
motivo?: 'WIZARD_PLAN' | 'WIZARD_MATERIA' | 'ADHOC'
}
/** si quieres forzar espejo para preview siempre */
mirrorToSupabase?: boolean;
mirrorToSupabase?: boolean
}): Promise<AppFile> {
return invokeEdge<AppFile>(EDGE.upload, payload);
return invokeEdge<AppFile>(EDGE.upload, payload)
}
export async function openai_files_delete(payload: {
openaiFileId: string;
openaiFileId: string
/** si quieres borrar también espejo y registro */
hardDelete?: boolean;
hardDelete?: boolean
}): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
return invokeEdge<{ ok: true }>(EDGE.remove, payload)
}

View File

@@ -3,6 +3,7 @@ import { invokeEdge } from '../supabase/invokeEdge'
import { buildRange, requireData, throwIfError } from './_helpers'
import type { Database } from '../../types/supabase'
import type {
Asignatura,
CambioPlan,
@@ -78,7 +79,7 @@ export async function plans_list(
`,
{ count: 'exact' },
)
.order('actualizado_en', { ascending: false })
.order('creado_en', { ascending: false })
// 2. Aplicamos filtros dinámicos
@@ -122,6 +123,8 @@ export async function plans_list(
}
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
console.log('plans_get')
const supabase = supabaseBrowser()
const { data, error } = await supabase
@@ -141,6 +144,48 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
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(
planId: UUID,
): Promise<Array<LineaPlan>> {
@@ -162,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase
.from('asignaturas')
.select(
'id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,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,prerrequisito_asignatura_id',
)
.eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -173,18 +218,31 @@ export async function plan_asignaturas_list(
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 { data, error } = await supabase
const from = page * pageSize
const to = from + pageSize - 1
const { data, error, count } = await supabase
.from('cambios_plan')
.select(
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id',
'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)
.order('cambiado_en', { ascending: false })
.range(from, to)
throwIfError(error)
return data ?? []
return {
data: data ?? [],
count: count ?? 0,
}
}
/** Wizard: crear plan manual (Edge Function) */
@@ -201,7 +259,56 @@ export type PlansCreateManualInput = {
export async function plans_create_manual(
input: PlansCreateManualInput,
): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input)
const supabase = supabaseBrowser()
// 1. Obtener estado 'BORRADOR'
const { data: estado, error: estadoError } = await supabase
.from('estados_plan')
.select('id,clave,orden')
.ilike('clave', 'BORRADOR%')
.order('orden', { ascending: true })
.limit(1)
.maybeSingle()
if (estadoError) {
throw new Error(estadoError.message)
}
// 2. Preparar insert
const planInsert: Database['public']['Tables']['planes_estudio']['Insert'] = {
activo: true,
actualizado_en: new Date().toISOString(),
carrera_id: input.carreraId,
creado_en: new Date().toISOString(),
datos: input.datos || {},
estado_actual_id: estado?.id || null,
estructura_id: input.estructuraId,
nivel: input.nivel,
nombre: input.nombre,
numero_ciclos: input.numCiclos,
tipo_ciclo: input.tipoCiclo,
tipo_origen: 'MANUAL',
}
// 3. Insertar
const { data: nuevoPlan, error: planError } = await supabase
.from('planes_estudio')
.insert([planInsert])
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.single()
if (planError) {
throw new Error(planError.message)
}
return nuevoPlan as unknown as PlanEstudio
}
/** Wizard: IA genera preview JSON (Edge Function) */
@@ -216,8 +323,8 @@ export type AIGeneratePlanInput = {
estructuraPlanId: UUID
}
iaConfig: {
descripcionEnfoque: string
notasAdicionales?: string
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA?: string
archivosReferencia?: Array<UUID>
repositoriosIds?: Array<UUID>
archivosAdjuntos: Array<UploadedFile>
@@ -239,7 +346,7 @@ export async function ai_generate_plan(
archivosAdjuntos: undefined, // los manejamos aparte
}),
)
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
input.iaConfig.archivosAdjuntos.forEach((file) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
@@ -281,7 +388,7 @@ export async function plans_import_from_files(payload: {
}
archivoWordPlanId: UUID
archivoMapaExcelId?: UUID | null
archivoMateriasExcelId?: UUID | null
archivoAsignaturasExcelId?: UUID | null
}): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
}
@@ -300,7 +407,7 @@ export async function plans_update_fields(
patch: PlansUpdateFieldsPatch,
): Promise<PlanEstudio> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.update(patch)

View File

@@ -1,181 +1,441 @@
import { supabaseBrowser } from "../supabase/client";
import { invokeEdge } from "../supabase/invokeEdge";
import { throwIfError, requireData } from "./_helpers";
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
import { throwIfError, requireData } from './_helpers'
import type { DocumentoResult } from './plans.api'
import type {
Asignatura,
BibliografiaAsignatura,
CarreraRow,
CambioAsignatura,
EstructuraAsignatura,
FacultadRow,
PlanEstudioRow,
TipoAsignatura,
UUID,
} from "../types/domain";
import type { DocumentoResult } from "./plans.api";
} from '../types/domain'
import type {
AsignaturaSugerida,
DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types'
import type { Database, Tables, TablesInsert } from '@/types/supabase'
const EDGE = {
subjects_create_manual: "subjects_create_manual",
ai_generate_subject: "ai_generate_subject",
subjects_persist_from_ai: "subjects_persist_from_ai",
subjects_clone_from_existing: "subjects_clone_from_existing",
subjects_import_from_file: "subjects_import_from_file",
generate_subject_suggestions: 'generate-subject-suggestions',
subjects_create_manual: 'subjects_create_manual',
ai_generate_subject: 'ai-generate-subject',
subjects_persist_from_ai: 'subjects_persist_from_ai',
subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file',
subjects_update_fields: "subjects_update_fields",
subjects_update_contenido: "subjects_update_contenido",
subjects_update_bibliografia: "subjects_update_bibliografia",
// Bibliografía
buscar_bibliografia: 'buscar-bibliografia',
subjects_generate_document: "subjects_generate_document",
subjects_get_document: "subjects_get_document",
} as const;
subjects_update_fields: 'subjects_update_fields',
subjects_update_bibliografia: 'subjects_update_bibliografia',
export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
const supabase = supabaseBrowser();
subjects_generate_document: 'subjects_generate_document',
subjects_get_document: 'subjects_get_document',
} as const
export type BuscarBibliografiaRequest = {
searchTerms: {
q: string
}
google: {
orderBy?: 'newest' | 'relevance'
langRestrict?: string
startIndex?: number
[k: string]: unknown
}
openLibrary: {
language?: string
page?: number
sort?: string
[k: string]: unknown
}
}
export type GoogleBooksVolume = {
kind?: 'books#volume'
id: string
etag?: string
selfLink?: string
volumeInfo?: {
title?: string
subtitle?: string
authors?: Array<string>
publisher?: string
publishedDate?: string
description?: string
industryIdentifiers?: Array<{ type?: string; identifier?: string }>
pageCount?: number
categories?: Array<string>
language?: string
previewLink?: string
infoLink?: string
canonicalVolumeLink?: string
imageLinks?: {
smallThumbnail?: string
thumbnail?: string
small?: string
medium?: string
large?: string
extraLarge?: string
}
}
searchInfo?: {
textSnippet?: string
}
[k: string]: unknown
}
export type OpenLibraryDoc = Record<string, unknown>
export type EndpointResult =
| { endpoint: 'google'; item: GoogleBooksVolume }
| { endpoint: 'open_library'; item: OpenLibraryDoc }
export async function buscar_bibliografia(
input: BuscarBibliografiaRequest,
): Promise<Array<EndpointResult>> {
const q = input.searchTerms.q
if (typeof q !== 'string' || q.trim().length < 1) {
throw new Error('q es requerido')
}
return await invokeEdge<Array<EndpointResult>>(
EDGE.buscar_bibliografia,
input,
{ headers: { 'Content-Type': 'application/json' } },
)
}
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' | '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 { data, error } = await supabase
.from("asignaturas")
.from('asignaturas')
.select(
`
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,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,criterios_de_evaluacion,
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,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
),
estructuras_asignatura(id,nombre,version,definicion)
`
estructuras_asignatura(id,nombre,definicion)
`,
)
.eq("id", subjectId)
.single();
.eq('id', subjectId)
.single()
throwIfError(error);
return requireData(data, "Materia no encontrada.");
throwIfError(error)
return requireData(
data,
'Asignatura no encontrada.',
) as unknown as AsignaturaDetail
}
export async function subjects_history(subjectId: UUID): Promise<CambioAsignatura[]> {
const supabase = supabaseBrowser();
export async function subjects_history(
subjectId: UUID,
): Promise<Array<CambioAsignatura>> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from("cambios_asignatura")
.from('cambios_asignatura')
.select(
"id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id"
'id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id',
)
.eq("asignatura_id", subjectId)
.order("cambiado_en", { ascending: false });
.eq('asignatura_id', subjectId)
.order('cambiado_en', { ascending: false })
throwIfError(error);
return data ?? [];
throwIfError(error)
return data ?? []
}
export async function subjects_bibliografia_list(subjectId: UUID): Promise<BibliografiaAsignatura[]> {
const supabase = supabaseBrowser();
export async function subjects_bibliografia_list(
subjectId: UUID,
): Promise<Array<BibliografiaAsignatura>> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from("bibliografia_asignatura")
.select("id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en")
.eq("asignatura_id", subjectId)
.order("tipo", { ascending: true })
.order("creado_en", { ascending: true });
.from('bibliografia_asignatura')
.select(
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
)
.eq('asignatura_id', subjectId)
.order('tipo', { ascending: true })
.order('creado_en', { ascending: true })
throwIfError(error);
return data ?? [];
throwIfError(error)
return data ?? []
}
/** Wizard: crear materia 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(
payload: TablesInsert<'asignaturas'>,
): Promise<Asignatura> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.insert(payload)
.select()
.single()
export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload);
throwIfError(error)
return requireData(data, 'No se pudo crear la asignatura.')
}
export async function ai_generate_subject(payload: {
planId: UUID;
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: UUID;
};
iaConfig: {
descripcionEnfoque: string;
notasAdicionales?: string;
archivosExistentesIds?: UUID[];
repositoriosIds?: UUID[];
archivosAdhocIds?: UUID[];
usarMCP?: boolean;
};
}): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_subject, payload);
/**
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
* - Siempre incluye `datosUpdate.plan_estudio_id`.
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
* En el frontend, insertamos primero y usamos `id` para actualizar.
*/
export type AISubjectUnifiedInput = {
datosUpdate: Partial<{
id: string
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
}
iaConfig?: {
descripcionEnfoqueAcademico?: string
instruccionesAdicionalesIA?: string
archivosAdjuntos?: Array<string>
}
}
export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload);
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(
input: AISubjectUnifiedInput,
): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
headers: { 'Content-Type': 'application/json' },
})
}
export async function subjects_persist_from_ai(payload: {
planId: UUID
jsonAsignatura: any
}): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload)
}
export async function subjects_clone_from_existing(payload: {
materiaOrigenId: UUID;
planDestinoId: UUID;
asignaturaOrigenId: UUID
planDestinoId: UUID
overrides?: Partial<{
nombre: string;
codigo: string;
tipo: TipoAsignatura;
creditos: number;
horas_semana: number;
}>;
nombre: string
codigo: string
tipo: TipoAsignatura
creditos: number
horas_semana: number
}>
}): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload);
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload)
}
export async function subjects_import_from_file(payload: {
planId: UUID;
archivoWordMateriaId: UUID;
archivosAdicionalesIds?: UUID[];
planId: UUID
archivoWordAsignaturaId: UUID
archivosAdicionalesIds?: Array<UUID>
}): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload);
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload)
}
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
export type SubjectsUpdateFieldsPatch = Partial<{
codigo: string | null;
nombre: string;
tipo: TipoAsignatura;
creditos: number;
horas_semana: number | null;
numero_ciclo: number | null;
linea_plan_id: UUID | null;
codigo: string | null
nombre: string
tipo: TipoAsignatura
creditos: number
horas_semana: number | null
numero_ciclo: number | null
linea_plan_id: UUID | null
datos: Record<string, any>;
}>;
datos: Record<string, any>
}>
export async function subjects_update_fields(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, { subjectId, patch });
export async function subjects_update_fields(
subjectId: UUID,
patch: SubjectsUpdateFieldsPatch,
): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, {
subjectId,
patch,
})
}
export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { subjectId, unidades });
export async function subjects_update_contenido(
subjectId: UUID,
unidades: Array<ContenidoApi>,
): Promise<Asignatura> {
const supabase = supabaseBrowser()
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<{
id?: UUID;
tipo: "BASICA" | "COMPLEMENTARIA";
cita: string;
tipo_fuente?: "MANUAL" | "BIBLIOTECA";
biblioteca_item_id?: string | null;
}>;
id?: UUID
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}>
export async function subjects_update_bibliografia(
subjectId: UUID,
entries: BibliografiaUpsertInput
entries: BibliografiaUpsertInput,
): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, { subjectId, entries });
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, {
subjectId,
entries,
})
}
/** Documento SEP materia */
/** Documento SEP asignatura */
/* export type DocumentoResult = {
archivoId: UUID;
signedUrl: string;
@@ -183,10 +443,145 @@ export async function subjects_update_bibliografia(
nombre?: string;
}; */
export async function subjects_generate_document(subjectId: UUID): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, { subjectId });
export async function subjects_generate_document(
subjectId: UUID,
): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, {
subjectId,
})
}
export async function subjects_get_document(subjectId: UUID): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, { subjectId });
export async function subjects_get_document(
subjectId: UUID,
): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, {
subjectId,
})
}
export async function subjects_get_structure_catalog(): Promise<
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('estructuras_asignatura')
.select('*')
.order('nombre', { ascending: true })
if (error) {
throw error
}
return data
}
export async function asignaturas_update(
asignaturaId: UUID,
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
): Promise<Asignatura> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.update(patch)
.eq('id', asignaturaId)
.select() // Trae la materia actualizada
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar la asignatura.')
}
// Insertar una nueva línea
export async function lineas_insert(linea: {
nombre: string
plan_estudio_id: string
orden: number
area?: string
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
.insert([linea])
.select()
.single()
if (error) throw error
return data
}
// Actualizar una línea existente
export async function lineas_update(
lineaId: string,
patch: { nombre?: string; orden?: number; area?: string },
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan')
.update(patch)
.eq('id', lineaId)
.select()
.single()
if (error) throw error
return data
}
export async function lineas_delete(lineaId: string) {
const supabase = supabaseBrowser()
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
const { error } = await supabase
.from('lineas_plan')
.delete()
.eq('id', lineaId)
if (error) throw error
return lineaId
}
export async function bibliografia_insert(
entry: TablesInsert<'bibliografia_asignatura'>,
): Promise<Tables<'bibliografia_asignatura'>> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('bibliografia_asignatura')
.insert([entry])
.select()
.single()
if (error) throw error
return data as Tables<'bibliografia_asignatura'>
}
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,337 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import {
ai_plan_chat,
ai_plan_chat_v2,
ai_plan_improve,
ai_subject_chat,
ai_subject_improve,
create_conversation,
get_chat_history,
getConversationByPlan,
library_search,
} from "../api/ai.api";
update_conversation_status,
update_recommendation_applied_status,
update_conversation_title,
getMessagesByConversation,
update_subject_conversation_status,
update_subject_recommendation_applied,
getMessagesBySubjectConversation,
getConversationBySubject,
ai_subject_chat_v2,
create_subject_conversation,
update_subject_conversation_name,
} from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client'
import type { UUID } from 'node:crypto'
export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve });
return useMutation({ mutationFn: ai_plan_improve })
}
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 useMessagesByChat(conversationId: string | null) {
const queryClient = useQueryClient()
const supabase = supabaseBrowser()
const query = useQuery({
queryKey: ['conversation-messages', conversationId],
queryFn: () => {
if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesByConversation(conversationId)
},
enabled: !!conversationId,
placeholderData: (previousData) => previousData,
})
useEffect(() => {
if (!conversationId) return
// Suscribirse a cambios en los mensajes de ESTA conversación
const channel = supabase
.channel(`realtime-messages-${conversationId}`)
.on(
'postgres_changes',
{
event: '*', // Escuchamos INSERT y UPDATE
schema: 'public',
table: 'plan_mensajes_ia',
filter: `conversacion_plan_id=eq.${conversationId}`,
},
(payload) => {
// Opción A: Invalidar la query para que React Query haga refetch (más seguro)
queryClient.invalidateQueries({
queryKey: ['conversation-messages', conversationId],
})
/* Opción B: Actualización manual del caché (más rápido/fluido)
if (payload.eventType === 'INSERT') {
queryClient.setQueryData(['conversation-messages', conversationId], (old: any) => [...old, payload.new])
} else if (payload.eventType === 'UPDATE') {
queryClient.setQueryData(['conversation-messages', conversationId], (old: any) =>
old.map((m: any) => m.id === payload.new.id ? payload.new : m)
)
}
*/
},
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [conversationId, queryClient, supabase])
return query
}
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() {
return useMutation({ mutationFn: ai_subject_improve });
}
export function useAISubjectChat() {
return useMutation({ mutationFn: ai_subject_chat });
return useMutation({ mutationFn: ai_subject_improve })
}
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'] })
},
})
}
// Asignaturas
export function useAISubjectChat() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (payload: {
subjectId: UUID
content: string
campos?: Array<string>
conversacionId?: string
}) => {
let currentId = payload.conversacionId
// 1. Si no hay ID, creamos la conversación de asignatura
if (!currentId) {
const response = await create_subject_conversation(payload.subjectId)
currentId = response.conversation_asignatura.id
}
// 2. Enviamos mensaje al endpoint de asignatura
const result = await ai_subject_chat_v2({
conversacionId: currentId!,
content: payload.content,
campos: payload.campos,
})
return { ...result, conversacionId: currentId }
},
onSuccess: (data) => {
// Invalidamos mensajes para que se refresque el chat
qc.invalidateQueries({
queryKey: ['subject-messages', data.conversacionId],
})
},
})
}
export function useConversationBySubject(subjectId: string | null) {
return useQuery({
queryKey: ['conversation-by-subject', subjectId],
queryFn: () => getConversationBySubject(subjectId!),
enabled: !!subjectId,
})
}
export function useMessagesBySubjectChat(conversationId: string | null) {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: ['subject-messages', conversationId],
queryFn: async () => {
if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesBySubjectConversation(conversationId)
},
enabled: !!conversationId,
placeholderData: (previousData) => previousData,
})
useEffect(() => {
if (!conversationId) return
const supabase = supabaseBrowser()
// Suscripción a cambios en la tabla específica para esta conversación
const channel = supabase
.channel(`subject_messages_${conversationId}`)
.on(
'postgres_changes',
{
event: 'UPDATE', // Solo nos interesan las actualizaciones (cuando pasa de PROCESANDO a COMPLETADO)
schema: 'public',
table: 'asignatura_mensajes_ia',
filter: `conversacion_asignatura_id=eq.${conversationId}`,
},
(payload) => {
// Si el mensaje se completó o dio error, invalidamos la caché para traer los datos nuevos
if (
payload.new.estado === 'COMPLETADO' ||
payload.new.estado === 'ERROR'
) {
queryClient.invalidateQueries({
queryKey: ['subject-messages', conversationId],
})
}
},
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [conversationId, queryClient])
return query
}
export function useUpdateSubjectRecommendation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { mensajeId: string; campoAfectado: string }) =>
update_subject_recommendation_applied(
payload.mensajeId,
payload.campoAfectado,
),
onSuccess: () => {
// Refrescamos los mensajes para ver el check de "aplicado"
qc.invalidateQueries({ queryKey: ['subject-messages'] })
},
})
}
export function useUpdateSubjectConversationStatus() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { id: string; estado: 'ARCHIVADA' | 'ACTIVA' }) =>
update_subject_conversation_status(payload.id, payload.estado),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
},
})
}
export function useUpdateSubjectConversationName() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { id: string; nombre: string }) =>
update_subject_conversation_name(payload.id, payload.nombre),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
// También invalidamos los mensajes si el título se muestra en la cabecera
qc.invalidateQueries({ queryKey: ['subject-messages'] })
},
})
}

View File

@@ -4,6 +4,7 @@ import {
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useEffect } from 'react'
import {
ai_generate_plan,
@@ -12,6 +13,7 @@ import {
plan_lineas_list,
plans_clone_from_existing,
plans_create_manual,
plans_delete,
plans_generate_document,
plans_get,
plans_get_document,
@@ -23,7 +25,9 @@ import {
plans_update_fields,
plans_update_map,
} from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import type {
PlanListFilters,
@@ -53,7 +57,10 @@ export function usePlanes(filters: PlanListFilters) {
export function usePlan(planId: UUID | null | undefined) {
return useQuery({
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
queryFn: () => plans_get(planId as UUID),
queryFn: () => {
console.log('usePlan')
return plans_get(planId as UUID)
},
enabled: Boolean(planId),
})
}
@@ -67,20 +74,92 @@ export function usePlanLineas(planId: UUID | null | undefined) {
}
export function usePlanAsignaturas(planId: UUID | null | undefined) {
return useQuery({
const qc = useQueryClient()
const query = useQuery({
queryKey: planId
? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID),
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
}
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) {
export function usePlanHistorial(
planId: UUID | null | undefined,
page: number,
) {
return useQuery({
queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null],
queryFn: () => plans_history(planId as UUID),
queryKey: planId
? [...qk.planHistorial(planId), page]
: ['planes', 'historial', null, page],
queryFn: () => plans_history(planId as UUID, page),
enabled: Boolean(planId),
placeholderData: (previousData) => previousData,
})
}
@@ -95,7 +174,7 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
export function useCatalogosPlanes() {
return useQuery({
queryKey: ['catalogos_planes'],
queryKey: qk.estructurasPlan(),
queryFn: getCatalogos,
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
})
@@ -243,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() {
const qc = useQueryClient()
@@ -254,3 +350,15 @@ export function useGeneratePlanDocumento() {
},
})
}
export function useDeleteLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_delete,
onSuccess: (_idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
},
})
}

View File

@@ -1,19 +1,20 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { qk } from '../query/keys'
import type { UUID } from '../types/domain'
import type {
BibliografiaUpsertInput,
SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import {
ai_generate_subject,
asignaturas_update,
bibliografia_delete,
bibliografia_insert,
bibliografia_update,
lineas_insert,
lineas_update,
subjects_bibliografia_list,
subjects_clone_from_existing,
subjects_create_manual,
subjects_generate_document,
subjects_get,
subjects_get_document,
subjects_get_structure_catalog,
subjects_history,
subjects_import_from_file,
subjects_persist_from_ai,
@@ -21,6 +22,15 @@ import {
subjects_update_contenido,
subjects_update_fields,
} from '../api/subjects.api'
import { qk } from '../query/keys'
import type {
BibliografiaUpsertInput,
ContenidoApi,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import type { UUID } from '../types/domain'
import type { TablesInsert } from '@/types/supabase'
export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({
@@ -63,13 +73,20 @@ export function useSubjectDocumento(subjectId: UUID | null | undefined) {
})
}
export function useSubjectEstructuras() {
return useQuery({
queryKey: qk.estructurasAsignatura(),
queryFn: () => subjects_get_structure_catalog(),
})
}
/* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: SubjectsCreateManualInput) =>
mutationFn: (payload: TablesInsert<'asignaturas'>) =>
subjects_create_manual(payload),
onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject)
@@ -84,14 +101,16 @@ export function useCreateSubjectManual() {
}
export function useGenerateSubjectAI() {
return useMutation({ mutationFn: ai_generate_subject })
return useMutation({
mutationFn: ai_generate_subject,
})
}
export function usePersistSubjectFromAI() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { planId: UUID; jsonMateria: any }) =>
mutationFn: (payload: { planId: UUID; jsonAsignatura: any }) =>
subjects_persist_from_ai(payload),
onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject)
@@ -146,7 +165,9 @@ export function useUpdateSubjectFields() {
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch),
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),
})
@@ -159,10 +180,19 @@ export function useUpdateSubjectContenido() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
subjects_update_contenido(vars.subjectId, vars.unidades),
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) })
},
})
@@ -194,3 +224,96 @@ export function useGenerateSubjectDocumento() {
},
})
}
export function useUpdateAsignatura() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: {
asignaturaId: UUID
patch: Partial<SubjectsUpdateFieldsPatch>
}) => asignaturas_update(vars.asignaturaId, vars.patch),
onSuccess: (updated) => {
// ✅ Mantener consistencia con las query keys centralizadas (qk)
// 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) Refresca vistas derivadas del plan
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
// 3) Refresca historial de la asignatura si existe
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
},
})
}
export function useCreateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_insert,
onSuccess: (nuevaLinea) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
})
},
})
}
export function useUpdateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: { lineaId: string; patch: any }) =>
lineas_update(vars.lineaId, vars.patch),
onSuccess: (updated) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', updated.plan_estudio_id],
})
},
})
}
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

@@ -0,0 +1,311 @@
import type {
Asignatura,
AsignaturaStructure,
UnidadTematica,
BibliografiaEntry,
CambioAsignatura,
DocumentoAsignatura,
} from '@/types/asignatura'
export const mockAsignatura: Asignatura = {
id: '1',
nombre: 'Inteligencia Artificial Aplicada',
clave: 'IAA-401',
creditos: 8,
lineaCurricular: 'Sistemas Inteligentes',
ciclo: '7° Semestre',
planId: 'plan-1',
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
carrera: 'Ingeniería en Sistemas Computacionales',
facultad: 'Facultad de Ingeniería',
estructuraId: 'estructura-1',
}
export const mockEstructura: AsignaturaStructure = {
id: 'estructura-1',
nombre: 'Plantilla SEP Licenciatura',
campos: [
{
id: 'objetivo_general',
nombre: 'Objetivo General',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Describe el propósito principal de la asignatura',
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
},
{
id: 'competencias',
nombre: 'Competencias a Desarrollar',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Competencias profesionales que se desarrollarán',
},
{
id: 'justificacion',
nombre: 'Justificación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Relevancia de la asignatura en el plan de estudios',
},
{
id: 'requisitos',
nombre: 'Requisitos / Seriación',
tipo: 'texto',
obligatorio: false,
descripcion: 'Asignaturas previas requeridas',
},
{
id: 'estrategias_didacticas',
nombre: 'Estrategias Didácticas',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Métodos de enseñanza-aprendizaje',
},
{
id: 'evaluacion',
nombre: 'Sistema de Evaluación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Criterios y porcentajes de evaluación',
},
{
id: 'perfil_docente',
nombre: 'Perfil del Docente',
tipo: 'texto_largo',
obligatorio: false,
descripcion: 'Características requeridas del profesor',
},
],
}
export const mockDatosGenerales: Record<string, any> = {
objetivo_general:
'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
competencias:
'• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
justificacion:
'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta asignatura proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
requisitos:
'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
estrategias_didacticas:
'• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
evaluacion:
'• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
perfil_docente:
'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
}
export const mockContenidoTematico: Array<UnidadTematica> = [
{
id: 'unidad-1',
nombre: 'Fundamentos de Inteligencia Artificial',
numero: 1,
temas: [
{
id: 'tema-1-1',
nombre: 'Historia y evolución de la IA',
descripcion: 'Desde los orígenes hasta la actualidad',
horasEstimadas: 2,
},
{
id: 'tema-1-2',
nombre: 'Tipos de IA y aplicaciones',
descripcion: 'IA débil, fuerte y superinteligencia',
horasEstimadas: 3,
},
{
id: 'tema-1-3',
nombre: 'Ética en IA',
descripcion: 'Consideraciones éticas y responsabilidad',
horasEstimadas: 2,
},
],
},
{
id: 'unidad-2',
nombre: 'Machine Learning',
numero: 2,
temas: [
{
id: 'tema-2-1',
nombre: 'Aprendizaje supervisado',
descripcion: 'Regresión y clasificación',
horasEstimadas: 6,
},
{
id: 'tema-2-2',
nombre: 'Aprendizaje no supervisado',
descripcion: 'Clustering y reducción de dimensionalidad',
horasEstimadas: 5,
},
{
id: 'tema-2-3',
nombre: 'Evaluación de modelos',
descripcion: 'Métricas y validación cruzada',
horasEstimadas: 4,
},
],
},
{
id: 'unidad-3',
nombre: 'Deep Learning',
numero: 3,
temas: [
{
id: 'tema-3-1',
nombre: 'Redes neuronales artificiales',
descripcion: 'Perceptrón y backpropagation',
horasEstimadas: 5,
},
{
id: 'tema-3-2',
nombre: 'Redes convolucionales (CNN)',
descripcion: 'Procesamiento de imágenes',
horasEstimadas: 6,
},
{
id: 'tema-3-3',
nombre: 'Redes recurrentes (RNN)',
descripcion: 'Procesamiento de secuencias',
horasEstimadas: 5,
},
{
id: 'tema-3-4',
nombre: 'Transformers y atención',
descripcion: 'Arquitecturas modernas',
horasEstimadas: 6,
},
],
},
{
id: 'unidad-4',
nombre: 'Aplicaciones Prácticas',
numero: 4,
temas: [
{
id: 'tema-4-1',
nombre: 'Procesamiento de lenguaje natural',
descripcion: 'NLP y chatbots',
horasEstimadas: 6,
},
{
id: 'tema-4-2',
nombre: 'Visión por computadora',
descripcion: 'Detección y reconocimiento',
horasEstimadas: 5,
},
{
id: 'tema-4-3',
nombre: 'Sistemas de recomendación',
descripcion: 'Filtrado colaborativo y contenido',
horasEstimadas: 4,
},
],
},
]
export const mockBibliografia: Array<BibliografiaEntry> = [
{
id: 'bib-1',
tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
fuenteBibliotecaId: 'lib-1',
fuenteBiblioteca: {
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: 'bib-2',
tipo: 'BASICA',
cita: "Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O'Reilly Media.",
fuenteBibliotecaId: 'lib-2',
fuenteBiblioteca: {
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: 'bib-3',
tipo: 'COMPLEMENTARIA',
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
},
{
id: 'bib-4',
tipo: 'COMPLEMENTARIA',
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
fuenteBibliotecaId: 'lib-4',
fuenteBiblioteca: {
id: 'lib-4',
titulo: 'Deep Learning with Python',
autor: 'François Chollet',
editorial: 'Manning Publications',
anio: 2021,
isbn: '978-1617296864',
tipo: 'libro',
disponible: false,
},
},
]
export const mockHistorial: Array<CambioAsignatura> = [
{
id: 'cambio-1',
tipo: 'datos',
descripcion: 'Actualización del objetivo general',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-10T14:30:00'),
detalles: { campo: 'objetivo_general' },
},
{
id: 'cambio-2',
tipo: 'contenido',
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-09T10:15:00'),
detalles: { unidad: 'Unidad 4' },
},
{
id: 'cambio-3',
tipo: 'ia',
descripcion: 'IA mejoró las competencias a desarrollar',
usuario: 'Dra. María López',
fecha: new Date('2024-12-08T16:45:00'),
detalles: { campo: 'competencias', accion: 'mejora' },
},
{
id: 'cambio-4',
tipo: 'bibliografia',
descripcion: 'Añadida referencia: Deep Learning with Python',
usuario: 'Biblioteca Central',
fecha: new Date('2024-12-07T09:00:00'),
},
{
id: 'cambio-5',
tipo: 'documento',
descripcion: 'Documento SEP regenerado (versión 3)',
usuario: 'Sistema',
fecha: new Date('2024-12-06T11:30:00'),
},
]
export const mockDocumentoSep: DocumentoAsignatura = {
id: 'doc-1',
asignaturaId: '1',
version: 3,
fechaGeneracion: new Date('2024-12-06T11:30:00'),
estado: 'listo',
}

View File

@@ -1,302 +0,0 @@
import type {
Materia,
MateriaStructure,
UnidadTematica,
BibliografiaEntry,
CambioMateria,
DocumentoMateria,
LibraryResource
} from '@/types/materia';
export const mockMateria: Materia = {
id: '1',
nombre: 'Inteligencia Artificial Aplicada',
clave: 'IAA-401',
creditos: 8,
lineaCurricular: 'Sistemas Inteligentes',
ciclo: '7° Semestre',
planId: 'plan-1',
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
carrera: 'Ingeniería en Sistemas Computacionales',
facultad: 'Facultad de Ingeniería',
estructuraId: 'estructura-1',
};
export const mockEstructura: MateriaStructure = {
id: 'estructura-1',
nombre: 'Plantilla SEP Licenciatura',
campos: [
{
id: 'objetivo_general',
nombre: 'Objetivo General',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Describe el propósito principal de la materia',
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
},
{
id: 'competencias',
nombre: 'Competencias a Desarrollar',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Competencias profesionales que se desarrollarán',
},
{
id: 'justificacion',
nombre: 'Justificación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Relevancia de la materia en el plan de estudios',
},
{
id: 'requisitos',
nombre: 'Requisitos / Seriación',
tipo: 'texto',
obligatorio: false,
descripcion: 'Materias previas requeridas',
},
{
id: 'estrategias_didacticas',
nombre: 'Estrategias Didácticas',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Métodos de enseñanza-aprendizaje',
},
{
id: 'evaluacion',
nombre: 'Sistema de Evaluación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Criterios y porcentajes de evaluación',
},
{
id: 'perfil_docente',
nombre: 'Perfil del Docente',
tipo: 'texto_largo',
obligatorio: false,
descripcion: 'Características requeridas del profesor',
},
],
};
export const mockDatosGenerales: Record<string, any> = {
objetivo_general: 'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
competencias: '• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
justificacion: 'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta materia proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
requisitos: 'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
estrategias_didacticas: '• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
evaluacion: '• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
perfil_docente: 'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
};
export const mockContenidoTematico: UnidadTematica[] = [
{
id: 'unidad-1',
nombre: 'Fundamentos de Inteligencia Artificial',
numero: 1,
temas: [
{ id: 'tema-1-1', nombre: 'Historia y evolución de la IA', descripcion: 'Desde los orígenes hasta la actualidad', horasEstimadas: 2 },
{ id: 'tema-1-2', nombre: 'Tipos de IA y aplicaciones', descripcion: 'IA débil, fuerte y superinteligencia', horasEstimadas: 3 },
{ id: 'tema-1-3', nombre: 'Ética en IA', descripcion: 'Consideraciones éticas y responsabilidad', horasEstimadas: 2 },
],
},
{
id: 'unidad-2',
nombre: 'Machine Learning',
numero: 2,
temas: [
{ id: 'tema-2-1', nombre: 'Aprendizaje supervisado', descripcion: 'Regresión y clasificación', horasEstimadas: 6 },
{ id: 'tema-2-2', nombre: 'Aprendizaje no supervisado', descripcion: 'Clustering y reducción de dimensionalidad', horasEstimadas: 5 },
{ id: 'tema-2-3', nombre: 'Evaluación de modelos', descripcion: 'Métricas y validación cruzada', horasEstimadas: 4 },
],
},
{
id: 'unidad-3',
nombre: 'Deep Learning',
numero: 3,
temas: [
{ id: 'tema-3-1', nombre: 'Redes neuronales artificiales', descripcion: 'Perceptrón y backpropagation', horasEstimadas: 5 },
{ id: 'tema-3-2', nombre: 'Redes convolucionales (CNN)', descripcion: 'Procesamiento de imágenes', horasEstimadas: 6 },
{ id: 'tema-3-3', nombre: 'Redes recurrentes (RNN)', descripcion: 'Procesamiento de secuencias', horasEstimadas: 5 },
{ id: 'tema-3-4', nombre: 'Transformers y atención', descripcion: 'Arquitecturas modernas', horasEstimadas: 6 },
],
},
{
id: 'unidad-4',
nombre: 'Aplicaciones Prácticas',
numero: 4,
temas: [
{ id: 'tema-4-1', nombre: 'Procesamiento de lenguaje natural', descripcion: 'NLP y chatbots', horasEstimadas: 6 },
{ id: 'tema-4-2', nombre: 'Visión por computadora', descripcion: 'Detección y reconocimiento', horasEstimadas: 5 },
{ id: 'tema-4-3', nombre: 'Sistemas de recomendación', descripcion: 'Filtrado colaborativo y contenido', horasEstimadas: 4 },
],
},
];
export const mockBibliografia: BibliografiaEntry[] = [
{
id: 'bib-1',
tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
fuenteBibliotecaId: 'lib-1',
fuenteBiblioteca: {
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: 'bib-2',
tipo: 'BASICA',
cita: 'Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O\'Reilly Media.',
fuenteBibliotecaId: 'lib-2',
fuenteBiblioteca: {
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: 'bib-3',
tipo: 'COMPLEMENTARIA',
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
},
{
id: 'bib-4',
tipo: 'COMPLEMENTARIA',
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
fuenteBibliotecaId: 'lib-4',
fuenteBiblioteca: {
id: 'lib-4',
titulo: 'Deep Learning with Python',
autor: 'François Chollet',
editorial: 'Manning Publications',
anio: 2021,
isbn: '978-1617296864',
tipo: 'libro',
disponible: false,
},
},
];
export const mockHistorial: CambioMateria[] = [
{
id: 'cambio-1',
tipo: 'datos',
descripcion: 'Actualización del objetivo general',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-10T14:30:00'),
detalles: { campo: 'objetivo_general' },
},
{
id: 'cambio-2',
tipo: 'contenido',
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-09T10:15:00'),
detalles: { unidad: 'Unidad 4' },
},
{
id: 'cambio-3',
tipo: 'ia',
descripcion: 'IA mejoró las competencias a desarrollar',
usuario: 'Dra. María López',
fecha: new Date('2024-12-08T16:45:00'),
detalles: { campo: 'competencias', accion: 'mejora' },
},
{
id: 'cambio-4',
tipo: 'bibliografia',
descripcion: 'Añadida referencia: Deep Learning with Python',
usuario: 'Biblioteca Central',
fecha: new Date('2024-12-07T09:00:00'),
},
{
id: 'cambio-5',
tipo: 'documento',
descripcion: 'Documento SEP regenerado (versión 3)',
usuario: 'Sistema',
fecha: new Date('2024-12-06T11:30:00'),
},
];
export const mockDocumentoSep: DocumentoMateria = {
id: 'doc-1',
materiaId: '1',
version: 3,
fechaGeneracion: new Date('2024-12-06T11:30:00'),
estado: 'listo',
};
export const mockLibraryResources: 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

@@ -1,31 +1,37 @@
export const qk = {
auth: ["auth"] as const,
session: () => ["auth", "session"] as const,
meProfile: () => ["auth", "meProfile"] as const,
auth: ['auth'] as const,
session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const,
facultades: () => ["meta", "facultades"] as const,
facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) =>
["meta", "carreras", { facultadId: facultadId ?? null }] as const,
['meta', 'carreras', { facultadId: facultadId ?? null }] as const,
estructurasPlan: (nivel?: string | null) =>
["meta", "estructurasPlan", { nivel: nivel ?? null }] as const,
estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const,
estadosPlan: () => ["meta", "estadosPlan"] as const,
['meta', 'estructurasPlan', { nivel: nivel ?? null }] as const,
estructurasAsignatura: () => ['meta', 'estructurasAsignatura'] as const,
estadosPlan: () => ['meta', 'estadosPlan'] as const,
planesList: (filters: unknown) => ["planes", "list", filters] as const,
plan: (planId: string) => ["planes", "detail", planId] as const,
planLineas: (planId: string) => ["planes", planId, "lineas"] as const,
planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const,
planHistorial: (planId: string) => ["planes", planId, "historial"] as const,
planDocumento: (planId: string) => ["planes", planId, "documento"] as const,
planesList: (filters: unknown) => ['planes', 'list', filters] 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,
planAsignaturas: (planId: string) =>
['planes', planId, 'asignaturas'] as const,
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const,
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) =>
["asignaturas", asignaturaId, "bibliografia"] as const,
['asignaturas', asignaturaId, 'bibliografia'] as const,
asignaturaHistorial: (asignaturaId: string) =>
["asignaturas", asignaturaId, "historial"] as const,
['asignaturas', asignaturaId, 'historial'] as const,
asignaturaDocumento: (asignaturaId: string) =>
["asignaturas", asignaturaId, "documento"] as const,
['asignaturas', asignaturaId, 'documento'] as const,
tareas: () => ["tareas", "mias"] as const,
notificaciones: () => ["notificaciones", "mias"] as const,
};
tareas: () => ['tareas', 'mias'] as const,
notificaciones: () => ['notificaciones', 'mias'] as const,
}

View File

@@ -1,12 +1,18 @@
import { supabaseBrowser } from "./client";
import {
FunctionsFetchError,
FunctionsHttpError,
FunctionsRelayError,
} from '@supabase/supabase-js'
import type { Database } from "@/types/supabase";
import type { SupabaseClient } from "@supabase/supabase-js";
import { supabaseBrowser } from './client'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
};
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string>
}
export class EdgeFunctionError extends Error {
constructor(
@@ -15,8 +21,8 @@ export class EdgeFunctionError extends Error {
public readonly status?: number,
public readonly details?: unknown,
) {
super(message);
this.name = "EdgeFunctionError";
super(message)
this.name = 'EdgeFunctionError'
}
}
@@ -34,23 +40,69 @@ export async function invokeEdge<TOut>(
opts: EdgeInvokeOptions = {},
client?: SupabaseClient<Database>,
): Promise<TOut> {
const supabase = client ?? supabaseBrowser();
const supabase = client ?? supabaseBrowser()
const { data, error } = await supabase.functions.invoke(functionName, {
body,
method: opts.method ?? "POST",
method: opts.method ?? 'POST',
headers: opts.headers,
});
})
if (error) {
const anyErr = error;
throw new EdgeFunctionError(
anyErr.message ?? "Error en Edge Function",
functionName,
anyErr.status,
anyErr,
);
// Valores por defecto (por si falla el parseo o es otro tipo de error)
let message = error.message // El genérico "returned a non-2xx status code"
let status = undefined
let details: unknown = error
// 2. Verificamos si es un error HTTP (4xx o 5xx) que trae cuerpo JSON
if (error instanceof FunctionsHttpError) {
try {
// Obtenemos el status real (ej. 404, 400)
status = error.context.status
// ¡LA CLAVE! Leemos el JSON que tu Edge Function envió
const errorBody = await error.context.json()
details = errorBody
// Intentamos extraer el mensaje humano según tu estructura { error: { message: "..." } }
// o la estructura simple { error: "..." }
if (errorBody && typeof errorBody === 'object') {
// Caso 1: Estructura anidada (la que definimos hace poco: { error: { message: "..." } })
if (
'error' in errorBody &&
typeof errorBody.error === 'object' &&
errorBody.error !== null &&
'message' in errorBody.error
) {
message = (errorBody.error as { message: string }).message
}
// Caso 2: Estructura simple ({ error: "Mensaje de error" })
else if (
'error' in errorBody &&
typeof errorBody.error === 'string'
) {
message = errorBody.error
}
// Caso 3: Propiedad message directa ({ message: "..." })
else if (
'message' in errorBody &&
typeof errorBody.message === 'string'
) {
message = errorBody.message
}
}
} catch (e) {
console.warn('No se pudo parsear el error JSON de la Edge Function', e)
}
} else if (error instanceof FunctionsRelayError) {
message = `Error de Relay Supabase: ${error.message}`
} else if (error instanceof FunctionsFetchError) {
message = `Error de conexión (Fetch): ${error.message}`
}
// 3. Lanzamos tu error personalizado con los datos reales extraídos
throw new EdgeFunctionError(message, functionName, status, details)
}
return data as TOut;
return data as TOut
}

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

@@ -1,16 +1,26 @@
import { useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
const Wizard = defineStepper(
{
@@ -24,8 +34,8 @@ const Wizard = defineStepper(
description: 'Nombre y estructura',
},
{
id: 'configuracion',
title: 'Configuración',
id: 'detalles',
title: 'Detalles',
description: 'Detalles según modo',
},
{
@@ -35,8 +45,6 @@ const Wizard = defineStepper(
},
)
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
const navigate = useNavigate()
const role = auth_get_current_user_role()
@@ -46,82 +54,112 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
canContinueDesdeDetalles,
} = useNuevaAsignaturaWizard(planId)
const titleOverrides =
wizard.tipoOrigen === 'IA_MULTIPLE'
? {
basicos: 'Sugerencias',
detalles: 'Estructura',
}
: undefined
const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
}
if (role !== 'JEFE_CARRERA') {
return (
<WizardLayout title="Nueva Asignatura" onClose={handleClose}>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<Icons.ShieldAlert className="h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
Solo el Jefe de Carrera puede crear asignaturas.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<Button variant="secondary" onClick={handleClose}>
Volver
</Button>
</CardContent>
</Card>
</WizardLayout>
)
}
return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => e.preventDefault()}
>
{role !== 'JEFE_CARRERA' ? (
<VistaSinPermisos onClose={handleClose} />
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => (
<>
<WizardHeader
title="Nueva Asignatura"
Wizard={Wizard}
methods={{ ...methods, onClose: handleClose }}
/>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
<div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoMetodoCardGroup
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoConfiguracionPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={simularGeneracionIA}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id)
return (
<WizardLayout
title="Nueva Asignatura"
onClose={handleClose}
headerSlot={
<WizardResponsiveHeader
wizard={Wizard}
methods={methods}
titleOverrides={titleOverrides}
/>
}
footerSlot={
<Wizard.Stepper.Controls>
<WizardControls
Wizard={Wizard}
methods={methods}
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
setWizard={setWizard}
/>
</>
)}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
</Wizard.Stepper.Controls>
}
>
<div className="mx-auto max-w-3xl">
{idx === 0 && (
<Wizard.Stepper.Panel>
<PasoMetodoCardGroup wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</WizardLayout>
)
}}
</Wizard.Stepper.Provider>
)
}

View File

@@ -1,90 +1,83 @@
import { useState } from "react";
import { useState } from 'react'
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
import type { NewSubjectWizardState } from '../types'
export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1,
planId,
modoCreacion: null,
plan_estudio_id: planId,
estructuraId: null,
tipoOrigen: null,
datosBasicos: {
nombre: "",
clave: "",
tipo: "OBLIGATORIA",
creditos: 0,
horasSemana: 0,
estructuraId: "",
nombre: '',
codigo: '',
tipo: null,
creditos: null,
horasAcademicas: null,
horasIndependientes: null,
estructuraId: '',
},
sugerencias: [],
clonInterno: {},
clonTradicional: {
archivoWordAsignaturaId: null,
archivosAdicionalesIds: [],
},
iaConfig: {
descripcionEnfoque: "",
notasAdicionales: "",
archivosExistentesIds: [],
descripcionEnfoqueAcademico: '',
instruccionesAdicionalesIA: '',
archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
},
iaMultiple: {
enfoque: '',
cantidadDeSugerencias: 10,
isLoading: false,
},
resumen: {},
isLoading: false,
errorMessage: null,
});
})
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
wizard.modoCreacion === "IA" ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
const canContinueDesdeMetodo =
wizard.tipoOrigen === 'MANUAL' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre &&
wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId;
const canContinueDesdeBasicos =
(!!wizard.datosBasicos.nombre &&
wizard.datosBasicos.tipo !== null &&
wizard.datosBasicos.creditos !== null &&
wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId) ||
(wizard.tipoOrigen === 'IA_MULTIPLE' &&
wizard.sugerencias.filter((s) => s.selected).length > 0)
const canContinueDesdeConfig = (() => {
if (wizard.modoCreacion === "MANUAL") return true;
if (wizard.modoCreacion === "IA") {
return !!wizard.iaConfig?.descripcionEnfoque;
const canContinueDesdeDetalles = (() => {
if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.tipoOrigen === 'IA_SIMPLE') {
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
}
if (wizard.modoCreacion === "CLONADO") {
if (wizard.subModoClonado === "INTERNO") {
return !!wizard.clonInterno?.asignaturaOrigenId;
}
if (wizard.subModoClonado === "TRADICIONAL") {
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return !!wizard.clonInterno?.asignaturaOrigenId
}
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 (onCreated: () => void) => {
setWizard((w) => ({ ...w, isLoading: true }));
await new Promise((r) => setTimeout(r, 1000));
onCreated();
};
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return !!wizard.clonTradicional?.archivoWordAsignaturaId
}
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
return wizard.estructuraId !== null
}
return false
})()
return {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
};
canContinueDesdeDetalles,
}
}

View File

@@ -1,45 +1,79 @@
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRO";
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { Asignatura } from '@/data'
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
export type AsignaturaPreview = {
nombre: string;
objetivo: string;
unidades: number;
bibliografiaCount: number;
};
nombre: string
objetivo: string
unidades: 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 = {
step: 1 | 2 | 3 | 4;
planId: string;
modoCreacion: ModoCreacion | null;
subModoClonado?: SubModoClonado;
step: 1 | 2 | 3 | 4
plan_estudio_id: Asignatura['plan_estudio_id']
estructuraId: Asignatura['estructura_id'] | null
tipoOrigen:
| Asignatura['tipo_origen']
| 'CLONADO'
| 'IA_SIMPLE'
| 'IA_MULTIPLE'
| null
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: string;
};
nombre: Asignatura['nombre']
codigo?: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos'] | null
horasAcademicas?: Asignatura['horas_academicas'] | null
horasIndependientes?: Asignatura['horas_independientes'] | null
estructuraId: Asignatura['estructura_id'] | null
}
sugerencias: Array<AsignaturaSugerida>
clonInterno?: {
facultadId?: string;
carreraId?: string;
planOrigenId?: string;
asignaturaOrigenId?: string | null;
};
facultadId?: string
carreraId?: string
planOrigenId?: string
asignaturaOrigenId?: string | null
}
clonTradicional?: {
archivoWordAsignaturaId: string | null;
archivosAdicionalesIds: Array<string>;
};
archivoWordAsignaturaId: string | null
archivosAdicionalesIds: Array<string>
}
iaConfig?: {
descripcionEnfoque: string;
notasAdicionales: string;
archivosExistentesIds: Array<string>;
};
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
}
iaMultiple?: {
enfoque: string
cantidadDeSugerencias: number
isLoading: boolean
}
resumen: {
previewAsignatura?: AsignaturaPreview;
};
isLoading: boolean;
errorMessage: string | null;
};
previewAsignatura?: AsignaturaPreview
}
isLoading: boolean
errorMessage: string | null
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
import { WizardControls } from '@/components/planes/wizard/WizardControls'
import { WizardHeader } from '@/components/planes/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper'
import {
Card,
@@ -19,16 +18,12 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
// import { useGeneratePlanAI } from '@/data/hooks/usePlans'
// Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
const Wizard = defineStepper(
{
@@ -64,136 +59,97 @@ export default function NuevoPlanModalContainer() {
// Crear plan: ahora la lógica vive en WizardControls
if (role !== 'JEFE_CARRERA') {
return (
<WizardLayout title="Nuevo plan de estudios" onClose={handleClose}>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
No tienes permisos para crear planes de estudio.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<button
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
onClick={handleClose}
>
Volver
</button>
</CardContent>
</Card>
</WizardLayout>
)
}
return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => {
e.preventDefault()
}}
>
{role !== 'JEFE_CARRERA' ? (
<>
<DialogHeader className="flex-none border-b p-6">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
<div className="flex-1 p-6">
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
No tienes permisos para crear planes de estudio.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<button
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
onClick={handleClose}
>
Volver
</button>
</CardContent>
</Card>
</div>
</>
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id)
return (
<WizardLayout
title="Nuevo plan de estudios"
onClose={handleClose}
headerSlot={
<WizardResponsiveHeader wizard={Wizard} methods={methods} />
}
footerSlot={
<Wizard.Stepper.Controls>
<WizardControls
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeModo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
setWizard={setWizard}
/>
</Wizard.Stepper.Controls>
}
>
{({ methods }) => {
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
const totalSteps = Wizard.steps.length
const nextStep = Wizard.steps[currentIndex] ?? {
title: '',
description: '',
}
return (
<>
<WizardHeader
currentIndex={currentIndex}
totalSteps={totalSteps}
currentTitle={methods.current.title}
currentDescription={methods.current.description}
nextTitle={nextStep.title}
onClose={handleClose}
Wizard={Wizard}
<div className="mx-auto max-w-3xl">
{idx === 0 && (
<Wizard.Stepper.Panel>
<PasoModoCardGroup wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
isLoading={wizard.isLoading}
/>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
<div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoModoCardGroup
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
<div className="flex-none border-t bg-white p-6">
<Wizard.Stepper.Controls>
<WizardControls
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={
Wizard.utils.getIndex(methods.current.id) === 0 ||
wizard.isLoading
}
disableNext={
wizard.isLoading ||
(Wizard.utils.getIndex(methods.current.id) === 0 &&
!canContinueDesdeModo) ||
(Wizard.utils.getIndex(methods.current.id) === 1 &&
!canContinueDesdeBasicos) ||
(Wizard.utils.getIndex(methods.current.id) === 2 &&
!canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={
Wizard.utils.getIndex(methods.current.id) >=
Wizard.steps.length - 1
}
wizard={wizard}
setWizard={setWizard}
/>
</Wizard.Stepper.Controls>
</div>
</>
)
}}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
</Wizard.Stepper.Panel>
)}
{idx === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</WizardLayout>
)
}}
</Wizard.Stepper.Provider>
)
}

View File

@@ -1,156 +1,156 @@
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
export const FACULTADES = [
{ id: "ing", nombre: "Facultad de Ingeniería" },
{ id: 'ing', nombre: 'Facultad de Ingeniería' },
{
id: "med",
nombre: "Facultad de Medicina en medicina en medicina en medicina",
id: 'med',
nombre: 'Facultad de Medicina en medicina en medicina en medicina',
},
{ id: "neg", nombre: "Facultad de Negocios" },
];
{ id: 'neg', nombre: 'Facultad de Negocios' },
]
export const CARRERAS = [
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
];
{ id: 'sis', nombre: 'Ing. en Sistemas', facultadId: 'ing' },
{ id: 'ind', nombre: 'Ing. Industrial', facultadId: 'ing' },
{ id: 'medico', nombre: 'Médico Cirujano', facultadId: 'med' },
{ id: 'act', nombre: 'Actuaría', facultadId: 'neg' },
]
export const NIVELES: Array<NivelPlanEstudio> = [
"Licenciatura",
"Maestría",
"Doctorado",
"Especialidad",
"Diplomado",
"Otro",
];
'Licenciatura',
'Maestría',
'Doctorado',
'Especialidad',
'Diplomado',
'Otro',
]
export const TIPOS_CICLO: Array<TipoCiclo> = [
"Semestre",
"Cuatrimestre",
"Trimestre",
"Otro",
];
'Semestre',
'Cuatrimestre',
'Trimestre',
'Otro',
]
export const PLANES_EXISTENTES = [
{
id: "plan-2021-sis",
nombre: "ISC 2021",
estado: "Aprobado",
id: 'plan-2021-sis',
nombre: 'ISC 2021',
estado: 'Aprobado',
anio: 2021,
facultadId: "ing",
carreraId: "sis",
facultadId: 'ing',
carreraId: 'sis',
},
{
id: "plan-2020-ind",
nombre: "I. Industrial 2020",
estado: "Aprobado",
id: 'plan-2020-ind',
nombre: 'I. Industrial 2020',
estado: 'Aprobado',
anio: 2020,
facultadId: "ing",
carreraId: "ind",
facultadId: 'ing',
carreraId: 'ind',
},
{
id: "plan-2019-med",
nombre: "Medicina 2019",
estado: "Vigente",
id: 'plan-2019-med',
nombre: 'Medicina 2019',
estado: 'Vigente',
anio: 2019,
facultadId: "med",
carreraId: "medico",
facultadId: 'med',
carreraId: 'medico',
},
];
]
export const ARCHIVOS = [
{
id: "file-1",
nombre: "Sílabo POO 2023.docx",
tipo: "docx",
tamaño: "245 KB",
id: 'file-1',
nombre: 'Sílabo POO 2023.docx',
tipo: 'docx',
tamaño: '245 KB',
},
{
id: "file-2",
nombre: "Guía de prácticas BD.pdf",
tipo: "pdf",
tamaño: "1.2 MB",
id: 'file-2',
nombre: 'Guía de prácticas BD.pdf',
tipo: 'pdf',
tamaño: '1.2 MB',
},
{
id: "file-3",
nombre: "Rúbrica evaluación proyectos.xlsx",
tipo: "xlsx",
tamaño: "89 KB",
id: 'file-3',
nombre: 'Rúbrica evaluación proyectos.xlsx',
tipo: 'xlsx',
tamaño: '89 KB',
},
{
id: "file-4",
nombre: "Banco de reactivos IA.docx",
tipo: "docx",
tamaño: "567 KB",
id: 'file-4',
nombre: 'Banco de reactivos IA.docx',
tipo: 'docx',
tamaño: '567 KB',
},
{
id: "file-5",
nombre: "Material didáctico Web.pdf",
tipo: "pdf",
tamaño: "3.4 MB",
id: 'file-5',
nombre: 'Asignatural didáctico Web.pdf',
tipo: 'pdf',
tamaño: '3.4 MB',
},
];
]
export const REPOSITORIOS = [
{
id: "repo-1",
nombre: "Materiales ISC 2024",
descripcion: "Documentos de referencia para Ingeniería en Sistemas",
id: 'repo-1',
nombre: 'Asignaturales ISC 2024',
descripcion: 'Documentos de referencia para Ingeniería en Sistemas',
cantidadArchivos: 45,
},
{
id: "repo-2",
nombre: "Lineamientos SEP",
descripcion: "Documentos oficiales y normativas SEP actualizadas",
id: 'repo-2',
nombre: 'Lineamientos SEP',
descripcion: 'Documentos oficiales y normativas SEP actualizadas',
cantidadArchivos: 12,
},
{
id: "repo-3",
nombre: "Bibliografía Digital",
descripcion: "Recursos bibliográficos digitalizados",
id: 'repo-3',
nombre: 'Bibliografía Digital',
descripcion: 'Recursos bibliográficos digitalizados',
cantidadArchivos: 128,
},
{
id: "repo-4",
nombre: "Plantillas Institucionales",
descripcion: "Formatos y plantillas oficiales ULSA",
id: 'repo-4',
nombre: 'Plantillas Institucionales',
descripcion: 'Formatos y plantillas oficiales ULSA',
cantidadArchivos: 23,
},
];
]
export const PLANTILLAS_ANEXO_1 = [
{
id: "sep-2025",
name: "Licenciatura RVOE SEP.docx",
versions: ["v2025.2 (Vigente)", "v2025.1", "v2024.Final"],
id: 'sep-2025',
name: 'Licenciatura RVOE SEP.docx',
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
},
{
id: "interno-mix",
name: "Estándar Institucional Mixto.docx",
versions: ["v2.0", "v1.5", "v1.0-beta"],
id: 'interno-mix',
name: 'Estándar Institucional Mixto.docx',
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
},
{
id: "conacyt",
name: "Formato Posgrado CONAHCYT.docx",
versions: ["v3.0 (2025)", "v2.8"],
id: 'conacyt',
name: 'Formato Posgrado CONAHCYT.docx',
versions: ['v3.0 (2025)', 'v2.8'],
},
];
]
export const PLANTILLAS_ANEXO_2 = [
{
id: "sep-2017-xlsx",
name: "Licenciatura RVOE 2017.xlsx",
versions: ["v2017.0", "v2018.1", "v2019.2", "v2020.Final"],
id: 'sep-2017-xlsx',
name: 'Licenciatura RVOE 2017.xlsx',
versions: ['v2017.0', 'v2018.1', 'v2019.2', 'v2020.Final'],
},
{
id: "interno-mix-xlsx",
name: "Estándar Institucional Mixto.xlsx",
versions: ["v1.0", "v1.5"],
id: 'interno-mix-xlsx',
name: 'Estándar Institucional Mixto.xlsx',
versions: ['v1.0', 'v1.5'],
},
{
id: "conacyt-xlsx",
name: "Formato Posgrado CONAHCYT.xlsx",
versions: ["v1.0", "v2.0"],
id: 'conacyt-xlsx',
name: 'Formato Posgrado CONAHCYT.xlsx',
versions: ['v1.0', 'v2.0'],
},
];
]

View File

@@ -8,11 +8,11 @@ export function useNuevoPlanWizard() {
tipoOrigen: null,
datosBasicos: {
nombrePlan: '',
carreraId: '',
facultadId: '',
facultad: { id: '', nombre: '' },
carrera: { id: '', nombre: '' },
nivel: '',
tipoCiclo: '',
numCiclos: undefined,
numCiclos: null,
estructuraPlanId: null,
},
// datosBasicos: {
@@ -34,8 +34,8 @@ export function useNuevoPlanWizard() {
archivoAsignaturasExcelId: null,
},
iaConfig: {
descripcionEnfoque: '',
notasAdicionales: '',
descripcionEnfoqueAcademico: '',
instruccionesAdicionalesIA: '',
archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
@@ -53,10 +53,10 @@ export function useNuevoPlanWizard() {
const canContinueDesdeBasicos =
!!wizard.datosBasicos.nombrePlan &&
!!wizard.datosBasicos.carreraId &&
!!wizard.datosBasicos.facultadId &&
!!wizard.datosBasicos.carrera.id &&
!!wizard.datosBasicos.facultad.id &&
!!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos !== undefined &&
wizard.datosBasicos.numCiclos !== null &&
wizard.datosBasicos.numCiclos > 0 &&
// Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.estructuraPlanId
@@ -65,7 +65,7 @@ export function useNuevoPlanWizard() {
if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.tipoOrigen === 'IA') {
// Requerimos descripción del enfoque y notas adicionales
return !!wizard.iaConfig?.descripcionEnfoque
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return !!wizard.clonInterno?.planOrigenId

View File

@@ -19,11 +19,17 @@ export type NewPlanWizardState = {
tipoOrigen: TipoOrigen | null
datosBasicos: {
nombrePlan: string
carreraId: string
facultadId: string
facultad: {
id: string
nombre: string
}
carrera: {
id: string
nombre: string
}
nivel: NivelPlanEstudio | ''
tipoCiclo: TipoCiclo | ''
numCiclos: number | undefined
numCiclos: number | null
// Selección de plantillas (obligatorias)
estructuraPlanId: string | null
}
@@ -49,8 +55,8 @@ export type NewPlanWizardState = {
} | null
}
iaConfig?: {
descripcionEnfoque: string
notasAdicionales?: string
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA?: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>

View File

@@ -28,6 +28,9 @@ declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
interface HistoryState {
showConfetti?: boolean
}
}
// Render the app

View File

@@ -12,23 +12,27 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PlanesListaRouteImport } from './routes/planes/_lista'
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
import { Route as PlanesPlanIdIndexRouteImport } from './routes/planes/$planId/index'
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route'
import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route'
import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index'
import { Route as PlanesPlanIdDetalleMateriasRouteImport } from './routes/planes/$planId/_detalle/materias'
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleDatosRouteImport } from './routes/planes/$planId/_detalle/datos'
import { Route as PlanesPlanIdAsignaturasListaRouteRouteImport } from './routes/planes/$planId/asignaturas/_lista/route'
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
import { Route as PlanesPlanIdAsignaturasListaNuevaRouteImport } from './routes/planes/$planId/asignaturas/_lista/nueva'
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 PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@@ -45,169 +49,203 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesListaRoute = PlanesListaRouteImport.update({
id: '/planes/_lista',
path: '/planes',
getParentRoute: () => rootRouteImport,
} as any)
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
id: '/demo/tanstack-query',
path: '/demo/tanstack-query',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesListaRouteRoute = PlanesListaRouteRouteImport.update({
id: '/planes/_lista',
path: '/planes',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdIndexRoute = PlanesPlanIdIndexRouteImport.update({
id: '/planes/$planId/',
path: '/planes/$planId/',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
id: '/nuevo',
path: '/nuevo',
getParentRoute: () => PlanesListaRouteRoute,
getParentRoute: () => PlanesListaRoute,
} as any)
const PlanesPlanIdAsignaturasRouteRoute =
PlanesPlanIdAsignaturasRouteRouteImport.update({
id: '/planes/$planId/asignaturas',
path: '/planes/$planId/asignaturas',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleRouteRoute =
PlanesPlanIdDetalleRouteRouteImport.update({
id: '/planes/$planId/_detalle',
path: '/planes/$planId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasIndexRoute =
PlanesPlanIdAsignaturasIndexRouteImport.update({
const PlanesPlanIdDetalleRoute = PlanesPlanIdDetalleRouteImport.update({
id: '/planes/$planId/_detalle',
path: '/planes/$planId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleIndexRoute =
PlanesPlanIdDetalleIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
} as any)
const PlanesPlanIdDetalleMateriasRoute =
PlanesPlanIdDetalleMateriasRouteImport.update({
id: '/materias',
path: '/materias',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
id: '/mapa',
path: '/mapa',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleIaplanRoute =
PlanesPlanIdDetalleIaplanRouteImport.update({
id: '/iaplan',
path: '/iaplan',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleHistorialRoute =
PlanesPlanIdDetalleHistorialRouteImport.update({
id: '/historial',
path: '/historial',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleFlujoRoute =
PlanesPlanIdDetalleFlujoRouteImport.update({
id: '/flujo',
path: '/flujo',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleDocumentoRoute =
PlanesPlanIdDetalleDocumentoRouteImport.update({
id: '/documento',
path: '/documento',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleDatosRoute =
PlanesPlanIdDetalleDatosRouteImport.update({
id: '/datos',
path: '/datos',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdAsignaturasListaRouteRoute =
PlanesPlanIdAsignaturasListaRouteRouteImport.update({
id: '/_lista',
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
const PlanesPlanIdDetalleAsignaturasRoute =
PlanesPlanIdDetalleAsignaturasRouteImport.update({
id: '/asignaturas',
path: '/asignaturas',
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
id: '/$asignaturaId',
path: '/$asignaturaId',
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasListaNuevaRoute =
PlanesPlanIdAsignaturasListaNuevaRouteImport.update({
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 =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
id: '/nueva',
path: '/nueva',
getParentRoute: () => PlanesPlanIdAsignaturasListaRouteRoute,
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRouteImport.update({
id: '/nueva',
path: '/nueva',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/materias': typeof PlanesPlanIdDetalleMateriasRoute
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
'/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
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
<<<<<<< HEAD
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
=======
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
'/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes': typeof PlanesListaRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/materias': typeof PlanesPlanIdDetalleMateriasRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/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
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
'/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/$planId/_detalle/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/_detalle/materias': typeof PlanesPlanIdDetalleMateriasRoute
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
'/planes/$planId/asignaturas/_lista/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
'/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
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -215,74 +253,86 @@ export interface FileRouteTypes {
| '/'
| '/dashboard'
| '/login'
| '/planes'
| '/demo/tanstack-query'
| '/planes'
| '/planes/$planId'
| '/planes/$planId/asignaturas'
| '/planes/nuevo'
| '/planes/$planId/'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/datos'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento'
| '/planes/$planId/flujo'
| '/planes/$planId/historial'
| '/planes/$planId/iaplan'
| '/planes/$planId/mapa'
| '/planes/$planId/materias'
| '/planes/$planId/asignaturas/'
| '/planes/$planId/'
| '/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/'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard'
| '/login'
| '/planes'
| '/demo/tanstack-query'
| '/planes/$planId'
| '/planes'
| '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas'
| '/planes/$planId/datos'
| '/planes/$planId/documento'
| '/planes/$planId/flujo'
| '/planes/$planId/historial'
| '/planes/$planId/iaplan'
| '/planes/$planId/mapa'
| '/planes/$planId/materias'
| '/planes/$planId'
| '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
id:
| '__root__'
| '/'
| '/dashboard'
| '/login'
| '/planes/_lista'
| '/demo/tanstack-query'
| '/planes/_lista'
| '/planes/$planId/_detalle'
| '/planes/$planId/asignaturas'
| '/planes/_lista/nuevo'
| '/planes/$planId/'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas/_lista'
| '/planes/$planId/_detalle/datos'
| '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial'
| '/planes/$planId/_detalle/iaplan'
| '/planes/$planId/_detalle/mapa'
| '/planes/$planId/_detalle/materias'
| '/planes/$planId/asignaturas/'
| '/planes/$planId/asignaturas/_lista/nueva'
| '/planes/$planId/_detalle/'
| '/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/'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
PlanesPlanIdIndexRoute: typeof PlanesPlanIdIndexRoute
PlanesListaRoute: typeof PlanesListaRouteWithChildren
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
@@ -308,6 +358,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista': {
id: '/planes/_lista'
path: '/planes'
fullPath: '/planes'
preLoaderRoute: typeof PlanesListaRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/tanstack-query': {
id: '/demo/tanstack-query'
path: '/demo/tanstack-query'
@@ -315,207 +372,250 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DemoTanstackQueryRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista': {
id: '/planes/_lista'
path: '/planes'
fullPath: '/planes'
preLoaderRoute: typeof PlanesListaRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/': {
id: '/planes/$planId/'
path: '/planes/$planId'
<<<<<<< HEAD
fullPath: '/planes/$planId/'
=======
fullPath: '/planes/$planId'
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista/nuevo': {
id: '/planes/_lista/nuevo'
path: '/nuevo'
fullPath: '/planes/nuevo'
preLoaderRoute: typeof PlanesListaNuevoRouteImport
parentRoute: typeof PlanesListaRouteRoute
}
'/planes/$planId/asignaturas': {
id: '/planes/$planId/asignaturas'
path: '/planes/$planId/asignaturas'
fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdAsignaturasRouteRouteImport
parentRoute: typeof rootRouteImport
parentRoute: typeof PlanesListaRoute
}
'/planes/$planId/_detalle': {
id: '/planes/$planId/_detalle'
path: '/planes/$planId'
fullPath: '/planes/$planId'
preLoaderRoute: typeof PlanesPlanIdDetalleRouteRouteImport
preLoaderRoute: typeof PlanesPlanIdDetalleRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/': {
id: '/planes/$planId/asignaturas/'
'/planes/$planId/_detalle/': {
id: '/planes/$planId/_detalle/'
path: '/'
fullPath: '/planes/$planId/asignaturas/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
}
'/planes/$planId/_detalle/materias': {
id: '/planes/$planId/_detalle/materias'
path: '/materias'
fullPath: '/planes/$planId/materias'
preLoaderRoute: typeof PlanesPlanIdDetalleMateriasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
fullPath: '/planes/$planId/'
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/mapa': {
id: '/planes/$planId/_detalle/mapa'
path: '/mapa'
fullPath: '/planes/$planId/mapa'
preLoaderRoute: typeof PlanesPlanIdDetalleMapaRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/iaplan': {
id: '/planes/$planId/_detalle/iaplan'
path: '/iaplan'
fullPath: '/planes/$planId/iaplan'
preLoaderRoute: typeof PlanesPlanIdDetalleIaplanRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/historial': {
id: '/planes/$planId/_detalle/historial'
path: '/historial'
fullPath: '/planes/$planId/historial'
preLoaderRoute: typeof PlanesPlanIdDetalleHistorialRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/flujo': {
id: '/planes/$planId/_detalle/flujo'
path: '/flujo'
fullPath: '/planes/$planId/flujo'
preLoaderRoute: typeof PlanesPlanIdDetalleFlujoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/documento': {
id: '/planes/$planId/_detalle/documento'
path: '/documento'
fullPath: '/planes/$planId/documento'
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/datos': {
id: '/planes/$planId/_detalle/datos'
path: '/datos'
fullPath: '/planes/$planId/datos'
preLoaderRoute: typeof PlanesPlanIdDetalleDatosRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/asignaturas/_lista': {
id: '/planes/$planId/asignaturas/_lista'
path: ''
'/planes/$planId/_detalle/asignaturas': {
id: '/planes/$planId/_detalle/asignaturas'
path: '/asignaturas'
fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/_lista/nueva': {
id: '/planes/$planId/asignaturas/_lista/nueva'
'/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': {
id: '/planes/$planId/_detalle/asignaturas/nueva'
path: '/nueva'
fullPath: '/planes/$planId/asignaturas/nueva'
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaNuevaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasListaRouteRoute
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
path: '/'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
path: '/nueva'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
}
}
}
interface PlanesListaRouteRouteChildren {
interface PlanesListaRouteChildren {
PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute
}
const PlanesListaRouteRouteChildren: PlanesListaRouteRouteChildren = {
const PlanesListaRouteChildren: PlanesListaRouteChildren = {
PlanesListaNuevoRoute: PlanesListaNuevoRoute,
}
const PlanesListaRouteRouteWithChildren =
PlanesListaRouteRoute._addFileChildren(PlanesListaRouteRouteChildren)
const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
PlanesListaRouteChildren,
)
interface PlanesPlanIdDetalleRouteRouteChildren {
PlanesPlanIdDetalleDatosRoute: typeof PlanesPlanIdDetalleDatosRoute
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
}
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
{
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
}
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
PlanesPlanIdDetalleAsignaturasRouteChildren,
)
interface PlanesPlanIdDetalleRouteChildren {
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
PlanesPlanIdDetalleMateriasRoute: typeof PlanesPlanIdDetalleMateriasRoute
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
}
const PlanesPlanIdDetalleRouteRouteChildren: PlanesPlanIdDetalleRouteRouteChildren =
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
PlanesPlanIdDetalleAsignaturasRoute:
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
}
const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
interface PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren =
{
PlanesPlanIdDetalleDatosRoute: PlanesPlanIdDetalleDatosRoute,
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
PlanesPlanIdDetalleMateriasRoute: PlanesPlanIdDetalleMateriasRoute,
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute,
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute,
}
const PlanesPlanIdDetalleRouteRouteWithChildren =
PlanesPlanIdDetalleRouteRoute._addFileChildren(
PlanesPlanIdDetalleRouteRouteChildren,
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren,
)
interface PlanesPlanIdAsignaturasListaRouteRouteChildren {
PlanesPlanIdAsignaturasListaNuevaRoute: typeof PlanesPlanIdAsignaturasListaNuevaRoute
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
}
const PlanesPlanIdAsignaturasListaRouteRouteChildren: PlanesPlanIdAsignaturasListaRouteRouteChildren =
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
{
PlanesPlanIdAsignaturasListaNuevaRoute:
PlanesPlanIdAsignaturasListaNuevaRoute,
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
}
const PlanesPlanIdAsignaturasListaRouteRouteWithChildren =
PlanesPlanIdAsignaturasListaRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasListaRouteRouteChildren,
)
interface PlanesPlanIdAsignaturasRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
PlanesPlanIdAsignaturasListaRouteRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
PlanesPlanIdAsignaturasIndexRoute: typeof PlanesPlanIdAsignaturasIndexRoute
}
const PlanesPlanIdAsignaturasRouteRouteChildren: PlanesPlanIdAsignaturasRouteRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
PlanesPlanIdAsignaturasListaRouteRoute:
PlanesPlanIdAsignaturasListaRouteRouteWithChildren,
PlanesPlanIdAsignaturasIndexRoute: PlanesPlanIdAsignaturasIndexRoute,
}
const PlanesPlanIdAsignaturasRouteRouteWithChildren =
PlanesPlanIdAsignaturasRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasRouteRouteChildren,
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
PlanesPlanIdAsignaturasRouteRoute:
PlanesPlanIdAsignaturasRouteRouteWithChildren,
PlanesPlanIdIndexRoute: PlanesPlanIdIndexRoute,
PlanesListaRoute: PlanesListaRouteWithChildren,
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -7,6 +7,8 @@ import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import type { QueryClient } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
interface MyRouterContext {
queryClient: QueryClient
}
@@ -31,6 +33,8 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
</>
),
notFoundComponent: () => <NotFoundPage />,
errorComponent: ({ error, reset }) => {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center space-y-4 p-6 text-center">

View File

@@ -1,32 +1,56 @@
import { createFileRoute, Outlet, Link } from '@tanstack/react-router'
import { createFileRoute, Outlet, Link, notFound } from '@tanstack/react-router'
import {
ChevronLeft,
GraduationCap,
Clock,
Hash,
CalendarDays,
Save,
} from 'lucide-react'
import { useState, useEffect } from 'react'
import { useState, useEffect, forwardRef } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { Skeleton } from '@/components/ui/skeleton'
import { usePlan } from '@/data/hooks/usePlans'
import { plans_get } from '@/data/api/plans.api'
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys'
export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => {
try {
await queryClient.ensureQueryData({
queryKey: qk.plan(planId),
queryFn: () => plans_get(planId),
})
} catch (e: any) {
// PGRST116: The result contains 0 rows
if (e?.code === 'PGRST116') {
throw notFound()
}
throw e
}
},
notFoundComponent: () => {
return (
<NotFoundPage
title="Plan de Estudios no encontrado"
message="El plan de estudios que intentas consultar no existe o no tienes permisos para verlo."
/>
)
},
component: RouteComponent,
})
function RouteComponent() {
const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId)
const { mutate } = useUpdatePlanFields()
// Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('')
@@ -48,32 +72,49 @@ function RouteComponent() {
'Especialidad',
]
const handleKeyDown = (e: React.KeyboardEvent) => {
const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
// 1. Permitir teclas de control (Borrar, flechas, etc.) siempre
const isControlKey =
e.key === 'Backspace' ||
e.key === 'Delete' ||
e.key.includes('Arrow') ||
e.metaKey ||
e.ctrlKey
if (e.key === 'Enter') {
e.preventDefault() // Evita el salto de línea
e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado
e.preventDefault()
e.currentTarget.blur()
return
}
// 2. Bloquear si excede los 200 caracteres y no es una tecla de control
const currentText = e.currentTarget.textContent || ''
if (currentText.length >= MAX_CHARACTERS && !isControlKey) {
e.preventDefault()
}
}
const handleSave = () => {
console.log('Guardando en DB...', { nombrePlan, nivelPlan })
// Aquí iría tu mutation
setIsDirty(false)
}
const handlePaste = (e: React.ClipboardEvent<HTMLSpanElement>) => {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
const currentText = e.currentTarget.textContent || ''
// Calcular cuánto espacio queda
const remainingSpace = MAX_CHARACTERS - currentText.length
if (remainingSpace > 0) {
const slicedText = text.slice(0, remainingSpace)
document.execCommand('insertText', false, slicedText)
}
}
return (
<div className="min-h-screen bg-white">
{/* Botón Flotante de Guardar */}
{isDirty && (
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-8 bottom-8 z-50 duration-300">
<Button
onClick={handleSave}
className="gap-2 rounded-full bg-teal-600 px-6 shadow-xl hover:bg-teal-700"
>
<Save size={16} /> Guardar cambios del Plan
</Button>
</div>
)}
{/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2">
@@ -86,66 +127,62 @@ function RouteComponent() {
</div>
</div>
<div className="mx-auto max-w-[1600px] space-y-8 p-8">
{/* Header del Plan */}
<div className="mx-auto max-w-400 space-y-8 p-8">
{/* 2. Header del Plan */}
{isLoading ? (
/* ===== SKELETON ===== */
<div className="mx-auto max-w-[1600px] p-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<DatosGeneralesSkeleton key={i} />
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<DatosGeneralesSkeleton key={i} />
))}
</div>
) : (
<>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
<span>{nivelPlan} en</span>
<span
role="textbox"
tabIndex={0}
contentEditable
suppressContentEditableWarning
spellCheck={false} // Quita el subrayado rojo de error ortográfico
onKeyDown={handleKeyDown}
onBlur={(e) =>
setNombrePlan(e.currentTarget.textContent || '')
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
{/* El prefijo "Nivel en" lo mantenemos simple */}
<span className="shrink-0">{nivelPlan} en</span>
<span
role="textbox"
tabIndex={0}
contentEditable
suppressContentEditableWarning
spellCheck={false}
onKeyDown={handleKeyDown}
onPaste={handlePaste} // Añadido para controlar lo que pegan
onBlur={(e) => {
const nuevoNombre =
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } })
}
className="cursor-text border-b border-transparent decoration-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500"
style={{
WebkitTextDecoration: 'none',
textDecoration: 'none',
}} // Doble seguridad contra subrayados
>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
<div className="flex gap-2">
{/* <Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
</Badge> */}
<Badge
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
}}
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
style={{ textDecoration: 'none' }}
>
{data?.estados_plan?.etiqueta}
</Badge>
</div>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
</>
<div className="flex gap-2">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
{data?.estados_plan?.etiqueta}
</Badge>
</div>
</div>
)}
{/* 3. Cards de Información con Context Menu */}
{/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger asChild>
<InfoCard
icon={<GraduationCap className="text-slate-400" />}
label="Nivel"
@@ -160,7 +197,9 @@ function RouteComponent() {
key={n}
onClick={() => {
setNivelPlan(n)
setIsDirty(true)
if (n !== data?.nivel) {
mutate({ planId, patch: { nivel: n } })
}
}}
>
{n}
@@ -182,21 +221,21 @@ function RouteComponent() {
<InfoCard
icon={<CalendarDays className="text-slate-400" />}
label="Creación"
value={data?.creado_en?.split('T')[0]} // Cortamos la fecha para que no sea tan larga
value={data?.creado_en?.split('T')[0]}
/>
</div>
{/* 4. Navegación de Tabs */}
<div className="scrollbar-hide overflow-x-auto border-b">
<nav className="flex min-w-max gap-8">
<Tab to="/planes/$planId/datos" params={{ planId }}>
<Tab to="/planes/$planId/" params={{ planId }}>
Datos Generales
</Tab>
<Tab to="/planes/$planId/mapa" params={{ planId }}>
Mapa Curricular
</Tab>
<Tab to="/planes/$planId/materias" params={{ planId }}>
Materias
<Tab to="/planes/$planId/asignaturas" params={{ planId }}>
Asignaturas
</Tab>
<Tab to="/planes/$planId/flujo" params={{ planId }}>
Flujo y Estados
@@ -221,31 +260,32 @@ function RouteComponent() {
)
}
function InfoCard({
icon,
label,
value,
isEditable,
}: {
icon: React.ReactNode
label: string
value: string | number | undefined
isEditable?: boolean
}) {
const InfoCard = forwardRef<
HTMLDivElement,
{
icon: React.ReactNode
label: string
value: string | number | undefined
isEditable?: boolean
} & React.HTMLAttributes<HTMLDivElement>
>(function InfoCard(
{ icon, label, value, isEditable, className, ...props },
ref,
) {
return (
<div
className={`flex h-[72px] w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
ref={ref}
{...props}
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
isEditable
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
: ''
}`}
} ${className ?? ''}`}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
{icon}
</div>
<div className="min-w-0 flex-1">
{' '}
{/* min-w-0 es vital para que el truncate funcione en flex */}
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{label}
</p>
@@ -255,7 +295,7 @@ function InfoCard({
</div>
</div>
)
}
})
function Tab({
to,
@@ -264,6 +304,7 @@ function Tab({
}: {
to: string
params?: any
search?: any
children: React.ReactNode
}) {
return (
@@ -272,6 +313,9 @@ function Tab({
params={params}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
activeOptions={{
exact: true,
}}
>
{children}
</Link>

View File

@@ -1,137 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { Pencil, X } from 'lucide-react';
export type Materia = {
id: string;
clave: string;
nombre: string;
creditos: number;
hd: number; // Horas Docente
hi: number; // Horas Independientes
tipo: 'Obligatoria' | 'Optativa' | 'Especialidad';
ciclo: number;
linea: string;
estado: string;
};
interface MateriaCardProps {
materia: Materia;
}
export function MateriaCard({ materia }: MateriaCardProps) {
return (
<Dialog.Root>
{/* Trigger: La tarjeta en sí misma */}
<Dialog.Trigger asChild>
<div className="group relative flex flex-col p-2 mb-2 rounded-lg border border-slate-200 bg-white hover:border-emerald-500 hover:shadow-md transition-all cursor-pointer select-none">
{/* Header de la tarjeta */}
<div className="flex justify-between items-start mb-1">
<span className="text-[9px] font-mono font-bold text-slate-400 uppercase">{materia.clave}</span>
<div className="flex gap-1">
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[8px] font-bold uppercase">
{materia.tipo === 'Obligatoria' ? 'OB' : 'OP'}
</span>
</div>
</div>
{/* Nombre */}
<h4 className="text-[11px] font-semibold text-slate-800 leading-tight mb-2 min-h-[2rem]">
{materia.nombre}
</h4>
{/* Footer de la tarjeta (Créditos y Horas) */}
<div className="flex justify-between items-center text-[9px] text-slate-500 border-t pt-1 border-slate-50">
<span>{materia.creditos} cr</span>
<div className="flex gap-1">
<span>HD:{materia.hd}</span>
<span>HI:{materia.hi}</span>
</div>
</div>
{/* Overlay de Hover (Opcional: un iconito de editar) */}
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="w-3 h-3 text-emerald-600" />
</div>
</div>
</Dialog.Trigger>
{/* Modal / Portal */}
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 animate-in fade-in" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-2xl p-6 z-50 border border-slate-200 animate-in zoom-in-95">
<div className="flex justify-between items-center mb-6">
<Dialog.Title className="text-lg font-bold text-slate-800">Editar Materia</Dialog.Title>
<Dialog.Close className="text-slate-400 hover:text-slate-600 transition-colors">
<X className="w-5 h-5" />
</Dialog.Close>
</div>
<form className="space-y-4">
{/* Clave y Nombre */}
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Clave</label>
<input
defaultValue={materia.clave}
className="px-3 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none text-sm font-mono"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Nombre</label>
<input
defaultValue={materia.nombre}
className="px-3 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none text-sm"
/>
</div>
</div>
{/* Créditos y Horas */}
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase italic">Créditos</label>
<input type="number" defaultValue={materia.creditos} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase italic">HD (Hrs Docente)</label>
<input type="number" defaultValue={materia.hd} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase italic">HI (Hrs Indep.)</label>
<input type="number" defaultValue={materia.hi} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
</div>
</div>
{/* Ciclo y Línea */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Ciclo</label>
<select className="px-3 py-2 rounded-lg border border-slate-300 text-sm bg-white">
<option>Ciclo {materia.ciclo}</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Línea Curricular</label>
<select className="px-3 py-2 rounded-lg border border-slate-300 text-sm bg-white">
<option>{materia.linea}</option>
</select>
</div>
</div>
{/* Botones de acción */}
<div className="flex justify-end gap-3 pt-6">
<Dialog.Close className="px-4 py-2 rounded-lg text-sm font-semibold text-slate-600 hover:bg-slate-100 transition-colors">
Cancelar
</Dialog.Close>
<button
type="button"
className="px-6 py-2 rounded-lg text-sm font-semibold bg-emerald-700 text-white hover:bg-emerald-800 transition-colors shadow-sm"
>
Guardar
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,16 +1,16 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import {
Plus,
Copy,
Search,
Filter,
ChevronRight,
BookOpen,
Loader2,
} from 'lucide-react'
import { useState, useMemo } from 'react'
import { useMemo, useState } from 'react'
import type { Materia } from '@/types/plan'
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan'
import type { Tables } from '@/types/supabase'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -33,45 +33,60 @@ import {
import { usePlanAsignaturas, usePlanLineas } from '@/data'
// --- Configuración de Estilos ---
const statusConfig: Record<string, { label: string; className: string }> = {
const statusConfig: Record<
AsignaturaStatus,
{ 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' },
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
}
const tipoConfig: Record<string, { label: string; className: string }> = {
obligatoria: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700' },
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
}
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> =
{
OBLIGATORIA: {
label: 'Obligatoria',
className: 'bg-blue-100 text-blue-700',
},
OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
}
// --- Mapeadores de API ---
const mapAsignaturas = (asigApi: Array<any> = []): Array<Materia> => {
const mapAsignaturas = (
asigApi: Array<Tables<'asignaturas'>> = [],
): Array<Asignatura> => {
return asigApi.map((asig) => ({
id: asig.id,
clave: asig.codigo,
clave: asig.codigo ?? '',
nombre: asig.nombre,
creditos: asig.creditos ?? 0,
creditos: asig.creditos,
ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null,
tipo:
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
estado: 'borrador', // O el campo que venga de tu API
hd: Math.floor((asig.horas_semana ?? 0) / 2),
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
tipo: asig.tipo,
estado: asig.estado,
hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisitos: [],
}))
}
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
component: MateriasPage,
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
component: AsignaturasPage,
})
function MateriasPage() {
function AsignaturasPage() {
const { planId } = Route.useParams()
const navigate = useNavigate()
// 1. Fetch de datos reales
const { data: asignaturasApi, isLoading: loadingAsig } =
const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
@@ -82,13 +97,13 @@ function MateriasPage() {
const [filterLinea, setFilterLinea] = useState<string>('all')
// 3. Procesamiento de datos
const materias = useMemo(
() => mapAsignaturas(asignaturasApi),
[asignaturasApi],
const asignaturas = useMemo(
() => mapAsignaturas(asignaturaApi),
[asignaturaApi],
)
const lineas = useMemo(() => lineasApi || [], [lineasApi])
const filteredMaterias = materias.filter((m) => {
const filteredAsignaturas = asignaturas.filter((m) => {
const matchesSearch =
m.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.clave.toLowerCase().includes(searchTerm.toLowerCase())
@@ -119,27 +134,34 @@ function MateriasPage() {
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-foreground text-xl font-bold">
Materias del Plan
Asignaturas del Plan
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{materias.length} materias en total {filteredMaterias.length}{' '}
filtradas
{asignaturas.length} asignaturas en total {' '}
{filteredAsignaturas.length} filtradas
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Copy className="mr-2 h-4 w-4" /> Clonar
</Button>
<Button className="bg-emerald-700 hover:bg-emerald-800">
<Plus className="mr-2 h-4 w-4" /> Nueva Materia
<Button
onClick={() => {
console.log('planId desde asignaturas', planId)
navigate({
to: `/planes/${planId}/asignaturas/nueva`,
resetScroll: false,
})
}}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
>
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
</Button>
</div>
</div>
{/* Barra de Filtros Avanzada */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
<div className="relative min-w-[240px] flex-1">
<div className="relative min-w-60 flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Buscar por nombre o clave..."
@@ -153,7 +175,7 @@ function MateriasPage() {
<Filter className="text-muted-foreground mr-1 h-4 w-4" />
<Select value={filterTipo} onValueChange={setFilterTipo}>
<SelectTrigger className="w-[140px] bg-white">
<SelectTrigger className="w-35 bg-white">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
@@ -164,7 +186,7 @@ function MateriasPage() {
</Select>
<Select value={filterEstado} onValueChange={setFilterEstado}>
<SelectTrigger className="w-[140px] bg-white">
<SelectTrigger className="w-35 bg-white">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
@@ -176,7 +198,7 @@ function MateriasPage() {
</Select>
<Select value={filterLinea} onValueChange={setFilterLinea}>
<SelectTrigger className="w-[180px] bg-white">
<SelectTrigger className="w-45 bg-white">
<SelectValue placeholder="Línea" />
</SelectTrigger>
<SelectContent>
@@ -196,23 +218,23 @@ function MateriasPage() {
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="w-[120px]">Clave</TableHead>
<TableHead className="w-30">Clave</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-12.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMaterias.length === 0 ? (
{filteredAsignaturas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron materias</p>
<p className="font-medium">No se encontraron asignaturas</p>
<p className="text-xs">
Intenta cambiar los filtros de búsqueda
</p>
@@ -220,59 +242,59 @@ function MateriasPage() {
</TableCell>
</TableRow>
) : (
filteredMaterias.map((materia) => (
filteredAsignaturas.map((asignatura) => (
<TableRow
key={materia.id}
key={asignatura.id}
className="group cursor-pointer transition-colors hover:bg-slate-50/80"
onClick={() =>
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId',
params: {
planId,
asignaturaId: materia.id, // 👈 puede ser índice, consecutivo o slug
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
},
state: {
realId: materia.id, // 👈 ID largo oculto
asignaturaId: materia.id,
realId: asignatura.id, // 👈 ID largo oculto
asignaturaId: asignatura.id,
} as any,
})
}
>
<TableCell className="font-mono text-xs font-bold text-slate-400">
{materia.clave}
{asignatura.clave}
</TableCell>
<TableCell className="font-semibold text-slate-700">
{materia.nombre}
{asignatura.nombre}
</TableCell>
<TableCell className="text-center font-medium">
{materia.creditos}
{asignatura.creditos}
</TableCell>
<TableCell className="text-center">
{materia.ciclo ? (
{asignatura.ciclo ? (
<Badge variant="outline" className="font-normal">
Ciclo {materia.ciclo}
Ciclo {asignatura.ciclo}
</Badge>
) : (
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="text-sm text-slate-600">
{getLineaNombre(materia.lineaCurricularId)}
{getLineaNombre(asignatura.lineaCurricularId)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`capitalize shadow-sm ${tipoConfig[materia.tipo]?.className}`}
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
>
{tipoConfig[materia.tipo]?.label}
{tipoConfig[asignatura.tipo].label}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`capitalize shadow-sm ${statusConfig[materia.estado]?.className}`}
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
>
{statusConfig[materia.estado]?.label}
{statusConfig[asignatura.estado].label}
</Badge>
</TableCell>
<TableCell>
@@ -286,6 +308,7 @@ function MateriasPage() {
</TableBody>
</Table>
</div>
<Outlet />
</div>
)
}

View File

@@ -3,12 +3,13 @@ import { createFileRoute } from '@tanstack/react-router'
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/nueva/NuevaAsignaturaModalContainer'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/_lista/nueva',
'/planes/$planId/_detalle/asignaturas/nueva',
)({
component: NuevaAsignaturaModal,
})
function NuevaAsignaturaModal() {
const { planId } = Route.useParams()
console.log('planId desde nueva', planId)
return <NuevaAsignaturaModalContainer planId={planId} />
}

View File

@@ -1,198 +0,0 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
import { useState, useEffect } from 'react'
import type { DatosGeneralesField } from '@/types/plan'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { usePlan } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
component: DatosGeneralesPage,
})
const formatLabel = (key: string) => {
const result = key.replace(/_/g, ' ')
return result.charAt(0).toUpperCase() + result.slice(1)
}
function DatosGeneralesPage() {
const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId)
const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => {
// 2. Validación de seguridad para sourceData
const sourceData = data?.datos
if (sourceData && typeof sourceData === 'object') {
const datosTransformados: Array<DatosGeneralesField> = Object.entries(
sourceData,
).map(([key, value], index) => ({
id: (index + 1).toString(),
label: formatLabel(key),
// Forzamos el valor a string de forma segura
value: typeof value === 'string' ? value : value?.toString() || '',
requerido: true,
tipo: 'texto',
}))
setCampos(datosTransformados)
}
console.log(data)
}, [data])
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (campo: DatosGeneralesField) => {
setEditingId(campo.id)
setEditValue(campo.value)
}
const handleCancel = () => {
setEditingId(null)
setEditValue('')
}
const handleSave = (id: string) => {
// Actualizamos el estado local de la lista
setCampos((prev) =>
prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
)
setEditingId(null)
setEditValue('')
// toast.success('Cambios guardados localmente')
}
const handleIARequest = (descripcion: string) => {
navigate({
to: '/planes/$planId/iaplan',
params: {
planId: planId, // o dinámico
},
state: {
prefill: descripcion,
} as any,
})
}
return (
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
<div className="mb-6">
<h2 className="text-foreground text-lg font-semibold">
Datos Generales del Plan
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Información estructural y descriptiva del plan de estudios
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{campos.map((campo) => {
const isEditing = editingId === campo.id
return (
<div
key={campo.id}
className={`rounded-xl border transition-all ${
isEditing
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
: 'bg-white hover:shadow-md'
}`}
>
{/* Header de la Card */}
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-slate-700">
{campo.label}
</h3>
{campo.requerido && (
<span className="text-xs text-red-500">*</span>
)}
</div>
{!isEditing && (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-teal-600"
onClick={() => handleIARequest(campo.value)}
>
<Sparkles size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(campo)}
>
<Pencil size={14} />
</Button>
</div>
)}
</div>
{/* Contenido de la Card */}
<div className="p-5">
{isEditing ? (
<div className="space-y-3">
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="min-h-[120px]"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
>
<X size={14} className="mr-1" /> Cancelar
</Button>
<Button
size="sm"
className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo.id)}
>
<Check size={14} className="mr-1" /> Guardar
</Button>
</div>
</div>
) : (
<div className="min-h-[100px]">
{campo.value ? (
<div className="text-sm leading-relaxed text-slate-600">
{campo.tipo === 'lista' ? (
<ul className="space-y-1">
{campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
{item}
</li>
))}
</ul>
) : (
<p className="whitespace-pre-wrap">{campo.value}</p>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-slate-400">
<AlertCircle size={14} />
<span>Sin contenido.</span>
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -8,6 +8,7 @@ import {
Clock,
FileJson,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -19,9 +20,34 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const handleDownloadPdf = async () => {
console.log('entre aqui ')
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
setPdfUrl(url)
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
setIsLoading(false)
}
}, [planId])
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
}
}, [loadPdfPreview])
const handleDownloadPdf = async () => {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
@@ -54,7 +80,12 @@ function RouteComponent() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={loadPdfPreview}
>
<RefreshCcw size={16} /> Regenerar
</Button>
<Button variant="outline" size="sm" className="gap-2">
@@ -90,70 +121,42 @@ function RouteComponent() {
</div>
{/* CONTENEDOR DEL DOCUMENTO (Visor) */}
{/* CONTENEDOR DEL VISOR REAL */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="flex items-center justify-between border-b bg-slate-100/50 p-2 px-4">
<div className="flex items-center gap-2 text-xs font-medium text-slate-500">
<FileText size={14} />
Plan_Estudios_ISC_2024.pdf
<FileText size={14} /> Preview_Documento.pdf
</div>
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs">
Abrir en nueva pestaña <ExternalLink size={12} />
</Button>
{pdfUrl && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => window.open(pdfUrl, '_blank')}
>
Abrir en nueva pestaña <ExternalLink size={12} />
</Button>
)}
</div>
<CardContent className="flex min-h-[800px] justify-center bg-slate-200/50 p-0 py-8">
{/* SIMULACIÓN DE HOJA DE PAPEL */}
<div className="relative min-h-[1000px] w-full max-w-[800px] border bg-white p-12 shadow-2xl md:p-16">
{/* Contenido del Plan */}
<div className="mb-12 text-center">
<p className="mb-1 text-xs font-bold tracking-widest text-slate-400 uppercase">
Universidad Tecnológica
</p>
<h2 className="text-2xl font-bold text-slate-800">
Plan de Estudios 2024
</h2>
<h3 className="text-lg font-semibold text-teal-700">
Ingeniería en Sistemas Computacionales
</h3>
<p className="mt-1 text-xs text-slate-500">
Facultad de Ingeniería
</p>
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" />
<p className="animate-pulse">Generando vista previa del PDF...</p>
</div>
<div className="space-y-8 text-slate-700">
<section>
<h4 className="mb-2 text-sm font-bold">1. Objetivo General</h4>
<p className="text-justify text-sm leading-relaxed">
Formar profesionales altamente capacitados en el desarrollo de
soluciones tecnológicas innovadoras, con sólidos conocimientos
en programación, bases de datos, redes y seguridad
informática.
</p>
</section>
<section>
<h4 className="mb-2 text-sm font-bold">2. Perfil de Ingreso</h4>
<p className="text-justify text-sm leading-relaxed">
Egresados de educación media superior con conocimientos
básicos de matemáticas, razonamiento lógico y habilidades de
comunicación. Interés por la tecnología y la resolución de
problemas.
</p>
</section>
<section>
<h4 className="mb-2 text-sm font-bold">3. Perfil de Egreso</h4>
<p className="text-justify text-sm leading-relaxed">
Profesional capaz de diseñar, desarrollar e implementar
sistemas de software de calidad, administrar infraestructuras
de red y liderar proyectos tecnológicos multidisciplinarios.
</p>
</section>
) : pdfUrl ? (
/* 3. VISOR DE PDF REAL */
<iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
title="PDF Preview"
/>
) : (
<div className="flex items-center justify-center p-20 text-slate-400">
No se pudo cargar la vista previa.
</div>
{/* Marca de agua o decoración lateral (opcional) */}
<div className="absolute top-0 left-0 h-full w-1 bg-slate-100" />
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,5 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { CheckCircle2, Circle, Clock } from 'lucide-react'
import { CheckCircle2, Clock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -74,7 +75,7 @@ function RouteComponent() {
</div>
<div className="mt-2 w-px flex-1 bg-slate-200" />
</div>
<Card className="flex-1 border-blue-500 bg-blue-50/10">
{/* <Card className="flex-1 border-blue-500 bg-blue-50/10">
<CardHeader className="flex flex-row items-center justify-between py-3">
<div>
<CardTitle className="text-lg text-blue-700">
@@ -97,11 +98,11 @@ function RouteComponent() {
<li>Mapa curricular aprobado preliminarmente</li>
</ul>
</CardContent>
</Card>
</Card> */}
</div>
{/* Estado: Pendiente */}
<div className="relative flex gap-4 pb-4">
{/* <div className="relative flex gap-4 pb-4">
<div className="flex flex-col items-center">
<div className="rounded-full bg-slate-100 p-1 text-slate-400">
<Circle className="h-6 w-6" />
@@ -113,7 +114,7 @@ function RouteComponent() {
<Badge variant="outline">Pendiente</Badge>
</CardHeader>
</Card>
</div>
</div> */}
</div>
{/* LADO DERECHO: Formulario de Transición */}
@@ -145,7 +146,7 @@ function RouteComponent() {
/>
</div>
<Button className="w-full bg-teal-600 hover:bg-teal-700">
<Button className="w-full bg-teal-600 hover:bg-teal-700" disabled>
Avanzar a Revisión Expertos
</Button>
</CardContent>

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { format, formatDistanceToNow, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import {
GitBranch,
Edit3,
@@ -11,20 +12,21 @@ import {
Eye,
History,
Calendar,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { usePlanHistorial } from '@/data/hooks/usePlans'
import { format, formatDistanceToNow, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import { usePlan, usePlanHistorial } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent,
@@ -58,14 +60,23 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() {
const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
// ESTADOS PARA EL MODAL
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 { data } = usePlan(planId)
const [selectedEvent, setSelectedEvent] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
useEffect(() => {
if (data?.estructuras_plan?.definicion?.properties) {
setStructure(data.estructuras_plan.definicion.properties)
}
}, [data])
const historyEvents = useMemo(() => {
if (!rawData) return []
return rawData.map((item: any) => {
@@ -80,10 +91,13 @@ function RouteComponent() {
description:
item.campo === 'datos'
? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}`
: `Se modificó el campo ${item.campo}`,
: `Se modificó el campo ${
structure?.[item.campo]?.title ?? item.campo
}`,
date: parseISO(item.cambiado_en),
icon: config.icon,
campo: item.campo,
campo:
data?.estructuras_plan?.definicion?.properties?.[item.campo]?.title,
details: {
from: item.valor_anterior,
to: item.valor_nuevo,
@@ -218,6 +232,46 @@ function RouteComponent() {
</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>
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
@@ -245,17 +299,20 @@ function RouteComponent() {
<div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
<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="h-2 w-2 rounded-full bg-red-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior
</span>
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
{selectedEvent?.details.from && (
<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="h-2 w-2 rounded-full bg-red-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior
</span>
</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">
{renderValue(selectedEvent.details.from)}
</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">
{renderValue(selectedEvent?.details.from)}
</div>
</div>
)}
{/* Lado Después */}
<div className="flex flex-col space-y-2">
@@ -275,6 +332,11 @@ function RouteComponent() {
<div className="flex justify-center border-t bg-slate-50 p-4">
<Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo}
{console.log(
data?.estructuras_plan?.definicion?.properties?.[
selectedEvent?.campo
]?.title,
)}
</Badge>
</div>
</DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,368 @@
import {
createFileRoute,
useNavigate,
useLocation,
} from '@tanstack/react-router'
// import confetti from 'canvas-confetti'
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
import { useState, useEffect } from 'react'
import type { DatosGeneralesField } from '@/types/plan'
import { Button } from '@/components/ui/button'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { usePlan, useUpdatePlanFields } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/')({
component: DatosGeneralesPage,
})
const formatLabel = (key: string) => {
const result = key.replace(/_/g, ' ')
return result.charAt(0).toUpperCase() + result.slice(1)
}
function DatosGeneralesPage() {
const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId)
const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const location = useLocation()
const updatePlan = useUpdatePlanFields()
// Confetti al llegar desde creación
useEffect(() => {
if (location.state.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
}
}, [location.state])
// Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => {
const definicion = data?.estructuras_plan?.definicion as any
const properties = definicion?.properties
const requiredOrder = definicion?.required as Array<string> | undefined
const valores = (data?.datos as Record<string, unknown>) || {}
if (properties && typeof properties === 'object') {
let keys = Object.keys(properties)
// Ordenar llaves basado en la lista "required" si existe
if (Array.isArray(requiredOrder)) {
keys = keys.sort((a, b) => {
const indexA = requiredOrder.indexOf(a)
const indexB = requiredOrder.indexOf(b)
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
if (indexA !== -1 && indexB === -1) return -1
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
if (indexA === -1 && indexB !== -1) return 1
// Si ambos están, comparar índices
if (indexA !== -1 && indexB !== -1) return indexA - indexB
// Ninguno en la lista, mantener orden relativo
return 0
})
}
const datosTransformados: Array<DatosGeneralesField> = keys.map(
(key, index) => {
const schema = properties[key]
const rawValue = valores[key]
return {
clave: key,
id: (index + 1).toString(),
label: schema?.title || formatLabel(key),
helperText: schema?.description || '',
holder: schema?.examples || '',
value:
rawValue !== undefined && rawValue !== null
? String(rawValue)
: '',
requerido: true,
tipo: Array.isArray(schema?.enum)
? 'select'
: schema?.type === 'number'
? 'number'
: 'texto',
opciones: schema?.enum || [],
}
},
)
setCampos(datosTransformados)
}
}, [data])
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
if (editingId && editingId !== nuevoCampo.id) {
const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue)
}
}
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value)
}
const handleCancel = () => {
setEditingId(null)
setEditValue('')
}
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = (
data: any,
campo: DatosGeneralesField,
valor: string,
) => {
const currentValue = data.datos[campo.clave]
let newValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
newValue = { ...currentValue, description: valor }
} else {
newValue = valor
}
return {
...data.datos,
[campo.clave]: newValue,
}
}
const ejecutarGuardadoSilencioso = (
campo: DatosGeneralesField,
valor: string,
) => {
if (!data?.datos) return
const datosActualizados = prepararDatosActualizados(data, campo, valor)
console.log(datosActualizados)
updatePlan.mutate({
planId,
patch: { datos: datosActualizados },
})
// Actualizar UI localmente
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
)
}
const handleSave = (campo: DatosGeneralesField) => {
if (!data?.datos) return
const currentValue = (data.datos as any)[campo.clave]
let newValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
// Caso 1: objeto con description
newValue = {
...currentValue,
description: editValue,
}
} else {
// Caso 2: valor plano (string, number, etc)
newValue = editValue
}
const datosActualizados = {
...data.datos,
[campo.clave]: newValue,
}
updatePlan.mutate({
planId,
patch: {
datos: datosActualizados,
},
})
// UI optimista
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
)
ejecutarGuardadoSilencioso(campo, editValue)
setEditingId(null)
setEditValue('')
}
const handleIARequest = (clave: string) => {
navigate({
to: '/planes/$planId/iaplan',
params: {
planId: planId, // o dinámico
},
state: {
campo_edit: clave,
} as any,
})
}
return (
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
<div className="mb-6">
<h2 className="text-foreground text-lg font-semibold">
Datos Generales del Plan
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Información estructural y descriptiva del plan de estudios
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{campos.map((campo) => {
const isEditing = editingId === campo.id
return (
<div
key={campo.id}
className={`rounded-xl border transition-all ${
isEditing
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
: 'bg-white hover:shadow-md'
}`}
>
{/* Header de la Card */}
<TooltipProvider>
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<h3 className="cursor-help text-sm font-medium text-slate-700">
{campo.label}
</h3>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">
{campo.helperText || 'Información del campo'}
</TooltipContent>
</Tooltip>
{campo.requerido && (
<span className="text-xs text-red-500">*</span>
)}
</div>
{!isEditing && (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-teal-600"
onClick={() => handleIARequest(campo)}
>
<Sparkles size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Generar con IA</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(campo)}
>
<Pencil size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Editar campo</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
{/* Contenido de la Card */}
<div className="p-5">
{isEditing ? (
<div className="space-y-3">
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="placeholder:text-muted-foreground/70 min-h-30 not-italic placeholder:italic"
placeholder={`Ej. ${campo.holder[0]}`}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
>
<X size={14} className="mr-1" /> Cancelar
</Button>
<Button
size="sm"
className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo)}
>
<Check size={14} className="mr-1" /> Guardar
</Button>
</div>
</div>
) : (
<div className="min-h-25">
{campo.value ? (
<div className="text-sm leading-relaxed text-slate-600">
{campo.tipo === 'lista' ? (
<ul className="space-y-1">
{campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
{item}
</li>
))}
</ul>
) : (
<p className="whitespace-pre-wrap">{campo.value}</p>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-slate-400">
<AlertCircle size={14} />
<span>Sin contenido.</span>
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router'
import {
@@ -6,10 +7,13 @@ import {
AlertTriangle,
GripVertical,
Trash2,
Pencil,
} from 'lucide-react'
import { useMemo, useState, useEffect } from 'react'
import { useMemo, useState, useEffect, Fragment } from 'react'
import type { Materia, LineaCurricular } from '@/types/plan'
import type { TipoAsignatura } from '@/data'
import type { Asignatura, LineaCurricular } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -33,7 +37,15 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { usePlanAsignaturas, usePlanLineas } from '@/data'
import {
useCreateLinea,
useDeleteLinea,
usePlan,
usePlanAsignaturas,
usePlanLineas,
useUpdateAsignatura,
useUpdateLinea,
} from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
const mapLineasToLineaCurricular = (
@@ -47,21 +59,26 @@ const mapLineasToLineaCurricular = (
}))
}
const mapAsignaturasToMaterias = (asigApi: Array<any> = []): Array<Materia> => {
return asigApi.map((asig) => ({
id: asig.id,
clave: asig.codigo,
nombre: asig.nombre,
creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
estado: 'borrador',
orden: asig.orden_celda ?? 0,
hd: Math.floor((asig.horas_semana ?? 0) / 2),
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
prerrequisitos: [],
}))
const mapAsignaturasToAsignaturas = (
asigApi: Array<any> = [],
): Array<Asignatura> => {
return asigApi.map((asig) => {
return {
id: asig.id,
clave: asig.codigo,
nombre: asig.nombre,
creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo,
estado: 'borrador',
orden: asig.orden_celda ?? 0,
// Mapeo directo de los nuevos campos de la API
hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
}
})
}
const lineColors = [
@@ -104,13 +121,13 @@ function StatItem({
)
}
function MateriaCardItem({
materia,
function AsignaturaCardItem({
asignatura,
onDragStart,
isDragging,
onClick,
}: {
materia: Materia
asignatura: Asignatura
onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean
onClick: () => void
@@ -118,7 +135,7 @@ function MateriaCardItem({
return (
<button
draggable
onDragStart={(e) => onDragStart(e, materia.id)}
onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick}
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
isDragging
@@ -128,21 +145,21 @@ function MateriaCardItem({
>
<div className="mb-1 flex items-start justify-between">
<span className="font-mono text-[10px] font-bold text-slate-400">
{materia.clave}
{asignatura.clave}
</span>
<Badge
variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[materia.estado] || ''}`}
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
>
{materia.estado}
{asignatura.estado}
</Badge>
</div>
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
{materia.nombre}
{asignatura.nombre}
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-[10px] text-slate-500">
{materia.creditos} CR HD:{materia.hd} HI:{materia.hi}
{asignatura.creditos} CR HD:{asignatura.hd} HI:{asignatura.hi}
</span>
<GripVertical
size={12}
@@ -159,73 +176,107 @@ export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
function MapaCurricularPage() {
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
// 1. Fetch de Datos
const { data: asignaturasApi, isLoading: loadingAsig } =
const { data } = usePlan(planId)
const [ciclo, setCiclo] = useState(0)
const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
const [tempNombreLinea, setTempNombreLinea] = useState('')
const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea()
const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
// 2. Estado Local (Para interactividad)
const [materias, setMaterias] = useState<Array<Materia>>([])
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
const [draggedMateria, setDraggedMateria] = useState<string | null>(null)
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
null,
)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null)
const [hasAreaComun, setHasAreaComun] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const { mutate: updateAsignatura } = useUpdateAsignatura()
const [seriacionValue, setSeriacionValue] = useState<string>('')
useEffect(() => {
if (data?.numero_ciclos) {
setCiclo(data.numero_ciclos)
}
}, [data])
const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim()
// 1. Validar que no esté vacío
if (!nombreNormalizado) return
// 2. Validar duplicados (Insensible a mayúsculas/minúsculas y acentos)
const nombreParaComparar = nombreNormalizado
const nombreBusqueda = nombreNormalizado
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
const yaExiste = lineas.some((l) => {
const lineaNombreBase = l.nombre
const lineaExistente = l.nombre
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
return lineaNombreBase === nombreParaComparar
return lineaExistente === nombreBusqueda
})
if (yaExiste) {
alert(`La línea "${nombreNormalizado}" ya existe.`)
alert(`La línea "${nombreNormalizado}" ya existe en este plan.`)
return
}
// 3. Validar Área Común (usando tu lógica previa)
const esAreaComun =
nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun'
if (esAreaComun && hasAreaComun) {
alert('El Área Común ya ha sido agregada.')
return
}
// 4. Agregar la línea si todo está bien
const nueva = {
id: crypto.randomUUID(),
nombre: nombreNormalizado,
orden: lineas.length + 1,
color: '#1976d2',
}
setLineas([...lineas, nueva])
if (esAreaComun) {
setHasAreaComun(true)
}
setNombreNuevaLinea('') // Limpiar input
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
createLinea(
{
nombre: nombreNormalizado,
plan_estudio_id: planId,
orden: maxOrden + 1,
area: 'sin asignar',
},
{
onSuccess: (nueva) => {
const mapeada = {
id: nueva.id,
nombre: nueva.nombre,
orden: nueva.orden,
color: '#1976d2',
}
setLineas((prev) => [...prev, mapeada])
setNombreNuevaLinea('')
},
},
)
}
const guardarEdicionLinea = (id: string, nuevoNombre?: string) => {
// Usamos el nombre que viene por parámetro o el del estado como fallback
const nombreAFijar = (
nuevoNombre !== undefined ? nuevoNombre : tempNombreLinea
).trim()
if (!nombreAFijar) {
setEditingLineaId(null)
return
}
updateLineaApi(
{
lineaId: id,
patch: { nombre: nombreAFijar },
},
{
onSuccess: (lineaActualizada) => {
setLineas((prev) =>
prev.map((l) =>
l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l,
),
)
setEditingLineaId(null)
setTempNombreLinea('')
},
onError: (err) => {
console.error('Error al actualizar linea:', err)
// Opcional: revertir cambios o avisar al usuario
},
},
)
}
const tieneAreaComun = useMemo(() => {
return lineas.some(
(l) =>
@@ -234,57 +285,120 @@ function MapaCurricularPage() {
)
}, [lineas])
// 3. Sincronizar API -> Estado Local
useEffect(() => {
if (asignaturasApi) setMaterias(mapAsignaturasToMaterias(asignaturasApi))
}, [asignaturasApi])
if (asignaturaApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
}, [asignaturaApi])
useEffect(() => {
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
}, [lineasApi])
const ciclosTotales = 9
const ciclosTotales = Number(ciclo)
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
const [editingData, setEditingData] = useState<Asignatura | null>(null)
const handleIntegerChange = (value: string) => {
if (value === '') return value
// Nuevo estado para controlar los datos temporales del modal de edición
const [editingData, setEditingData] = useState<Materia | null>(null)
// Solo números, máximo 3 cifras
const regex = /^\d{1,3}$/
// 1. FUNCION DE GUARDAR MODAL
if (!regex.test(value)) return null
return value
}
const handleDecimalChange = (value: string, max?: number): string | null => {
if (value === '') return ''
const val = value.replace(',', '.')
const regex = /^\d*\.?\d{0,2}$/
if (!regex.test(val)) return null
if (max !== undefined) {
const num = Number(val)
if (!isNaN(num) && num > max) {
return max.toFixed(2)
}
}
return val
}
const handleSaveChanges = () => {
if (!editingData) return
console.log(materias)
setMaterias((prev) =>
setAsignaturas((prev) =>
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
)
setIsEditModalOpen(false)
}
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']
prerrequisito_asignatura_id?: string | null
}
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre,
codigo: editingData.clave,
creditos: editingData.creditos,
horas_academicas: editingData.hd,
horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
}
// 2. MODIFICACIÓN: Zona de soltado siempre visible
// Cambiamos la condición: Mostramos la sección si hay materias sin asignar
// O si simplemente queremos tener el "depósito" disponible.
const unassignedMaterias = materias.filter((m) => m.ciclo === null)
// --- Lógica de Gestión ---
const agregarLinea = (nombre: string) => {
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
setLineas([...lineas, nueva])
updateAsignatura(
{ asignaturaId: editingData.id, patch: patch as any },
{
onSuccess: () => {
setIsEditModalOpen(false)
// Opcional: Mostrar un toast de éxito
},
onError: (error) => {
console.error('Error al guardar:', error)
alert('Hubo un error al guardar los cambios.')
},
},
)
}
const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null,
)
const borrarLinea = (id: string) => {
setMaterias((prev) =>
prev.map((m) =>
m.lineaCurricularId === id
? { ...m, ciclo: null, lineaCurricularId: null }
: m,
),
)
setLineas((prev) => prev.filter((l) => l.id !== id))
if (
!confirm(
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
)
) {
return
}
deleteLineaApi(id, {
onSuccess: () => {
// Primero: Las materias que estaban en esa línea pasan a ser "huérfanas"
setAsignaturas((prev) =>
prev.map((asig) =>
asig.lineaCurricularId === id
? { ...asig, ciclo: null, lineaCurricularId: null }
: asig,
),
)
setLineas((prev) => prev.filter((l) => l.id !== id))
},
onError: (error) => {
console.error(error)
alert('No se pudo eliminar la línea. Verifica si tiene dependencias.')
},
})
}
// --- Selectores/Cálculos ---
const getTotalesCiclo = (ciclo: number) => {
return materias
return asignaturas
.filter((m) => m.ciclo === ciclo)
.reduce(
(acc, m) => ({
@@ -297,8 +411,8 @@ function MapaCurricularPage() {
}
const getSubtotalLinea = (lineaId: string) => {
return materias
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
return asignaturas
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) // Aseguramos que pertenezca a la línea Y tenga ciclo
.reduce(
(acc, m) => ({
cr: acc.cr + (m.creditos || 0),
@@ -310,7 +424,7 @@ function MapaCurricularPage() {
}
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedMateria(id)
setDraggedAsignatura(id)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
@@ -320,21 +434,37 @@ function MapaCurricularPage() {
lineaId: string | null,
) => {
e.preventDefault()
if (draggedMateria) {
setMaterias((prev) =>
if (draggedAsignatura) {
// 1. Actualización optimista del UI
setAsignaturas((prev) =>
prev.map((m) =>
m.id === draggedMateria
m.id === draggedAsignatura
? { ...m, ciclo, lineaCurricularId: lineaId }
: m,
),
)
setDraggedMateria(null)
const patch = {
numero_ciclo: ciclo,
linea_plan_id: lineaId,
}
updateAsignatura(
{ asignaturaId: draggedAsignatura, patch },
{
onError: (error) => {
console.error('Error al mover:', error)
// Opcional: Revertir el estado local si falla
},
},
)
setDraggedAsignatura(null)
}
}
const stats = useMemo(
() =>
materias.reduce(
asignaturas.reduce(
(acc, m) => {
if (m.ciclo !== null) {
acc.cr += m.creditos || 0
@@ -345,9 +475,36 @@ function MapaCurricularPage() {
},
{ cr: 0, hd: 0, hi: 0 },
),
[materias],
[asignaturas],
)
const handleKeyDownLinea = (
e: React.KeyboardEvent<HTMLSpanElement>,
id: string,
) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}
const handleBlurLinea = (
e: React.FocusEvent<HTMLSpanElement>,
id: string,
) => {
const nuevoNombre = e.currentTarget.textContent.trim() || ''
// Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id)
if (nuevoNombre !== lineaOriginal?.nombre) {
// IMPORTANTE: Pasamos nuevoNombre directamente
guardarEdicionLinea(id, nuevoNombre)
} else {
setEditingLineaId(null)
}
}
if (loadingAsig || loadingLineas)
return <div className="p-10 text-center">Cargando mapa curricular...</div>
@@ -358,14 +515,22 @@ function MapaCurricularPage() {
<div>
<h2 className="text-xl font-bold">Mapa Curricular</h2>
<p className="text-sm text-slate-500">
Organiza las materias de la petición por línea y ciclo
Organiza las asignaturas de la petición por línea y ciclo
</p>
</div>
<div className="flex items-center gap-3">
{materias.filter((m) => !m.ciclo).length > 0 && (
<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 >
0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '}
{materias.filter((m) => !m.ciclo).length} sin asignar
{
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
.length
}{' '}
sin asignar
</Badge>
)}
<DropdownMenu>
@@ -425,98 +590,124 @@ function MapaCurricularPage() {
<div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]">
<div
className="mb-4 grid gap-3"
className="grid gap-3"
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">
LÍNEA CURRICULAR
</div>
{ciclosArray.map((n) => (
<div
key={n}
key={`header-${n}`}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
>
Ciclo {n}
</div>
))}
<div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL
</div>
</div>
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id)
return (
<div
key={linea.id}
className="mb-3 grid gap-3"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
>
<span className="text-xs font-bold">{linea.nombre}</span>
<Trash2
size={14}
className="cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => borrarLinea(linea.id)}
/>
</div>
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id)
{ciclosArray.map((ciclo) => (
return (
<Fragment key={linea.id}>
<div
key={ciclo}
onDragOver={handleDragOver}
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={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
>
{materias
.filter(
(m) =>
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
)
.map((m) => (
<MateriaCardItem
key={m.id}
materia={m}
isDragging={draggedMateria === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
<div className="flex-1 overflow-hidden">
<span
contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => {
if (editingLineaId !== linea.id) {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}}
className={`block w-full text-xs font-bold break-words outline-none ${
editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer'
}`}
>
{linea.nombre}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingLineaId(linea.id)}
className="..."
>
{' '}
<Pencil size={12} />{' '}
</button>
<Trash2
onClick={() => borrarLinea(linea.id)}
className="..."
size={14}
/>
</div>
</div>
))}
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
<div>Cr: {sub.cr}</div>
<div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div>
</div>
</div>
)
})}
{ciclosArray.map((ciclo) => (
<div
key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver}
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"
>
{asignaturas
.filter(
(m) =>
m.ciclo === ciclo &&
m.lineaCurricularId === linea.id,
)
.map((m) => (
<AsignaturaCardItem
key={m.id}
asignatura={m}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
</div>
))}
<div
className="mt-6 grid gap-3 border-t pt-4"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div className="p-2 font-bold text-slate-600">
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
<div>Cr: {sub.cr}</div>
<div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div>
</div>
</Fragment>
)
})}
<div className="col-span-full my-2 border-t border-slate-200"></div>
<div className="self-center p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
return (
<div
key={ciclo}
key={`footer-${ciclo}`}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
>
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
@@ -526,6 +717,7 @@ function MapaCurricularPage() {
</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>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
@@ -534,35 +726,34 @@ function MapaCurricularPage() {
</div>
</div>
{/* Materias Sin Asignar */}
{/* SECCIÓN DE MATERIAS SIN ASIGNAR (Mejorada para estar siempre disponible) */}
{/* Asignaturas Sin Asignar */}
<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="flex items-center gap-2 text-slate-600">
<h3 className="text-sm font-bold tracking-wider uppercase">
Bandeja de Entrada / Materias sin asignar
Bandeja de Entrada / Asignaturas sin asignar
</h3>
<Badge variant="secondary">{unassignedMaterias.length}</Badge>
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
</div>
<p className="text-xs text-slate-400">
Arrastra una materia aquí para quitarla del mapa
Arrastra una asignatura aquí para quitarla del mapa
</p>
</div>
<div
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
draggedMateria
draggedAsignatura
? 'border-teal-300 bg-teal-50/50'
: 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
>
{unassignedMaterias.map((m) => (
{unassignedAsignaturas.map((m) => (
<div key={m.id} className="w-[200px]">
<MateriaCardItem
materia={m}
isDragging={draggedMateria === m.id}
<AsignaturaCardItem
asignatura={m}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición
@@ -571,9 +762,9 @@ function MapaCurricularPage() {
/>
</div>
))}
{unassignedMaterias.length === 0 && (
{unassignedAsignaturas.length === 0 && (
<div className="flex w-full items-center justify-center text-sm text-slate-400">
No hay materias pendientes. Arrastra una materia aquí para
No hay asignaturas pendientes. Arrastra una asignatura aquí para
desasignarla.
</div>
)}
@@ -582,10 +773,13 @@ function MapaCurricularPage() {
{/* Modal de Edición */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="sm:max-w-[550px]">
<DialogContent
className="sm:max-w-[550px]"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="font-bold text-slate-700">
Editar Materia
Editar Asignatura
</DialogTitle>
</DialogHeader>
@@ -599,6 +793,7 @@ function MapaCurricularPage() {
Clave
</label>
<Input
maxLength={100}
value={editingData.clave}
onChange={(e) =>
setEditingData({ ...editingData, clave: e.target.value })
@@ -610,6 +805,7 @@ function MapaCurricularPage() {
Nombre
</label>
<Input
maxLength={200}
value={editingData.nombre}
onChange={(e) =>
setEditingData({ ...editingData, nombre: e.target.value })
@@ -626,13 +822,17 @@ function MapaCurricularPage() {
</label>
<Input
type="number"
min={0}
value={editingData.creditos}
onChange={(e) =>
setEditingData({
...editingData,
creditos: Number(e.target.value),
})
}
onChange={(e) => {
const val = handleDecimalChange(e.target.value, 10)
if (val !== null) {
setEditingData({
...editingData,
creditos: val === '' ? 0 : Number(val),
})
}
}}
/>
</div>
<div className="space-y-2">
@@ -642,12 +842,15 @@ function MapaCurricularPage() {
<Input
type="number"
value={editingData.hd}
onChange={(e) =>
setEditingData({
...editingData,
hd: Number(e.target.value),
})
}
onChange={(e) => {
const val = handleIntegerChange(e.target.value)
if (val !== null) {
setEditingData({
...editingData,
hd: Number(e.target.value),
})
}
}}
/>
</div>
<div className="space-y-2">
@@ -657,12 +860,15 @@ function MapaCurricularPage() {
<Input
type="number"
value={editingData.hi}
onChange={(e) =>
setEditingData({
...editingData,
hi: Number(e.target.value),
})
}
onChange={(e) => {
const val = handleIntegerChange(e.target.value)
if (val !== null) {
setEditingData({
...editingData,
hi: Number(e.target.value),
})
}
}}
/>
</div>
</div>
@@ -728,35 +934,58 @@ function MapaCurricularPage() {
</div>
</div>
{/* Fila 4: Seriación (Igual a tu imagen) */}
{/* Fila 4: Seriación (Prerrequisitos) */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisitos)
Seriación (Prerrequisito)
</label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Seleccionar materia..." />
<Select
// Cambiamos a manejo de valor único basado en el ID de la columna
value={editingData.prerrequisito_asignatura_id || undefined}
onValueChange={(val) => {
console.log(editingData)
setEditingData({
...editingData,
prerrequisito_asignatura_id: val === 'none' ? null : val,
})
}}
>
<SelectTrigger className="w-full bg-white">
<SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger>
<SelectContent>
{materias.map((m) => (
<SelectItem key={m.id} value={m.clave}>
{m.nombre}
</SelectItem>
))}
<SelectItem value="none">-- Sin Seriación --</SelectItem>
{asignaturas
.filter((asig) => {
// 1. No es la misma materia
const noEsMisma = asig.id !== editingData.id
// 2. El ciclo debe ser estrictamente MENOR
const esCicloMenor =
asig.ciclo !== null &&
editingData.ciclo !== null &&
asig.ciclo < editingData.ciclo
return noEsMisma && esCicloMenor
})
.sort(
(a, b) =>
(a.ciclo || 0) - (b.ciclo || 0) ||
a.nombre.localeCompare(b.nombre),
)
.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
<span className="font-bold text-teal-600">
[C{asig.ciclo}]
</span>{' '}
{asig.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-2 flex gap-2">
{/* Aquí usamos el array vacío que inicializamos en el mapeador */}
{editingData.prerrequisitos.map((pre) => (
<Badge
key={pre}
variant="secondary"
className="bg-slate-100 text-slate-600"
>
{pre} <span className="ml-1 cursor-pointer">×</span>
</Badge>
))}
</div>
{/* Visualización del Prerrequisito con el Nombre */}
</div>
{/* Fila 5: Tipo */}
@@ -766,7 +995,7 @@ function MapaCurricularPage() {
</label>
<Select
value={editingData.tipo}
onValueChange={(val: 'obligatoria' | 'optativa') =>
onValueChange={(val: 'OBLIGATORIA' | 'OPTATIVA') =>
setEditingData({ ...editingData, tipo: val })
}
>
@@ -774,8 +1003,8 @@ function MapaCurricularPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="obligatoria">Obligatoria</SelectItem>
<SelectItem value="optativa">Optativa</SelectItem>
<SelectItem value="OBLIGATORIA">Obligatoria</SelectItem>
<SelectItem value="OPTATIVA">Optativa</SelectItem>
</SelectContent>
</Select>
</div>

View File

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

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,19 @@
import { createFileRoute } from '@tanstack/react-router'
import { NuevaBibliografiaModalContainer } from '@/features/bibliografia/nueva/NuevaBibliografiaModalContainer'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva',
)({
component: NuevaBibliografiaModal,
})
function NuevaBibliografiaModal() {
const { planId, asignaturaId } = Route.useParams()
return (
<NuevaBibliografiaModalContainer
planId={planId}
asignaturaId={asignaturaId}
/>
)
}

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 { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento',
)({
component: RouteComponent,
})
function RouteComponent() {
const { asignaturaId } = 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 fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
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)
}
}, [asignaturaId])
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
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

@@ -1,18 +1,263 @@
import MateriaDetailPage from '@/components/asignaturas/detalle/MateriaDetailPage'
import { createFileRoute } from '@tanstack/react-router'
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'
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: RouteComponent,
component: AsignaturaLayout,
})
function RouteComponent() {
//const { planId, asignaturaId } = Route.useParams()
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>
<MateriaDetailPage></MateriaDetailPage>
<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>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<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" />
Pertenece al plan:{' '}
<span className="text-blue-100">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
</span>
</span>
</div>
</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: 'iaasignatura' },
{ 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,16 +0,0 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId/asignaturas/_lista')({
component: RouteComponent,
})
function RouteComponent() {
return (
<main className="bg-background min-h-screen w-full">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
<h1 className="text-foreground text-2xl font-semibold">Asignaturas</h1>
<Outlet />
</div>
</main>
)
}

View File

@@ -1,10 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId/asignaturas/')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/planes/$planId/materias',
params,
})
},
})

View File

@@ -1,9 +0,0 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId/asignaturas')({
component: AsignaturasLayout,
})
function AsignaturasLayout() {
return <Outlet />
}

View File

@@ -1,10 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId/')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/planes/$planId/materias',
params,
})
},
})

View File

@@ -125,7 +125,11 @@ function RouteComponent() {
</div>
</div>
<button
onClick={() => navigate({ to: '/planes/nuevo' })}
onClick={() => {
console.log('planId')
navigate({ to: '/planes/nuevo', resetScroll: false })
}}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
>
<Icons.Plus /> Nuevo plan de estudios

119
src/types/asignatura.ts Normal file
View File

@@ -0,0 +1,119 @@
export type AsignaturaTab =
| 'datos-generales'
| 'contenido-tematico'
| 'bibliografia'
| 'ia-asignatura'
| 'documento-sep'
| 'historial'
export interface Asignatura {
id: string
nombre: string
clave: string
creditos?: number
lineaCurricular?: string
ciclo?: string
planId: string
planNombre: string
carrera: string
facultad: string
estructuraId: string
}
export interface CampoEstructura {
id: string
nombre: string
tipo: 'texto' | 'texto_largo' | 'lista' | 'numero'
obligatorio: boolean
descripcion?: string
placeholder?: string
}
export interface AsignaturaStructure {
id: string
nombre: string
campos: Array<CampoEstructura>
}
export interface Tema {
id: string
nombre: string
descripcion?: string
horasEstimadas?: number
}
export interface UnidadTematica {
id: string
nombre: string
numero: number
temas: Array<Tema>
}
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: LibraryResource
}
export interface LibraryResource {
id: string
titulo: string
autor: string
editorial?: string
anio?: number
isbn?: string
tipo: 'libro' | 'articulo' | 'revista' | 'recurso_digital'
disponible: boolean
}
export interface IAMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
campoAfectado?: string
sugerencia?: IASugerencia
}
export interface IASugerencia {
campoId: string
campoNombre: string
valorActual: string
valorSugerido: string
aceptada?: boolean
}
export interface CambioAsignatura {
id: string
tipo: 'datos' | 'contenido' | 'bibliografia' | 'ia' | 'documento'
descripcion: string
usuario: string
fecha: Date
detalles?: Record<string, any>
}
export interface DocumentoAsignatura {
id: string
asignaturaId: string
version: number
fechaGeneracion: Date
url?: string
estado: 'generando' | 'listo' | 'error'
}
export interface AsignaturaDetailState {
asignatura: Asignatura | null
estructura: AsignaturaStructure | null
datosGenerales: Record<string, any>
contenidoTematico: Array<UnidadTematica>
bibliografia: Array<BibliografiaEntry>
iaMessages: Array<IAMessage>
documentoSep: DocumentoAsignatura | null
historial: Array<CambioAsignatura>
activeTab: AsignaturaTab
isSaving: boolean
isLoading: boolean
errorMessage: string | null
}

12
src/types/citeproc.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
declare module 'citeproc' {
const CSL: {
Engine: new (
sys: any,
style: string,
lang?: string,
forceLang?: boolean,
) => any
}
export default CSL
}

View File

@@ -1,119 +0,0 @@
export type MateriaTab =
| 'datos-generales'
| 'contenido-tematico'
| 'bibliografia'
| 'ia-materia'
| 'documento-sep'
| 'historial';
export interface Materia {
id: string;
nombre: string;
clave: string;
creditos?: number;
lineaCurricular?: string;
ciclo?: string;
planId: string;
planNombre: string;
carrera: string;
facultad: string;
estructuraId: string;
}
export interface CampoEstructura {
id: string;
nombre: string;
tipo: 'texto' | 'texto_largo' | 'lista' | 'numero';
obligatorio: boolean;
descripcion?: string;
placeholder?: string;
}
export interface MateriaStructure {
id: string;
nombre: string;
campos: CampoEstructura[];
}
export interface Tema {
id: string;
nombre: string;
descripcion?: string;
horasEstimadas?: number;
}
export interface UnidadTematica {
id: string;
nombre: string;
numero: number;
temas: Tema[];
}
export interface BibliografiaEntry {
id: string;
tipo: 'BASICA' | 'COMPLEMENTARIA';
cita: string;
fuenteBibliotecaId?: string;
fuenteBiblioteca?: LibraryResource;
}
export interface LibraryResource {
id: string;
titulo: string;
autor: string;
editorial?: string;
anio?: number;
isbn?: string;
tipo: 'libro' | 'articulo' | 'revista' | 'recurso_digital';
disponible: boolean;
}
export interface IAMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
campoAfectado?: string;
sugerencia?: IASugerencia;
}
export interface IASugerencia {
campoId: string;
campoNombre: string;
valorActual: string;
valorSugerido: string;
aceptada?: boolean;
}
export interface CambioMateria {
id: string;
tipo: 'datos' | 'contenido' | 'bibliografia' | 'ia' | 'documento';
descripcion: string;
usuario: string;
fecha: Date;
detalles?: Record<string, any>;
}
export interface DocumentoMateria {
id: string;
materiaId: string;
version: number;
fechaGeneracion: Date;
url?: string;
estado: 'generando' | 'listo' | 'error';
}
export interface MateriaDetailState {
materia: Materia | null;
estructura: MateriaStructure | null;
datosGenerales: Record<string, any>;
contenidoTematico: UnidadTematica[];
bibliografia: BibliografiaEntry[];
iaMessages: IAMessage[];
documentoSep: DocumentoMateria | null;
historial: CambioMateria[];
activeTab: MateriaTab;
isSaving: boolean;
isLoading: boolean;
errorMessage: string | null;
}

Some files were not shown because too many files have changed in this diff Show More