90 Commits

Author SHA1 Message Date
4c730fa0ab Merge pull request 'Se renderizan las previsualizaciones del plan y de la asignatura y también se pueden descargar como word o pdf' (#211) from issue/200-renderizado-de-plantillas-con-edge-function-de-car into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #211
2026-03-20 23:47:37 +00:00
2abe296b9e close #200: Se guardan los docx y pdf con el nombre del plan/asignatura 2026-03-20 17:44:36 -06:00
1bce226d15 Se descargan correctamente los docx del plan y de la asignatura 2026-03-20 17:31:59 -06:00
b986ec343e Se visualiza y descarga el pdf de la asignatura 2026-03-20 17:31:07 -06:00
379e2d3826 Actualizar src/routes/planes/$planId/_detalle/mapa.tsx
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
2026-03-20 21:30:16 +00:00
cb5422f57c Merge pull request 'Refactor AsignaturaCardItem to use Tooltip and improve styling; update color mapping for lineas' (#208) from mejorar-diseño-de-tarjetas into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m27s
Reviewed-on: #208
2026-03-20 21:17:37 +00:00
Your Name
67724181fd Refactor AsignaturaCardItem to use Tooltip and improve styling; update color mapping for lineas 2026-03-20 15:17:17 -06:00
d9a5cec3c5 En el body se manda el parámetro para convertir el documento a pdf 2026-03-20 13:22:23 -06:00
96848e1793 Se utiliza la edge function de carbone para obtener el pdf del anexo del plan de estudios a partir del id del plan 2026-03-20 12:24:17 -06:00
cbaf96c6b5 Merge pull request 'Add letter-spacing to font-bold class in styles.css' (#206) from agregar-tipografía into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m13s
Reviewed-on: #206
2026-03-20 17:37:05 +00:00
0fb831fb58 Merge branch 'main' into agregar-tipografía 2026-03-20 17:36:58 +00:00
0d1aa61022 Add letter-spacing to font-bold class in styles.css 2026-03-20 11:35:51 -06:00
84281a88f2 Merge pull request 'Add Indivisa font family and update styles.css' (#205) from agregar-tipografía into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
Reviewed-on: #205
2026-03-20 17:33:03 +00:00
d91018c612 Add Indivisa font family and update styles.css 2026-03-20 11:30:39 -06:00
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
63 changed files with 16104 additions and 1739 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

View File

@@ -4,6 +4,7 @@
"": { "": {
"name": "acad-ia-2", "name": "acad-ia-2",
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -30,6 +31,7 @@
"@tanstack/router-plugin": "^1.132.0", "@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"citeproc": "^2.4.63",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -137,6 +139,18 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], "@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/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=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -249,6 +263,8 @@
"@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=="], "@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/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/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -735,6 +751,8 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "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=="], "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=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],

View File

@@ -17,6 +17,7 @@
"ci:verify": "prettier --check . && eslint . && tsc --noEmit" "ci:verify": "prettier --check . && eslint . && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -43,6 +44,7 @@
"@tanstack/router-plugin": "^1.132.0", "@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"citeproc": "^2.4.63",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,12 @@
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { Pencil, Sparkles } from 'lucide-react' import { Minus, Pencil, Plus, Sparkles } from 'lucide-react'
import { useState, useEffect } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { AsignaturaDetail } from '@/data' import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -14,6 +15,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string id: string
@@ -41,50 +43,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
function parseContenidoTematicoToPlainText(value: unknown): string { type CriterioEvaluacionRow = {
if (!Array.isArray(value)) return '' criterio: string
porcentaje: number
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()
} }
const columnParsers: Partial<Record<string, (value: unknown) => string>> = { type CriterioEvaluacionRowDraft = {
contenido_tematico: parseContenidoTematicoToPlainText, id: string
criterio: string
porcentaje: string // allow empty while editing
} }
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -132,11 +99,19 @@ function DatosGenerales({
}: { }: {
onPersistDato: (clave: string, value: string) => void onPersistDato: (clave: string, value: string) => void
}) { }) {
const { asignaturaId } = useParams({ const { asignaturaId, planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const navigate = useNavigate()
const { data: data, isLoading: isLoading } = useSubject(asignaturaId) 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) // 1. Extraemos la definición de la estructura (los metadatos)
const definicionRaw = data?.estructuras_asignatura?.definicion const definicionRaw = data?.estructuras_asignatura?.definicion
@@ -154,6 +129,56 @@ function DatosGenerales({
const valoresActuales = isRecord(datosRaw) const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>) ? (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> if (isLoading) return <p>Cargando información...</p>
return ( return (
@@ -209,10 +234,29 @@ function DatosGenerales({
clave={key} clave={key}
title={cardTitle} title={cardTitle}
initialContent={currentContent} initialContent={currentContent}
xColumn={xColumn}
placeholder={placeholder} placeholder={placeholder}
description={description} description={description}
onPersist={(clave, value) => onPersistDato(clave, value)} 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()
}
}
}}
/> />
) )
}, },
@@ -244,12 +288,11 @@ function DatosGenerales({
<InfoCard <InfoCard
title="Sistema de Evaluación" title="Sistema de Evaluación"
type="evaluation" type="evaluation"
initialContent={[ initialContent={criteriosEvaluacion}
{ label: 'Exámenes parciales', value: '30%' }, containerRef={evaluationCardRef}
{ label: 'Proyecto integrador', value: '35%' }, forceEditToken={evaluationForceEditToken}
{ label: 'Prácticas de laboratorio', value: '20%' }, highlightToken={evaluationHighlightToken}
{ label: 'Participación', value: '15%' }, onPersist={({ value }) => persistCriteriosEvaluacion(value)}
]}
/> />
</div> </div>
</div> </div>
@@ -265,11 +308,19 @@ interface InfoCardProps {
initialContent: any initialContent: any
placeholder?: string placeholder?: string
description?: string description?: string
xColumn?: string
required?: boolean // Nueva prop para el asterisco required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation' type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void onEnhanceAI?: (content: any) => void
onPersist?: (clave: string, value: string) => void 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({ function InfoCard({
@@ -279,14 +330,22 @@ function InfoCard({
initialContent, initialContent,
placeholder, placeholder,
description, description,
xColumn,
required, required,
type = 'text', type = 'text',
onPersist, onPersist,
onClickEditButton,
containerRef,
forceEditToken,
highlightToken,
}: InfoCardProps) { }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
const [data, setData] = useState(initialContent) const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent) const [tempText, setTempText] = useState(initialContent)
const [evalRows, setEvalRows] = useState<Array<CriterioEvaluacionRowDraft>>(
[],
)
const navigate = useNavigate() const navigate = useNavigate()
const { planId } = useParams({ const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
@@ -295,16 +354,85 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(initialContent) setTempText(initialContent)
}, [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 = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? '')) 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) setData(tempText)
setIsEditing(false) setIsEditing(false)
if (type === 'text' && clave && onPersist) { if (type === 'text') {
onPersist(clave, String(tempText ?? '')) void onPersist?.({ type, clave, value: String(tempText ?? '') })
} }
} }
@@ -325,8 +453,27 @@ function InfoCard({
}) })
} }
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 ( return (
<Card className="overflow-hidden transition-all hover:border-slate-300"> <div ref={containerRef as any}>
<Card
className={
'overflow-hidden transition-all hover:border-slate-300 ' +
(isHighlighted ? 'ring-primary/40 ring-2' : '')
}
>
<TooltipProvider> <TooltipProvider>
<CardHeader className="border-b bg-slate-50/50 px-5 py-3"> <CardHeader className="border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -375,19 +522,14 @@ function InfoCard({
size="icon" size="icon"
className="h-8 w-8 text-slate-400" className="h-8 w-8 text-slate-400"
onClick={() => { onClick={() => {
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico), const startEditing = () => setIsEditing(true)
// redirigimos a la pestaña de Contenido en vez de editar inline.
if (xColumn === 'contenido_tematico') { if (onClickEditButton) {
// Agregamos un timestamp para forzar la actualización onClickEditButton({ startEditing })
// de la location.state aunque la ruta sea la misma.
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
params: { planId, asignaturaId: asignaturaId! },
})
return return
} }
setIsEditing(true) startEditing()
}} }}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
@@ -404,17 +546,177 @@ function InfoCard({
<CardContent className="pt-4"> <CardContent className="pt-4">
{isEditing ? ( {isEditing ? (
<div className="space-y-3"> <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 <Textarea
value={tempText} value={tempText}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => setTempText(e.target.value)} onChange={(e) => setTempText(e.target.value)}
className="min-h-30 text-sm leading-relaxed" className="min-h-30 text-sm leading-relaxed"
/> />
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setIsEditing(false)} 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 Cancelar
</Button> </Button>
@@ -422,6 +724,7 @@ function InfoCard({
size="sm" size="sm"
className="bg-[#00a878] hover:bg-[#008f66]" className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave} onClick={handleSave}
disabled={type === 'evaluation' && evaluationTotal > 100}
> >
Guardar Guardar
</Button> </Button>
@@ -436,11 +739,14 @@ function InfoCard({
<p className="text-slate-400 italic">Sin información.</p> <p className="text-slate-400 italic">Sin información.</p>
))} ))}
{type === 'requirements' && <RequirementsView items={data} />} {type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />} {type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
) )
} }
@@ -466,7 +772,11 @@ function RequirementsView({ items }: { items: Array<any> }) {
} }
// Vista de Evaluación // Vista de Evaluación
function EvaluationView({ items }: { items: Array<any> }) { function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
const porcentajeTotal = items.reduce(
(total, item) => total + Number(item.porcentaje),
0,
)
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{items.map((item, i) => ( {items.map((item, i) => (
@@ -474,10 +784,15 @@ function EvaluationView({ items }: { items: Array<any> }) {
key={i} key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic" 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="text-slate-500">{item.criterio}</span>
<span className="font-bold text-blue-600">{item.value}</span> <span className="font-bold text-blue-600">{item.porcentaje}%</span>
</div> </div>
))} ))}
{porcentajeTotal < 100 && (
<p className="text-destructive text-sm font-medium">
El porcentaje total es menor a 100%.
</p>
)}
</div> </div>
) )
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/label-has-associated-control */
/* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-static-element-interactions */
import { useParams } from '@tanstack/react-router' import { useNavigate, useParams } from '@tanstack/react-router'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react' import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
@@ -54,7 +54,8 @@ export interface BibliografiaEntry {
} }
export function BibliographyItem() { export function BibliographyItem() {
const { asignaturaId } = useParams({ const navigate = useNavigate()
const { planId, asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
@@ -68,13 +69,9 @@ export function BibliographyItem() {
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId) const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
// --- 3. Estados de UI (Solo para diálogos y edición) --- // --- 3. Estados de UI (Solo para diálogos y edición) ---
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
'BASICA',
)
console.log('Datos actuales en el front:', bibliografia) console.log('Datos actuales en el front:', bibliografia)
// --- 4. Derivación de datos (Se calculan en cada render) --- // --- 4. Derivación de datos (Se calculan en cada render) ---
@@ -85,20 +82,6 @@ export function BibliographyItem() {
// --- Handlers Conectados a la Base de Datos --- // --- Handlers Conectados a la Base de Datos ---
const handleAddManual = (cita: string) => {
crearBibliografia(
{
asignatura_id: asignaturaId,
tipo: newEntryType,
cita,
tipo_fuente: 'MANUAL',
},
{
onSuccess: () => setIsAddDialogOpen(false),
},
)
}
const handleAddFromLibrary = ( const handleAddFromLibrary = (
resource: any, resource: any,
tipo: 'BASICA' | 'COMPLEMENTARIA', tipo: 'BASICA' | 'COMPLEMENTARIA',
@@ -179,20 +162,17 @@ export function BibliographyItem() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <Button
<DialogTrigger asChild> onClick={() =>
<Button variant="outline"> navigate({
<Plus className="mr-2 h-4 w-4" /> Añadir manual 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"
>
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
</Button> </Button>
</DialogTrigger>
<DialogContent>
<AddManualDialog
tipo={newEntryType}
onTypeChange={setNewEntryType}
onAdd={handleAddManual}
/>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
@@ -364,49 +344,6 @@ 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({ resources, onSelect, existingIds }: any) { function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA') const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')

View File

@@ -1,3 +1,5 @@
import { DragDropProvider } from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import { useParams } from '@tanstack/react-router' import { useParams } from '@tanstack/react-router'
import { import {
Plus, Plus,
@@ -11,7 +13,7 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent } from 'react' import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -50,6 +52,95 @@ export interface UnidadTematica {
temas: Array<Tema> temas: Array<Tema>
} }
function createClientId(prefix: string) {
try {
const c = (globalThis as any).crypto
if (c && typeof c.randomUUID === 'function')
return `${prefix}-${c.randomUUID()}`
} catch {
// ignore
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function arrayMove<T>(array: Array<T>, fromIndex: number, toIndex: number) {
const next = array.slice()
const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex
if (startIndex < 0 || startIndex >= next.length) return next
const endIndex = toIndex < 0 ? next.length + toIndex : toIndex
const [item] = next.splice(startIndex, 1)
next.splice(endIndex, 0, item)
return next
}
function renumberUnidades(unidades: Array<UnidadTematica>) {
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
}
function InsertUnidadOverlay({
onInsert,
position,
}: {
onInsert: () => void
position: 'top' | 'bottom'
}) {
return (
<div
className={cn(
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
// Match the `space-y-4` gap so the hover target is *between* units.
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
)}
>
<Button
type="button"
variant="outline"
size="sm"
className="bg-background/95 border-border/60 hover:bg-background cursor-pointer opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
onInsert()
}}
>
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
</Button>
</div>
)
}
function SortableUnidad({
id,
index,
registerContainer,
children,
}: {
id: string
index: number
registerContainer: (el: HTMLDivElement | null) => void
children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode
}) {
const { ref, handleRef, isDragSource, isDropTarget } = useSortable({
id,
index,
})
return (
<div
ref={(el) => {
ref(el)
registerContainer(el)
}}
className={cn(
'group relative',
isDragSource && 'opacity-80',
isDropTarget && 'ring-primary/20 ring-2',
)}
>
{children({ handleRef })}
</div>
)
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
@@ -100,20 +191,18 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (Array.isArray(value.temas)) { if (Array.isArray(value.temas)) {
temas = value.temas temas = value.temas
.map(mapTemaValue) .map(mapTemaValue)
.filter((t): t is ContenidoTemaApi => t !== null) .filter((x): x is ContenidoTemaApi => x !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
} }
return { unidad, titulo, temas } return {
...value,
unidad,
titulo,
temas,
}
} }
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> { function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') { if (typeof value === 'string') {
try { try {
return mapContenidoTematicoFromDb(JSON.parse(value)) return mapContenidoTematicoFromDb(JSON.parse(value))
@@ -192,7 +281,16 @@ export function ContenidoTematico() {
const [temaDraftHoras, setTemaDraftHoras] = useState('') const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const didInitExpandedUnitsRef = useRef(false)
const unidadesRef = useRef<Array<UnidadTematica>>([])
useEffect(() => {
unidadesRef.current = unidades
}, [unidades])
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => { const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
didInitExpandedUnitsRef.current = true
const payload = serializeUnidadesToApi(nextUnidades) const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({ await updateContenido.mutateAsync({
subjectId: asignaturaId, subjectId: asignaturaId,
@@ -246,10 +344,17 @@ export function ContenidoTematico() {
}) })
} }
const parseHorasEstimadas = (raw: string): number => {
const normalized = raw.trim().replace(',', '.')
const parsed = Number.parseFloat(normalized)
if (!Number.isFinite(parsed)) return 0
return parsed
}
const commitEditTema = () => { const commitEditTema = () => {
if (!editingTema) return if (!editingTema) return
const parsedHoras = Number.parseInt(temaDraftHoras, 10) const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => { const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u if (u.id !== editingTema.unitId) return u
@@ -303,28 +408,110 @@ export function ContenidoTematico() {
data ? data.contenido_tematico : undefined, data ? data.contenido_tematico : undefined,
) )
const transformed = contenido.map((u, idx) => ({ // 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
id: `u-${u.unidad || idx + 1}`, // (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
numero: u.unidad || idx + 1, const currentPayload = JSON.stringify(
nombre: u.titulo || 'Sin título', serializeUnidadesToApi(unidadesRef.current),
)
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
const incomingPayload = JSON.stringify(
contenido.map((u, idx) => ({
unidad: u.unidad || idx + 1,
titulo: u.titulo || 'Sin título',
temas: Array.isArray(u.temas) temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({ ? u.temas.map((t) => {
id: `t-${u.unidad || idx + 1}-${tidx + 1}`, if (typeof t === 'string') {
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', return {
horasEstimadas: t?.horasEstimadas || 0, nombre: t,
})) horasEstimadas: 0,
descripcion: undefined,
}
}
return {
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
}
})
: [], : [],
})) })),
)
// Si los datos son idénticos, abortamos el useEffect.
// ¡Nuestros IDs locales se salvan y no hay parpadeos!
if (currentPayload === incomingPayload && unidadesRef.current.length > 0) {
return
}
// 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado.
// Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE.
const prevUnidades = [...unidadesRef.current]
const transformed = contenido.map((u, idx) => {
const dbTitulo = u.titulo || 'Sin título'
// Buscamos si ya existe una unidad con este mismo título
const existingUnitIndex = prevUnidades.findIndex(
(prev) => prev.nombre === dbTitulo,
)
let unidadId
let existingUnit = null
if (existingUnitIndex !== -1) {
existingUnit = prevUnidades[existingUnitIndex]
unidadId = existingUnit.id
prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo
} else {
unidadId = createClientId(`u-${u.unidad || idx + 1}`)
}
return {
id: unidadId,
numero: u.unidad || idx + 1,
nombre: dbTitulo,
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => {
const dbTemaNombre =
typeof t === 'string' ? t : t?.nombre || 'Tema'
// Reciclamos subtemas por nombre también
const existingTema = existingUnit?.temas.find(
(prevT) => prevT.nombre === dbTemaNombre,
)
const temaId = existingTema
? existingTema.id
: createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`)
return {
id: temaId,
nombre: dbTemaNombre,
horasEstimadas:
coerceNumber(
typeof t === 'string' ? undefined : t?.horasEstimadas,
) ?? 0,
}
})
: [],
}
})
setUnidades(transformed) setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id)) const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set( const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)), Array.from(prev).filter((id) => validIds.has(id)),
) )
if (filtered.size > 0) return filtered
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set() // Expandir la primera unidad solo una vez al llegar a la ruta.
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
}
return filtered
}) })
}, [data]) }, [data])
@@ -353,7 +540,7 @@ export function ContenidoTematico() {
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos) // 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce( const totalHoras = unidades.reduce(
(acc, u) => (acc, u) =>
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0),
0, 0,
) )
@@ -364,16 +551,22 @@ export function ContenidoTematico() {
setExpandedUnits(newExpanded) setExpandedUnits(newExpanded)
} }
const addUnidad = () => { const insertUnidadAt = (insertIndex: number) => {
const newNumero = unidades.length + 1 const newId = createClientId('u')
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: newNumero, numero: 0,
temas: [], temas: [],
} }
const next = [...unidades, newUnidad]
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
const next = renumberUnidades([
...unidades.slice(0, clampedIndex),
newUnidad,
...unidades.slice(clampedIndex),
])
setUnidades(next) setUnidades(next)
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const n = new Set(prev) const n = new Set(prev)
@@ -382,10 +575,40 @@ export function ContenidoTematico() {
}) })
setPendingScrollUnitId(newId) setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId) setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre) setUnitDraftNombre(newUnidad.nombre)
setUnitOriginalNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre)
void persistUnidades(next)
}
const handleReorderEnd = (event: any) => {
if (event?.canceled) return
const source = event?.operation?.source
if (!source) return
// Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden
if (!isSortable(source)) return
// Extraemos las posiciones exactas calculadas por dnd-kit
const { initialIndex, index } = source.sortable
// Si lo soltó en la misma posición de la que salió, cancelamos
if (initialIndex === index) return
setUnidades((prev) => {
// Hacemos el movimiento usando los índices directos
const moved = arrayMove(prev, initialIndex, index)
const next = renumberUnidades(moved)
// Disparamos la persistencia hacia Supabase
void persistUnidades(next).catch((err) => {
console.error('No se pudo guardar el orden de unidades', err)
})
return next
})
} }
// --- Lógica de Temas --- // --- Lógica de Temas ---
@@ -451,15 +674,31 @@ export function ContenidoTematico() {
</div> </div>
</div> </div>
<DragDropProvider onDragEnd={handleReorderEnd}>
<div className="space-y-4"> <div className="space-y-4">
{unidades.map((unidad) => ( {unidades.map((unidad, index) => (
<div <SortableUnidad
key={unidad.id} key={unidad.id}
ref={(el) => { id={unidad.id}
index={index}
registerContainer={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el) if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id) else unitContainerRefs.current.delete(unidad.id)
}} }}
> >
{({ handleRef }) => (
<>
{index === 0 && (
<InsertUnidadOverlay
position="top"
onInsert={() => insertUnidadAt(index)}
/>
)}
<InsertUnidadOverlay
position="bottom"
onInsert={() => insertUnidadAt(index + 1)}
/>
<Card className="overflow-hidden border-slate-200 shadow-sm"> <Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible <Collapsible
open={expandedUnits.has(unidad.id)} open={expandedUnits.has(unidad.id)}
@@ -467,9 +706,19 @@ export function ContenidoTematico() {
> >
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3"> <CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" /> <span
ref={handleRef as any}
className="inline-flex cursor-grab touch-none items-center text-slate-300"
aria-label="Reordenar unidad"
>
<GripVertical className="h-4 w-4" />
</span>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-0"> <Button
variant="ghost"
size="sm"
className="h-auto cursor-pointer p-0"
>
{expandedUnits.has(unidad.id) ? ( {expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
) : ( ) : (
@@ -485,7 +734,9 @@ export function ContenidoTematico() {
<Input <Input
ref={unitTitleInputRef} ref={unitTitleInputRef}
value={unitDraftNombre} value={unitDraftNombre}
onChange={(e) => setUnitDraftNombre(e.target.value)} onChange={(e) =>
setUnitDraftNombre(e.target.value)
}
onBlur={() => { onBlur={() => {
if (cancelNextBlurRef.current) { if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false cancelNextBlurRef.current = false
@@ -518,7 +769,7 @@ export function ContenidoTematico() {
)} )}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs font-medium text-slate-400"> <span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '} <Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce( {unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0), (sum, t) => sum + (t.horasEstimadas || 0),
@@ -529,9 +780,12 @@ export function ContenidoTematico() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500" className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => onClick={() =>
setDeleteDialog({ type: 'unidad', id: unidad.id }) setDeleteDialog({
type: 'unidad',
id: unidad.id,
})
} }
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -554,10 +808,14 @@ export function ContenidoTematico() {
} }
draftNombre={temaDraftNombre} draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras} draftHoras={temaDraftHoras}
onBeginEdit={() => beginEditTema(unidad.id, tema.id)} onBeginEdit={() =>
beginEditTema(unidad.id, tema.id)
}
onDraftNombreChange={setTemaDraftNombre} onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras} onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={handleTemaEditorBlurCapture} onEditorBlurCapture={
handleTemaEditorBlurCapture
}
onEditorKeyDownCapture={ onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture handleTemaEditorKeyDownCapture
} }
@@ -576,7 +834,7 @@ export function ContenidoTematico() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700" className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)} onClick={() => addTema(unidad.id)}
> >
<Plus className="mr-2 h-3 w-3" /> Añadir subtema <Plus className="mr-2 h-3 w-3" /> Añadir subtema
@@ -586,23 +844,12 @@ export function ContenidoTematico() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</Card> </Card>
</div> </>
)}
</SortableUnidad>
))} ))}
</div> </div>
</DragDropProvider>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
</div>
<DeleteConfirmDialog <DeleteConfirmDialog
dialog={deleteDialog} dialog={deleteDialog}
@@ -667,6 +914,9 @@ function TemaRow({
<Input <Input
type="number" type="number"
value={draftHoras} value={draftHoras}
min={0}
max={200}
step={0.5}
onChange={(e) => onDraftHorasChange(e.target.value)} onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white" className="h-8 w-16 bg-white"
/> />
@@ -675,7 +925,7 @@ function TemaRow({
<> <>
<button <button
type="button" type="button"
className="flex flex-1 items-center gap-3 text-left" className="flex flex-1 cursor-pointer items-center gap-3 text-left"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -690,7 +940,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-blue-600" className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -701,7 +951,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500" className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onDelete() onDelete()

View File

@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
pdfUrl: string | null pdfUrl: string | null
isLoading: boolean isLoading: boolean
onDownload: () => void onDownloadPdf: () => void
onDownloadWord: () => void
onRegenerate: () => void onRegenerate: () => void
isRegenerating: boolean isRegenerating: boolean
} }
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({ export function DocumentoSEPTab({
pdfUrl, pdfUrl,
isLoading, isLoading,
onDownload, onDownloadPdf,
onDownloadWord,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog <AlertDialog
open={showConfirmDialog} open={showConfirmDialog}
onOpenChange={setShowConfirmDialog} onOpenChange={setShowConfirmDialog}
> >
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={isRegenerating}> <Button
variant="outline"
size="sm"
className="gap-2"
disabled={isRegenerating}
>
{isRegenerating ? ( {isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="h-4 w-4" />
)} )}
{isRegenerating ? 'Generando...' : 'Regenerar documento'} {isRegenerating ? 'Generando...' : 'Regenerar'}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{pdfUrl && !isLoading && (
<>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Download className="h-4 w-4" /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={onDownloadPdf}
>
<Download className="h-4 w-4" /> Descargar PDF
</Button>
</>
)}
</div> </div>
</div> </div>
{/* PDF Preview */} {/* PDF Preview */}
<Card className="h-[800px] overflow-hidden"> <Card className="h-200 overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" /> <Loader2 className="h-10 w-10 animate-spin" />

File diff suppressed because it is too large Load Diff

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

@@ -26,7 +26,7 @@ export default function PasoSugerenciasForm({
onChange: Dispatch<SetStateAction<NewSubjectWizardState>> onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
}) { }) {
const enfoque = wizard.iaMultiple?.enfoque ?? '' const enfoque = wizard.iaMultiple?.enfoque ?? ''
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10 const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
const isLoading = wizard.iaMultiple?.isLoading ?? false const isLoading = wizard.iaMultiple?.isLoading ?? false
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false) const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
Cantidad de sugerencias Cantidad de sugerencias
</Label> </Label>
<Input <Input
placeholder="Ej. 10" placeholder="Ej. 5"
value={cantidadDeSugerencias} value={cantidadDeSugerencias}
type="number" type="number"
min={1} min={1}

View File

@@ -1,11 +1,12 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } from '@/data' import type { AISubjectUnifiedInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase' import type { TablesInsert } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -13,6 +14,7 @@ import {
useGenerateSubjectAI, useGenerateSubjectAI,
qk, qk,
useCreateSubjectManual, useCreateSubjectManual,
subjects_get_maybe,
} from '@/data' } from '@/data'
export function WizardControls({ export function WizardControls({
@@ -41,6 +43,154 @@ export function WizardControls({
const generateSubjectAI = useGenerateSubjectAI() const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual() const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false) const [isSpinningIA, setIsSpinningIA] = useState(false)
const cancelledRef = useRef(false)
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
const watchSubjectIdRef = useRef<string | null>(null)
const watchTimeoutRef = useRef<number | null>(null)
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const stopSubjectWatch = useCallback(() => {
if (watchTimeoutRef.current) {
window.clearTimeout(watchTimeoutRef.current)
watchTimeoutRef.current = null
}
watchSubjectIdRef.current = null
const ch = realtimeChannelRef.current
if (ch) {
realtimeChannelRef.current = null
try {
supabaseBrowser().removeChannel(ch)
} catch {
// noop
}
}
}, [])
useEffect(() => {
return () => {
stopSubjectWatch()
}
}, [stopSubjectWatch])
const handleSubjectReady = (args: {
id: string
plan_estudio_id: string
estado?: unknown
}) => {
if (cancelledRef.current) return
const estado = String(args.estado ?? '').toLowerCase()
if (estado === 'generando') return
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
state: { showConfetti: true },
})
}
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
stopSubjectWatch()
watchSubjectIdRef.current = args.subjectId
// Timeout de seguridad (mismo límite que teníamos con polling)
watchTimeoutRef.current = window.setTimeout(
() => {
if (cancelledRef.current) return
if (watchSubjectIdRef.current !== args.subjectId) return
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
}))
},
6 * 60 * 1000,
)
const supabase = supabaseBrowser()
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
realtimeChannelRef.current = channel
channel.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'asignaturas',
filter: `id=eq.${args.subjectId}`,
},
(payload) => {
if (cancelledRef.current) return
const next: any = (payload as any)?.new
if (!next?.id || !next?.plan_estudio_id) return
handleSubjectReady({
id: String(next.id),
plan_estudio_id: String(next.plan_estudio_id),
estado: next.estado,
})
},
)
channel.subscribe((status) => {
if (cancelledRef.current) return
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
}))
}
})
}
const uploadAiAttachments = async (args: {
planId: string
files: Array<{ file: File }>
}): Promise<Array<string>> => {
const supabase = supabaseBrowser()
if (!args.files.length) return []
const runId = crypto.randomUUID()
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
const keys: Array<string> = []
for (const f of args.files) {
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
const { error } = await supabase.storage
.from('ai-storage')
.upload(key, f.file, {
contentType: f.file.type || undefined,
})
if (error) throw new Error(error.message)
keys.push(key)
}
return keys
}
const handleCreate = async () => { const handleCreate = async () => {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
@@ -48,48 +198,99 @@ export function WizardControls({
errorMessage: null, errorMessage: null,
})) }))
let startedWaiting = false
try { try {
if (wizard.tipoOrigen === 'IA_SIMPLE') { if (wizard.tipoOrigen === 'IA_SIMPLE') {
const aiInput: AIGenerateSubjectInput = { if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.datosBasicos.estructuraId) {
throw new Error('Estructura inválida.')
}
if (!wizard.datosBasicos.nombre.trim()) {
throw new Error('Nombre inválido.')
}
if (wizard.datosBasicos.creditos == null) {
throw new Error('Créditos inválidos.')
}
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
const supabase = supabaseBrowser()
const placeholder: TablesInsert<'asignaturas'> = {
plan_estudio_id: wizard.plan_estudio_id, plan_estudio_id: wizard.plan_estudio_id,
datosBasicos: { estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre, nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo, codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo!, tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos!, creditos: wizard.datosBasicos.creditos,
horasIndependientes: wizard.datosBasicos.horasIndependientes, horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horasAcademicas: wizard.datosBasicos.horasAcademicas, horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
estructuraId: wizard.datosBasicos.estructuraId!, estado: 'generando',
tipo_origen: 'IA',
}
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholder)
.select('id,plan_estudio_id')
.single()
if (insertError) throw new Error(insertError.message)
const subjectId = inserted.id
setIsSpinningIA(true)
// Inicia watch realtime antes de disparar la Edge para no perder updates.
startedWaiting = true
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id: subjectId,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? null,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes:
wizard.datosBasicos.horasIndependientes ?? null,
}, },
iaConfig: { iaConfig: {
descripcionEnfoqueAcademico: descripcionEnfoqueAcademico:
wizard.iaConfig!.descripcionEnfoqueAcademico, wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
instruccionesAdicionalesIA: instruccionesAdicionalesIA:
wizard.iaConfig!.instruccionesAdicionalesIA, wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosReferencia: wizard.iaConfig!.archivosReferencia, archivosAdjuntos,
repositoriosReferencia:
wizard.iaConfig!.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
}, },
} }
console.log( console.log(
`${new Date().toISOString()} - Enviando a generar asignatura con IA`, `${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
) )
setIsSpinningIA(true) await generateSubjectAI.mutateAsync(payload as any)
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
// await new Promise((resolve) => setTimeout(resolve, 20000)) // debug
setIsSpinningIA(false)
// console.log(
// `${new Date().toISOString()} - Asignatura IA generada`,
// asignatura,
// )
navigate({ // Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`, const latest = await subjects_get_maybe(subjectId)
state: { showConfetti: true }, if (latest) {
handleSubjectReady({
id: latest.id as any,
plan_estudio_id: latest.plan_estudio_id as any,
estado: (latest as any).estado,
}) })
}
return return
} }
@@ -108,6 +309,15 @@ export function WizardControls({
const supabase = supabaseBrowser() 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( const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
(s): TablesInsert<'asignaturas'> => ({ (s): TablesInsert<'asignaturas'> => ({
plan_estudio_id: wizard.plan_estudio_id, plan_estudio_id: wizard.plan_estudio_id,
@@ -141,16 +351,33 @@ export function WizardControls({
// Disparar generación en paralelo (no bloquear navegación) // Disparar generación en paralelo (no bloquear navegación)
insertedIds.forEach((id, idx) => { insertedIds.forEach((id, idx) => {
const s = selected[idx] const s = selected[idx]
const payload: AIGenerateSubjectJsonInput = { const creditosForEdge =
typeof s.creditos === 'number' && s.creditos > 0
? s.creditos
: undefined
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id, 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, descripcionEnfoqueAcademico: s.descripcion,
// (opcionales) parches directos si el edge los usa instruccionesAdicionalesIA:
estructura_id: wizard.estructuraId, wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
linea_plan_id: s.linea_plan_id, archivosAdjuntos,
numero_ciclo: s.numero_ciclo, },
} }
void generateSubjectAI.mutateAsync(payload).catch((e) => { void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
console.error('Error generando asignatura IA (multiple):', e) console.error('Error generando asignatura IA (multiple):', e)
}) })
}) })
@@ -166,6 +393,8 @@ export function WizardControls({
resetScroll: false, resetScroll: false,
}) })
setIsSpinningIA(false)
return return
} }
@@ -195,16 +424,19 @@ export function WizardControls({
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false) setIsSpinningIA(false)
stopSubjectWatch()
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura', errorMessage: err?.message ?? 'Error creando la asignatura',
})) }))
} finally { } finally {
if (!startedWaiting) {
setIsSpinningIA(false) setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false })) setWizard((w) => ({ ...w, isLoading: false }))
} }
} }
}
return ( return (
<div className="flex grow items-center justify-between"> <div className="flex grow items-center justify-between">

View File

@@ -2,26 +2,31 @@ import { Check, Loader2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useUpdatePlanFields } from '@/data' // Tu hook existente import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
export const ImprovementCard = ({ export const ImprovementCard = ({
suggestions, suggestions,
onApply, onApply,
planId, // Necesitamos el ID planId,
currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON dbMessageId,
currentDatos,
activeChatId,
onApplySuccess,
}: { }: {
suggestions: Array<any> suggestions: Array<any>
onApply?: (key: string, value: string) => void onApply?: (key: string, value: string) => void
planId: string planId: string
currentDatos: any currentDatos: any
dbMessageId: string
activeChatId: any
onApplySuccess?: (key: string) => void
}) => { }) => {
const [appliedFields, setAppliedFields] = useState<Array<string>>([]) const [localApplied, setLocalApplied] = useState<Array<string>>([])
const updatePlan = useUpdatePlanFields() const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const handleApply = (key: string, newValue: string) => { const handleApply = (key: string, newValue: string) => {
if (!currentDatos) return if (!currentDatos) return
// 1. Lógica para preparar el valor (idéntica a tu handleSave original)
const currentValue = currentDatos[key] const currentValue = currentDatos[key]
let finalValue: any let finalValue: any
@@ -35,13 +40,11 @@ export const ImprovementCard = ({
finalValue = newValue finalValue = newValue
} }
// 2. Construir el nuevo objeto 'datos' manteniendo lo que ya existía
const datosActualizados = { const datosActualizados = {
...currentDatos, ...currentDatos,
[key]: finalValue, [key]: finalValue,
} }
// 3. Ejecutar la mutación directamente aquí
updatePlan.mutate( updatePlan.mutate(
{ {
planId: planId as any, planId: planId as any,
@@ -49,9 +52,19 @@ export const ImprovementCard = ({
}, },
{ {
onSuccess: () => { onSuccess: () => {
setAppliedFields((prev) => [...prev, key]) 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) if (onApply) onApply(key, newValue)
console.log(`Campo ${key} guardado exitosamente`)
}, },
}, },
) )
@@ -60,7 +73,7 @@ export const ImprovementCard = ({
return ( return (
<div className="mt-2 flex w-full flex-col gap-4"> <div className="mt-2 flex w-full flex-col gap-4">
{suggestions.map((sug) => { {suggestions.map((sug) => {
const isApplied = appliedFields.includes(sug.key) const isApplied = sug.applied === true || localApplied.includes(sug.key)
const isUpdating = const isUpdating =
updatePlan.isPending && updatePlan.isPending &&
updatePlan.variables.patch.datos?.[sug.key] !== undefined updatePlan.variables.patch.datos?.[sug.key] !== undefined

View File

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

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 ( return (
<textarea <textarea
ref={ref}
data-slot="textarea" data-slot="textarea"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) )
} })
Textarea.displayName = 'Textarea'
export { Textarea } export { Textarea }

View File

@@ -5,16 +5,24 @@ export function WizardResponsiveHeader({
wizard, wizard,
methods, methods,
titleOverrides, titleOverrides,
hiddenStepIds,
}: { }: {
wizard: any wizard: any
methods: any methods: any
titleOverrides?: Record<string, string> titleOverrides?: Record<string, string>
hiddenStepIds?: Array<string>
}) { }) {
const idx = wizard.utils.getIndex(methods.current.id) const hidden = new Set(hiddenStepIds ?? [])
const totalSteps = wizard.steps.length const visibleSteps = (wizard.steps as Array<any>).filter(
const currentIndex = idx + 1 (s) => s && !hidden.has(s.id),
const hasNextStep = idx < totalSteps - 1 )
const nextStep = wizard.steps[currentIndex]
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 const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
@@ -45,10 +53,11 @@ export function WizardResponsiveHeader({
<div className="hidden sm:block"> <div className="hidden sm:block">
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2"> <wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{wizard.steps.map((step: any) => ( {visibleSteps.map((step: any, visibleIdx: number) => (
<wizard.Stepper.Step <wizard.Stepper.Step
key={step.id} key={step.id}
of={step.id} of={step.id}
icon={visibleIdx + 1}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
<wizard.Stepper.Title> <wizard.Stepper.Title>

View File

@@ -100,7 +100,7 @@ export async function library_search(payload: {
export async function create_conversation(planId: string) { export async function create_conversation(planId: string) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations', 'create-chat-conversation/plan/conversations',
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
}): Promise<{ reply: string; meta?: any }> { }): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`, `create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`,
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -175,6 +175,22 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] 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( export async function update_conversation_title(
conversacionId: string, conversacionId: string,
@@ -192,3 +208,170 @@ export async function update_conversation_title(
if (error) throw error if (error) throw error
return data 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

@@ -1,27 +1,86 @@
// document.api.ts // document.api.ts
const DOCUMENT_PDF_URL = import { supabaseBrowser } from '../supabase/client'
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434' import { invokeEdge } from '../supabase/invokeEdge'
import { requireData, throwIfError } from './_helpers'
import type { Tables } from '@/types/supabase'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
interface GeneratePdfParams { interface GeneratePdfParams {
plan_estudio_id: string plan_estudio_id: string
convertTo?: 'pdf'
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
convertTo?: 'pdf'
} }
export async function fetchPlanPdf({ export async function fetchPlanPdf({
plan_estudio_id, plan_estudio_id,
convertTo,
}: GeneratePdfParams): Promise<Blob> { }: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, { return await invokeEdge<Blob>(
method: 'POST', EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
plan_estudio_id,
body: convertTo ? { convertTo } : {},
},
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ plan_estudio_id }), responseType: 'blob',
}) },
)
if (!response.ok) { }
throw new Error('Error al generar el PDF')
} export async function fetchAsignaturaPdf({
asignatura_id,
// n8n devuelve el archivo → lo tratamos como blob convertTo,
return await response.blob() }: GeneratePdfParamsAsignatura): Promise<Blob> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.select('*')
.eq('id', asignatura_id)
.single()
throwIfError(error)
const row = requireData(
data as Pick<
Tables<'asignaturas'>,
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
>,
'Asignatura no encontrada',
)
const body: Record<string, unknown> = {
data: row,
}
if (convertTo) body.convertTo = convertTo
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
asignatura_id,
body: {
...body,
},
},
{
headers: {
'Content-Type': 'application/json',
},
responseType: 'blob',
},
)
} }

View File

@@ -144,6 +144,48 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
return requireData(data, 'Plan no encontrado.') return requireData(data, 'Plan no encontrado.')
} }
/**
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
* Útil para flujos de polling donde el plan puede tardar en aparecer.
*/
export async function plans_get_maybe(
planId: UUID,
): Promise<PlanEstudio | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.eq('id', planId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as PlanEstudio | null
}
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.delete()
.eq('id', planId)
.select('id')
.maybeSingle()
throwIfError(error)
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
return { id: ((data as any)?.id ?? planId) as UUID }
}
export async function plan_lineas_list( export async function plan_lineas_list(
planId: UUID, planId: UUID,
): Promise<Array<LineaPlan>> { ): Promise<Array<LineaPlan>> {
@@ -165,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,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) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })

View File

@@ -15,12 +15,11 @@ import type {
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { import type {
AsignaturaSugerida, AsignaturaSugerida,
DataAsignaturaSugerida, DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types' } from '@/features/asignaturas/nueva/types'
import type { Database, TablesInsert } from '@/types/supabase' import type { Database, Tables, TablesInsert } from '@/types/supabase'
const EDGE = { const EDGE = {
generate_subject_suggestions: 'generate-subject-suggestions', generate_subject_suggestions: 'generate-subject-suggestions',
@@ -30,6 +29,9 @@ const EDGE = {
subjects_clone_from_existing: 'subjects_clone_from_existing', subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file', subjects_import_from_file: 'subjects_import_from_file',
// Bibliografía
buscar_bibliografia: 'buscar-bibliografia',
subjects_update_fields: 'subjects_update_fields', subjects_update_fields: 'subjects_update_fields',
subjects_update_bibliografia: 'subjects_update_bibliografia', subjects_update_bibliografia: 'subjects_update_bibliografia',
@@ -37,6 +39,82 @@ const EDGE = {
subjects_get_document: 'subjects_get_document', subjects_get_document: 'subjects_get_document',
} as const } 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 = export type ContenidoTemaApi =
| string | string
| { | {
@@ -93,7 +171,7 @@ export type PlanEstudioInSubject = Pick<
export type EstructuraAsignaturaInSubject = Pick< export type EstructuraAsignaturaInSubject = Pick<
EstructuraAsignatura, EstructuraAsignatura,
'id' | 'nombre' | 'version' | 'definicion' 'id' | 'nombre' | 'definicion'
> >
/** /**
@@ -113,12 +191,12 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
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, 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( planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
), ),
estructuras_asignatura(id,nombre,version,definicion) estructuras_asignatura(id,nombre,definicion)
`, `,
) )
.eq('id', subjectId) .eq('id', subjectId)
@@ -154,7 +232,7 @@ export async function subjects_bibliografia_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
.select( .select(
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en', 'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
) )
.eq('asignatura_id', subjectId) .eq('asignatura_id', subjectId)
.order('tipo', { ascending: true }) .order('tipo', { ascending: true })
@@ -178,54 +256,49 @@ export async function subjects_create_manual(
return requireData(data, 'No se pudo crear la asignatura.') return requireData(data, 'No se pudo crear la asignatura.')
} }
export type AIGenerateSubjectInput = { /**
plan_estudio_id: Asignatura['plan_estudio_id'] * Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
datosBasicos: { * - Siempre incluye `datosUpdate.plan_estudio_id`.
nombre: Asignatura['nombre'] * - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
codigo?: Asignatura['codigo'] * En el frontend, insertamos primero y usamos `id` para actualizar.
tipo: Asignatura['tipo'] | null */
creditos: Asignatura['creditos'] | null export type AISubjectUnifiedInput = {
horasAcademicas?: Asignatura['horas_academicas'] | null datosUpdate: Partial<{
horasIndependientes?: Asignatura['horas_independientes'] | null id: string
estructuraId: Asignatura['estructura_id'] | null plan_estudio_id: string
estructura_id: string
nombre: string
codigo: string | null
tipo: string | null
creditos: number
horas_academicas: number | null
horas_independientes: number | null
numero_ciclo: number | null
linea_plan_id: string | null
orden_celda: number | null
}> & {
plan_estudio_id: string
} }
// clonInterno?: {
// facultadId?: string
// carreraId?: string
// planOrigenId?: string
// asignaturaOrigenId?: string | null
// }
// clonTradicional?: {
// archivoWordAsignaturaId: string | null
// archivosAdicionalesIds: Array<string>
// }
iaConfig?: { iaConfig?: {
descripcionEnfoqueAcademico: string descripcionEnfoqueAcademico?: string
instruccionesAdicionalesIA: string instruccionesAdicionalesIA?: string
archivosReferencia: Array<string> archivosAdjuntos?: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
} }
} }
/** export async function subjects_get_maybe(
* Edge (JSON): actualizar/llenar una asignatura existente por id. subjectId: UUID,
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa). ): Promise<Asignatura | null> {
*/ const supabase = supabaseBrowser()
export type AIGenerateSubjectJsonInput = Partial<{
plan_estudio_id: Asignatura['plan_estudio_id'] const { data, error } = await supabase
nombre: Asignatura['nombre'] .from('asignaturas')
codigo: Asignatura['codigo'] .select('id,plan_estudio_id,estado')
tipo: Asignatura['tipo'] | null .eq('id', subjectId)
creditos: Asignatura['creditos'] .maybeSingle()
horas_academicas: Asignatura['horas_academicas'] | null
horas_independientes: Asignatura['horas_independientes'] | null throwIfError(error)
estructura_id: Asignatura['estructura_id'] | null return (data ?? null) as unknown as Asignatura | null
linea_plan_id: Asignatura['linea_plan_id'] | null
numero_ciclo: Asignatura['numero_ciclo'] | null
descripcionEnfoqueAcademico: string
}> & {
id: Asignatura['id']
} }
export type GenerateSubjectSuggestionsInput = { export type GenerateSubjectSuggestionsInput = {
@@ -263,30 +336,8 @@ export async function generate_subject_suggestions(
} }
export async function ai_generate_subject( export async function ai_generate_subject(
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput, input: AISubjectUnifiedInput,
): Promise<any> { ): Promise<any> {
if ('datosBasicos' in input) {
const edgeFunctionBody = new FormData()
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
edgeFunctionBody.append(
'iaConfig',
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
)
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
return invokeEdge<any>(
EDGE.ai_generate_subject,
edgeFunctionBody,
undefined,
supabaseBrowser(),
)
}
return invokeEdge<any>(EDGE.ai_generate_subject, input, { return invokeEdge<any>(EDGE.ai_generate_subject, input, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })
@@ -491,13 +542,9 @@ export async function lineas_delete(lineaId: string) {
return lineaId return lineaId
} }
export async function bibliografia_insert(entry: { export async function bibliografia_insert(
asignatura_id: string entry: TablesInsert<'bibliografia_asignatura'>,
tipo: 'BASICA' | 'COMPLEMENTARIA' ): Promise<Tables<'bibliografia_asignatura'>> {
cita: string
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
@@ -506,7 +553,7 @@ export async function bibliografia_insert(entry: {
.single() .single()
if (error) throw error if (error) throw error
return data return data as Tables<'bibliografia_asignatura'>
} }
export async function bibliografia_update( export async function bibliografia_update(

View File

@@ -1,20 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_plan_chat_v2, ai_plan_chat_v2,
ai_plan_improve, ai_plan_improve,
ai_subject_chat,
ai_subject_improve, ai_subject_improve,
create_conversation, create_conversation,
get_chat_history, get_chat_history,
getConversationByPlan, getConversationByPlan,
library_search, library_search,
update_conversation_status, update_conversation_status,
update_recommendation_applied_status,
update_conversation_title, 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' } from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client'
// eslint-disable-next-line node/prefer-node-protocol import type { UUID } from 'node:crypto'
import type { UUID } from 'crypto'
export function useAIPlanImprove() { export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve }) return useMutation({ mutationFn: ai_plan_improve })
@@ -87,12 +96,88 @@ export function useConversationByPlan(planId: string | null) {
}) })
} }
export function useAISubjectImprove() { export function useMessagesByChat(conversationId: string | null) {
return useMutation({ mutationFn: ai_subject_improve }) 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 useAISubjectChat() { export function useUpdateRecommendationApplied() {
return useMutation({ mutationFn: ai_subject_chat }) 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 useLibrarySearch() { export function useLibrarySearch() {
@@ -111,3 +196,142 @@ export function useUpdateConversationTitle() {
}, },
}) })
} }
// 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, useQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_generate_plan, ai_generate_plan,
@@ -12,6 +13,7 @@ import {
plan_lineas_list, plan_lineas_list,
plans_clone_from_existing, plans_clone_from_existing,
plans_create_manual, plans_create_manual,
plans_delete,
plans_generate_document, plans_generate_document,
plans_get, plans_get,
plans_get_document, plans_get_document,
@@ -25,6 +27,7 @@ import {
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api' import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import type { import type {
PlanListFilters, PlanListFilters,
@@ -71,23 +74,79 @@ export function usePlanLineas(planId: UUID | null | undefined) {
} }
export function usePlanAsignaturas(planId: UUID | null | undefined) { export function usePlanAsignaturas(planId: UUID | null | undefined) {
return useQuery({ const qc = useQueryClient()
const query = useQuery({
queryKey: planId queryKey: planId
? qk.planAsignaturas(planId) ? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null], : ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
refetchInterval: (query) => {
const data = query.state.data
if (!Array.isArray(data)) return false
const hayGenerando = data.some(
(a: any) => (a as { estado?: unknown }).estado === 'generando',
)
return hayGenerando ? 500 : false
},
refetchIntervalInBackground: true,
}) })
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( export function usePlanHistorial(
@@ -263,6 +322,23 @@ export function useTransitionPlanEstado() {
}) })
} }
export function useDeletePlanEstudio() {
const qc = useQueryClient()
return useMutation({
mutationFn: (planId: UUID) => plans_delete(planId),
onSuccess: (_ok, planId) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.removeQueries({ queryKey: qk.plan(planId) })
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
qc.removeQueries({ queryKey: qk.planLineas(planId) })
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
},
})
}
export function useGeneratePlanDocumento() { export function useGeneratePlanDocumento() {
const qc = useQueryClient() const qc = useQueryClient()

View File

@@ -13,6 +13,7 @@ export const qk = {
planesList: (filters: unknown) => ['planes', 'list', filters] as const, planesList: (filters: unknown) => ['planes', 'list', filters] as const,
plan: (planId: string) => ['planes', 'detail', planId] as const, plan: (planId: string) => ['planes', 'detail', planId] as const,
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const, planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
planAsignaturas: (planId: string) => planAsignaturas: (planId: string) =>
['planes', planId, 'asignaturas'] as const, ['planes', planId, 'asignaturas'] as const,
@@ -22,6 +23,8 @@ export const qk = {
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const, sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) => asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const, ['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) => asignaturaBibliografia: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'bibliografia'] as const, ['asignaturas', asignaturaId, 'bibliografia'] as const,
asignaturaHistorial: (asignaturaId: string) => asignaturaHistorial: (asignaturaId: string) =>

View File

@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = { export type EdgeInvokeOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string> headers?: Record<string, string>
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
} }
export class EdgeFunctionError extends Error { export class EdgeFunctionError extends Error {
@@ -26,6 +27,55 @@ export class EdgeFunctionError extends Error {
} }
} }
// Soporta base64 puro o data:...;base64,...
function decodeBase64ToUint8Array(input: string): Uint8Array {
const trimmed = input.trim()
const base64 = trimmed.startsWith('data:')
? trimmed.slice(trimmed.indexOf(',') + 1)
: trimmed
const bin = atob(base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
function stripDataUrlPrefix(input: string): string {
const trimmed = input.trim()
if (!trimmed.startsWith('data:')) return trimmed
const commaIdx = trimmed.indexOf(',')
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
}
function looksLikeBase64(s: string): boolean {
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
// base64 típico: solo chars permitidos y longitud razonable
if (t.length < 64) return false
return /^[A-Za-z0-9+/]+$/.test(t)
}
function startsWithZip(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
}
function startsWithPdf(bytes: Uint8Array): boolean {
return (
bytes.length >= 5 &&
bytes[0] === 0x25 &&
bytes[1] === 0x50 &&
bytes[2] === 0x44 &&
bytes[3] === 0x46 &&
bytes[4] === 0x2d
) // "%PDF-"
}
function binaryStringToUint8Array(input: string): Uint8Array {
const bytes = new Uint8Array(input.length)
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
return bytes
}
export async function invokeEdge<TOut>( export async function invokeEdge<TOut>(
functionName: string, functionName: string,
body?: body?:
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
): Promise<TOut> { ): Promise<TOut> {
const supabase = client ?? supabaseBrowser() const supabase = client ?? supabaseBrowser()
const { data, error } = await supabase.functions.invoke(functionName, { // Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
const invoke: any = (supabase.functions as any).invoke.bind(
supabase.functions,
)
const { data, error } = await invoke(functionName, {
body, body,
method: opts.method ?? 'POST', method: opts.method ?? 'POST',
headers: opts.headers, headers: opts.headers,
responseType: opts.responseType,
}) })
if (error) { if (error) {
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
throw new EdgeFunctionError(message, functionName, status, details) throw new EdgeFunctionError(message, functionName, status, details)
} }
if (opts.responseType === 'blob') {
const anyData: unknown = data
if (anyData instanceof Blob) {
return anyData as TOut
}
throw new EdgeFunctionError(
'La Edge Function no devolvió un binario (Blob) válido.',
functionName,
undefined,
{ receivedType: typeof anyData, received: anyData },
)
}
return data as TOut return data as TOut
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export 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()
}
export 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')
}
export const columnParsers: Partial<
Record<string, (value: unknown) => string>
> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}

View File

@@ -31,6 +31,8 @@ import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido' 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 PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva' import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
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({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
@@ -156,6 +158,18 @@ const PlanesPlanIdDetalleAsignaturasNuevaRoute =
path: '/nueva', path: '/nueva',
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute, getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
} as any) } 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 { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -174,12 +188,14 @@ export interface FileRoutesByFullPath {
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute '/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute '/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute '/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute '/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute '/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute '/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -196,12 +212,13 @@ export interface FileRoutesByTo {
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute '/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute '/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute '/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute '/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -221,12 +238,14 @@ export interface FileRoutesById {
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute '/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute '/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute '/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute '/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute '/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute '/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -253,6 +272,8 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId/historial' | '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura' | '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/' | '/planes/$planId/asignaturas/$asignaturaId/'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -269,12 +290,13 @@ export interface FileRouteTypes {
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido' | '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento' | '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial' | '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura' | '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -299,6 +321,8 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId/historial' | '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura' | '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/' | '/planes/$planId/asignaturas/$asignaturaId/'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -467,6 +491,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute 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
}
} }
} }
@@ -521,8 +559,26 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
const PlanesPlanIdDetalleRouteWithChildren = const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren) PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
interface PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute,
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute,
}
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren,
)
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren { interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
@@ -533,7 +589,7 @@ interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren = const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
{ {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute, PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute, PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:

View File

@@ -8,10 +8,11 @@ import {
Clock, Clock,
FileJson, FileJson,
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { usePlan } from '@/data'
import { fetchPlanPdf } from '@/data/api/document.api' import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() { function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' }) const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const { data: plan } = usePlan(planId)
const [pdfUrl, setPdfUrl] = useState<string | null>(null) const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
const loadPdfPreview = useCallback(async () => { const loadPdfPreview = useCallback(async () => {
try { try {
setIsLoading(true) if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId }) const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) pdfUrlRef.current = url
setPdfUrl(url) setPdfUrl(url)
} catch (error) { } catch (error) {
console.error('Error cargando preview:', error) console.error('Error cargando preview:', error)
} finally { } finally {
setIsLoading(false) if (isMountedRef.current) setIsLoading(false)
} }
}, [planId]) }, [planId])
useEffect(() => { useEffect(() => {
isMountedRef.current = true
loadPdfPreview() loadPdfPreview()
return () => { return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
@@ -51,12 +63,13 @@ function RouteComponent() {
try { try {
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId, plan_estudio_id: planId,
convertTo: 'pdf',
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = 'plan_estudios.pdf' link.download = `${planFileBaseName}.pdf`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -67,6 +80,27 @@ function RouteComponent() {
alert('No se pudo generar el PDF') alert('No se pudo generar el PDF')
} }
} }
const handleDownloadWord = async () => {
try {
const docBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${planFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
} catch (error) {
console.error(error)
alert('No se pudo generar el Word')
}
}
return ( return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6"> <div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */} {/* HEADER DE ACCIONES */}
@@ -88,12 +122,17 @@ function RouteComponent() {
> >
<RefreshCcw size={16} /> Regenerar <RefreshCcw size={16} /> Regenerar
</Button> </Button>
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button <Button
size="sm" size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800" className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Download size={16} /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleDownloadPdf} onClick={handleDownloadPdf}
> >
<Download size={16} /> Descargar PDF <Download size={16} /> Descargar PDF
@@ -139,7 +178,7 @@ function RouteComponent() {
)} )}
</div> </div>
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0"> <CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
{isLoading ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white"> <div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" /> <RefreshCcw size={40} className="animate-spin opacity-50" />
@@ -149,7 +188,7 @@ function RouteComponent() {
/* 3. VISOR DE PDF REAL */ /* 3. VISOR DE PDF REAL */
<iframe <iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`} src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl" className="h-250 w-full max-w-250 border-none shadow-2xl"
title="PDF Preview" title="PDF Preview"
/> />
) : ( ) : (
@@ -163,6 +202,24 @@ function RouteComponent() {
) )
} }
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
// Componente pequeño para las tarjetas de estado superior // Componente pequeño para las tarjetas de estado superior
function StatusCard({ function StatusCard({
icon, icon,

View File

@@ -13,6 +13,8 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
Loader2,
Sparkles,
RotateCcw, RotateCcw,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -21,14 +23,21 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard' import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAIPlanChat, useAIPlanChat,
useChatHistory,
useConversationByPlan, useConversationByPlan,
useMessagesByChat,
useUpdateConversationStatus, useUpdateConversationStatus,
useUpdateConversationTitle, useUpdateConversationTitle,
} from '@/data' } from '@/data'
@@ -67,7 +76,25 @@ interface SelectedField {
label: string label: string
value: string value: string
} }
interface EstructuraDefinicion {
properties?: {
[key: string]: {
title: string
description?: string
}
}
}
interface ChatMessageJSON {
user: 'user' | 'assistant'
message?: string
prompt?: string
refusal?: boolean
recommendations?: Array<{
campo_afectado: string
texto_mejora: string
aplicada: boolean
}>
}
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent, component: RouteComponent,
}) })
@@ -77,19 +104,16 @@ function RouteComponent() {
const { data } = usePlan(planId) const { data } = usePlan(planId)
const routerState = useRouterState() const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false) const [openIA, setOpenIA] = useState(false)
const [conversacionId, setConversacionId] = useState<string | null>(null) const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
const { mutateAsync: sendChat, isLoading } = useAIPlanChat()
const { mutate: updateStatusMutation } = useUpdateConversationStatus() const { mutate: updateStatusMutation } = useUpdateConversationStatus()
const [isSyncing, setIsSyncing] = useState(false)
const [activeChatId, setActiveChatId] = useState<string | undefined>( const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined, undefined,
) )
const { data: historyMessages, isLoading: isLoadingHistory } =
useChatHistory(activeChatId)
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
// archivos const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -105,86 +129,158 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const isInitialLoad = useRef(true)
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null) const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null) const editableRef = useRef<HTMLSpanElement>(null)
const { mutate: updateTitleMutation } = useUpdateConversationTitle() const { mutate: updateTitleMutation } = useUpdateConversationTitle()
const [isSending, setIsSending] = useState(false)
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
null,
)
const [filterQuery, setFilterQuery] = useState('')
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
if (!data?.estructuras_plan?.definicion?.properties) return [] const definicion = data?.estructuras_plan
return Object.entries(data.estructuras_plan.definicion.properties).map( ?.definicion as EstructuraDefinicion
([key, value]) => ({
// Encadenamiento opcional para evitar errores si data es null
if (!definicion.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({
key, key,
label: value.title, label: value.title,
value: String(value.description || ''), value: String(value.description || ''),
}), }))
)
}, [data]) }, [data])
useEffect(() => { const filteredFields = useMemo(() => {
// 1. Si no hay ID o está cargando el historial, no hacemos nada return availableFields.filter(
if (!activeChatId || isLoadingHistory) return (field) =>
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
)
}, [availableFields, filterQuery, selectedFields])
const messagesFromApi = historyMessages?.items || historyMessages const chatMessages = useMemo(() => {
if (!activeChatId || !mensajesDelChat) return []
if (Array.isArray(messagesFromApi)) { // flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
const flattened = messagesFromApi.map((msg) => { return mensajesDelChat.flatMap((msg: any) => {
let content = msg.content const messages = []
let suggestions: Array<any> = []
if (typeof content === 'object' && content !== null) { // 1. Mensaje del Usuario
suggestions = Object.entries(content) messages.push({
.filter(([key]) => key !== 'ai-message') id: `${msg.id}-user`,
.map(([key, value]) => ({ role: 'user',
key, content: msg.mensaje,
label: key.replace(/_/g, ' '), selectedFields: msg.campos || [], // Aquí están tus campos
newValue: value as string, })
}))
// 2. Mensaje del Asistente (si hay respuesta)
content = content['ai-message'] || JSON.stringify(content) if (msg.respuesta) {
} // Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
// Si el content es un string que parece JSON (caso común en respuestas RAW) const rawRecommendations = msg.propuesta?.recommendations || []
else if (typeof content === 'string' && content.startsWith('{')) {
try { messages.push({
const parsed = JSON.parse(content) id: `${msg.id}-ai`,
suggestions = Object.entries(parsed) dbMessageId: msg.id,
.filter(([key]) => key !== 'ai-message') role: 'assistant',
.map(([key, value]) => ({ content: msg.respuesta,
key, isRefusal: msg.is_refusal,
label: key.replace(/_/g, ' '), suggestions: rawRecommendations.map((rec: any) => {
newValue: value as string, const fieldConfig = availableFields.find(
})) (f) => f.key === rec.campo_afectado,
content = parsed['ai-message'] || content )
} catch (e) { return {
/* no es json */ key: rec.campo_afectado,
} label: fieldConfig
} ? fieldConfig.label
: rec.campo_afectado.replace(/_/g, ' '),
return { newValue: rec.texto_mejora,
...msg, applied: rec.aplicada,
content, }
suggestions, }),
type: suggestions.length > 0 ? 'improvement-card' : 'text', })
} }
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = (behavior = 'smooth') => {
if (scrollRef.current) {
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
)
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
}) })
if (!isLoading) {
setMessages(flattened.reverse())
} }
} }
}, [historyMessages, activeChatId, isLoadingHistory, isLoading]) }
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
useEffect(() => { useEffect(() => {
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes if (chatMessages.length > 0) {
if (isInitialLoad.current) {
// Si es el primer render con mensajes, vamos al final al instante
scrollToBottom('instant')
isInitialLoad.current = false
} else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth')
}
}
}, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
useEffect(() => {
if (isLoadingConv || isSending) return
const currentChatExists = activeChats.some(
(chat) => chat.id === activeChatId,
)
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined)
setMessages([])
return
}
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if ( if (
!activeChatId && !activeChatId &&
lastConversation && activeChats.length > 0 &&
lastConversation.length > 0 && !isCreationMode &&
!isCreationMode chatMessages.length === 0
) { ) {
setActiveChatId(lastConversation[0].id) setActiveChatId(activeChats[0].id)
} }
}, [lastConversation, activeChatId]) }, [
activeChats,
activeChatId,
isLoadingConv,
isSending,
messages.length,
chatMessages.length,
messages,
])
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
@@ -198,7 +294,7 @@ function RouteComponent() {
setInput((prev) => setInput((prev) =>
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
) )
}, [availableFields]) }, [availableFields, routerState.location.state])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
@@ -210,7 +306,7 @@ function RouteComponent() {
}, },
]) ])
setInput('') setInput('')
setSelectedFields([]) // setSelectedFields([])
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -227,6 +323,9 @@ function RouteComponent() {
if (activeChatId === id) { if (activeChatId === id) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
setOptimisticMessage(null)
setInput('')
setSelectedFields([])
} }
}, },
}, },
@@ -239,8 +338,6 @@ function RouteComponent() {
{ id, estado: 'ACTIVA' }, { id, estado: 'ACTIVA' },
{ {
onSuccess: () => { onSuccess: () => {
// Al invalidar la query, React Query traerá la lista fresca
// y el chat se moverá solo de "archivados" a "activos"
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], queryKey: ['conversation-by-plan', planId],
}) })
@@ -251,142 +348,123 @@ function RouteComponent() {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value const val = e.target.value
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
setInput(val) setInput(val)
// Solo abrir si termina en ":"
setShowSuggestions(val.endsWith(':')) // Busca un ":" seguido de letras justo antes del cursor
const textBeforeCursor = val.slice(0, cursorPosition)
const match = textBeforeCursor.match(/:(\w*)$/)
if (match) {
setShowSuggestions(true)
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
} else {
setShowSuggestions(false)
setFilterQuery('')
}
} }
const injectFieldsIntoInput = ( const injectFieldsIntoInput = (
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
// Quita cualquier bloque previo de campos // 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() // Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ') const fieldLabels = fields.map((f) => f.label).join(', ')
return `${cleaned}\n[Campos: ${fieldLabels}]` // 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
return `${cleaned}: ${fieldLabels}`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
let isAdding = false // 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => { setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key) const isSelected = prev.find((f) => f.key === field.key)
if (isSelected) { return isSelected ? prev : [...prev, field]
return prev.filter((f) => f.key !== field.key)
} else {
isAdding = true
return [...prev, field]
}
}) })
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
setInput((prev) => { setInput((prev) => {
const cleanPrev = prev.replace(/:/g, '').trim() // Reemplaza el último ":" y cualquier texto de filtro por el label del campo
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
if (cleanPrev === '') { return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
return `${field.label} `
}
return `${cleanPrev} ${field.label} `
}) })
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('')
} }
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => { const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
// Si no hay campos, enviamos el texto tal cual
if (fields.length === 0) return userInput if (fields.length === 0) return userInput
return `Instrucción del usuario: ${userInput}` return ` ${userInput}`
} }
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields) const finalContent = buildPrompt(rawText, currentFields)
setIsSending(true)
const userMsg = { setOptimisticMessage(finalContent)
id: Date.now().toString(),
role: 'user',
content: rawText,
}
setMessages((prev) => [...prev, userMsg])
setInput('') setInput('')
try { // setSelectedFields([])
const payload: any = {
planId: planId,
content: finalPrompt,
conversacionId: activeChatId || undefined,
}
if (currentFields.length > 0) { try {
payload.campos = currentFields.map((f) => f.key) const payload = {
planId: planId as any,
content: finalContent,
conversacionId: activeChatId,
campos:
currentFields.length > 0
? currentFields.map((f) => f.key)
: undefined,
} }
const response = await sendChat(payload) const response = await sendChat(payload)
setIsSyncing(true)
if (response.conversacionId && response.conversacionId !== activeChatId) { if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId) setActiveChatId(response.conversacionId)
}
// Esto obliga a 'useConversationByPlan' a buscar en la DB el nuevo chat creado // ESPERAMOS a que la caché se actualice antes de quitar el "isSending"
await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], queryKey: ['conversation-by-plan', planId],
}) }),
} queryClient.invalidateQueries({
queryKey: ['conversation-messages', response.conversacionId],
// --- NUEVA LÓGICA DE PARSEO --- }),
let aiText = 'Sin respuesta del asistente'
let suggestions: Array<any> = []
if (response.raw) {
try {
const rawData = JSON.parse(response.raw)
// Extraemos el mensaje conversacional
aiText = rawData['ai-message'] || 'Cambios aplicados con éxito.'
// Filtramos todo lo que no sea el mensaje para crear las sugerencias
suggestions = Object.entries(rawData)
.filter(([key]) => key !== 'ai-message')
.map(([key, value]) => ({
key,
label: key.replace(/_/g, ' '),
newValue: value as string,
}))
} catch (e) {
console.error('Error parseando el campo raw:', e)
aiText = response.raw // Fallback si no es JSON
}
}
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: 'assistant',
content: aiText,
type: suggestions.length > 0 ? 'improvement-card' : 'text',
suggestions: suggestions,
},
]) ])
} catch (error) { } catch (error) {
console.error('Error en el chat:', error) console.error('Error:', error)
setMessages((prev) => [ setOptimisticMessage(null)
...prev, } finally {
{ // Solo ahora quitamos los indicadores de carga
id: 'error', setIsSending(false)
role: 'assistant', // setOptimisticMessage(null)
content: 'Lo siento, hubo un error al procesar tu solicitud.',
},
])
} }
} }
useEffect(() => {
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
// Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
// Ahora la validación es directa y no debería dar avisos de "unnecessary"
if (ultimoMensajeDB?.respuesta) {
setIsSyncing(false)
setOptimisticMessage(null)
}
}, [mensajesDelChat, isSyncing])
const totalReferencias = useMemo(() => { const totalReferencias = useMemo(() => {
return ( return (
selectedArchivoIds.length + selectedArchivoIds.length +
@@ -395,15 +473,9 @@ function RouteComponent() {
) )
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const { activeChats, archivedChats } = useMemo(() => { const removeSelectedField = (fieldKey: string) => {
const allChats = lastConversation || [] setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
} }
}, [lastConversation])
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
@@ -438,27 +510,47 @@ function RouteComponent() {
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1"> <div className="space-y-1 pr-2">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
{!showArchived ? ( {!showArchived ? (
activeChats.map((chat) => ( activeChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${ className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900' ? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50' : 'text-slate-600 hover:bg-slate-50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto */}
<div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
// donde aparecen los botones
maskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
}}
>
{/* pr-12 reserva espacio para los botones absolutos */}
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild className="min-w-0 flex-1">
<div className="min-w-0 flex-1">
<span <span
ref={editingChatId === chat.id ? editableRef : null} ref={
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id} contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${ className={`block truncate outline-none ${
editingChatId === chat.id editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500' ? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -468,52 +560,64 @@ function RouteComponent() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
const newTitle = e.currentTarget.textContent || '' e.currentTarget.blur()
updateTitleMutation(
{ id: chat.id, titulo: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingChatId(null) setEditingChatId(null)
e.currentTarget.textContent =
e.currentTarget.textContent = chat.nombre || '' chat.nombre || ''
} }
}} }}
onBlur={(e) => { onBlur={(e) => {
if (editingChatId === chat.id) { if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || '' const newTitle =
if (newTitle !== chat.nombre) { e.currentTarget.textContent?.trim() || ''
updateTitleMutation({ id: chat.id, nombre: newTitle }) if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
} }
setEditingChatId(null) setEditingChatId(null)
} }
}} }}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
> >
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} {chat.nombre ||
`Chat ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
</TooltipTrigger>
{editingChatId !== chat.id && (
<TooltipContent
side="right"
className="max-w-[280px] break-all"
>
{chat.nombre || 'Conversación'}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{/* ACCIONES */} {/* LADO DERECHO: Acciones ABSOLUTAS */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100"> <div
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
}`}
>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingChatId(chat.id) setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="p-1 text-slate-400 hover:text-teal-600" className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
> >
<Send size={12} className="rotate-45" /> <Send size={12} className="rotate-45" />
</button> </button>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="p-1 text-slate-400 hover:text-amber-600" className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -521,24 +625,26 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* ... Resto del código de archivados (sin cambios) ... */ /* Sección de archivados (Simplificada para mantener consistencia) */
<div className="animate-in fade-in slide-in-from-left-2"> <div className="animate-in fade-in slide-in-from-left-2 px-1">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedChats.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="group relative mb-1 flex w-full items-center overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
> >
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8"> <span className="block truncate">
{chat.nombre || {chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`} `Archivado ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600" className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -576,46 +682,110 @@ function RouteComponent() {
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => ( {!activeChatId &&
chatMessages.length === 0 &&
!optimisticMessage ? (
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus
size={48}
className="mb-4 text-slate-300"
/>
<h3 className="text-lg font-medium text-slate-900">
No hay un chat seleccionado
</h3>
<p className="text-sm text-slate-500">
Selecciona un chat del historial o crea uno nuevo para
empezar.
</p>
</div>
) : (
<>
{chatMessages.map((msg: any) => {
const isAI = msg.role === 'assistant'
const isUser = msg.role === 'user'
// IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
const isProcessing = msg.isProcessing
return (
<div <div
key={msg.id} key={msg.id}
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'ml-auto items-end' : 'items-start'}`} className={`flex max-w-[85%] flex-col ${
> isUser ? 'ml-auto items-end' : 'items-start'
<div
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: 'rounded-tl-none border bg-white text-slate-700'
}`} }`}
> >
{/* Contenido de texto normal */} <div
{msg.content} className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
isUser
? 'rounded-tr-none bg-teal-600 text-white'
: `rounded-tl-none border bg-white text-slate-700 ${
msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
}`
}`}
>
{/* Aviso de Refusal */}
{msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span>
</div>
)}
{/* Si el mensaje tiene sugerencias (ImprovementCard) */} {/* CONTENIDO CORRECTO: Usamos msg.content */}
{msg.suggestions && msg.suggestions.length > 0 && ( {isAI && isProcessing ? (
<div className="flex items-center gap-2 py-1">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
</div>
</div>
) : (
msg.content // <--- CAMBIO CLAVE
)}
{/* Recomendaciones */}
{isAI && msg.suggestions?.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<ImprovementCard <ImprovementCard
suggestions={msg.suggestions} suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap
planId={planId} // Del useParams() dbMessageId={msg.dbMessageId}
currentDatos={data?.datos} // De tu query usePlan(planId) planId={planId}
onApply={(key, val) => { currentDatos={data?.datos}
// Esto es opcional, si quieres hacer algo más en la UI del chat activeChatId={activeChatId}
console.log( onApplySuccess={(key) =>
'Evento onApply disparado desde el chat', removeSelectedField(key)
) }
}}
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>
))} )
{isLoading && ( })}
<div className="flex gap-2 p-4">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" /> {(isSending || isSyncing) && (
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" /> <div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" /> <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>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}
</>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -646,25 +816,35 @@ function RouteComponent() {
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */} {/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
Seleccionar campo para IA Resultados para "{filterQuery}"
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => ( {filteredFields.length > 0 ? (
filteredFields.map((field, index) => (
<button <button
key={field.key} key={field.key}
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50" className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
index === 0
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
: 'hover:bg-slate-50'
}`}
> >
<span className="text-slate-700 group-hover:text-teal-700"> <span>{field.label}</span>
{field.label} {index === 0 && (
<span className="font-mono text-[10px] opacity-50">
TAB
</span> </span>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)} )}
</button> </button>
))} ))
) : (
<div className="p-3 text-center text-xs text-slate-400">
No hay coincidencias
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -696,6 +876,35 @@ function RouteComponent() {
<Textarea <Textarea
value={input} value={input}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={(e) => {
if (showSuggestions) {
if (e.key === 'Tab' || e.key === 'Enter') {
if (filteredFields.length > 0) {
e.preventDefault()
toggleField(filteredFields[0])
}
}
if (e.key === 'Escape') {
e.preventDefault()
setShowSuggestions(false)
setFilterQuery('')
}
} else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if (
e.key === 'Backspace' &&
input === '' &&
selectedFields.length > 0
) {
setSelectedFields((prev) => prev.slice(0, -1))
}
}
if (e.key === 'Enter' && !e.shiftKey && !showSuggestions) {
e.preventDefault()
if (!isSending) handleSend()
}
}}
placeholder={ placeholder={
selectedFields.length > 0 selectedFields.length > 0
? 'Escribe instrucciones adicionales...' ? 'Escribe instrucciones adicionales...'
@@ -706,12 +915,16 @@ function RouteComponent() {
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={
(!input.trim() && selectedFields.length === 0) || isLoading isSending || (!input.trim() && selectedFields.length === 0)
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700" className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
> >
<Send size={16} className="text-white" /> {isSending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,6 @@ import {
Plus, Plus,
ChevronDown, ChevronDown,
AlertTriangle, AlertTriangle,
GripVertical,
Trash2, Trash2,
Pencil, Pencil,
} from 'lucide-react' } from 'lucide-react'
@@ -46,16 +45,33 @@ import {
useUpdateAsignatura, useUpdateAsignatura,
useUpdateLinea, useUpdateLinea,
} from '@/data' } from '@/data'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const palette = [
'#4F46E5', // índigo
'#7C3AED', // violeta
'#EA580C', // naranja
'#059669', // esmeralda
'#DC2626', // rojo
'#0891B2', // cyan
'#CA8A04', // ámbar
'#C026D3', // fucsia
]
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
lineasApi: Array<any> = [], lineasApi: Array<any> = [],
): Array<LineaCurricular> => { ): Array<LineaCurricular> => {
return lineasApi.map((linea) => ({ return lineasApi.map((linea, index) => ({
id: linea.id, id: linea.id,
nombre: linea.nombre, nombre: linea.nombre,
orden: linea.orden ?? 0, orden: linea.orden ?? 0,
color: '#1976d2', color: palette[index % palette.length],
})) }))
} }
@@ -76,7 +92,7 @@ const mapAsignaturasToAsignaturas = (
// Mapeo directo de los nuevos campos de la API // Mapeo directo de los nuevos campos de la API
hd: asig.horas_academicas ?? 0, hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0, hi: asig.horas_independientes ?? 0,
prerrequisitos: [], prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
} }
}) })
} }
@@ -121,52 +137,216 @@ function StatItem({
) )
} }
import * as Icons from 'lucide-react'
const estadoConfig: Record<
Asignatura['estado'],
{
label: string
dot: string
soft: string
icon: React.ComponentType<{ className?: string }>
}
> = {
borrador: {
label: 'Borrador',
dot: 'bg-slate-500',
soft: 'bg-slate-100 text-slate-700',
icon: Icons.FileText,
},
revisada: {
label: 'Revisada',
dot: 'bg-amber-500',
soft: 'bg-amber-100 text-amber-700',
icon: Icons.ScanSearch,
},
aprobada: {
label: 'Aprobada',
dot: 'bg-emerald-500',
soft: 'bg-emerald-100 text-emerald-700',
icon: Icons.BadgeCheck,
},
generando: {
label: 'Generando',
dot: 'bg-sky-500',
soft: 'bg-sky-100 text-sky-700',
icon: Icons.LoaderCircle,
},
}
function hexToRgba(hex: string, alpha: number) {
const clean = hex.replace('#', '')
const bigint = parseInt(clean, 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function AsignaturaCardItem({ function AsignaturaCardItem({
asignatura, asignatura,
lineaColor,
lineaNombre,
onDragStart, onDragStart,
isDragging, isDragging,
onClick, onClick,
}: { }: {
asignatura: Asignatura asignatura: Asignatura
lineaColor: string
lineaNombre?: string
onDragStart: (e: React.DragEvent, id: string) => void onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean isDragging: boolean
onClick: () => void onClick: () => void
}) { }) {
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
const EstadoIcon = estado.icon
return ( return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button <button
draggable draggable
onDragStart={(e) => onDragStart(e, asignatura.id)} onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick} onClick={onClick}
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${ className={[
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
'transition-all duration-300 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'active:cursor-grabbing cursor-grab',
isDragging isDragging
? 'scale-95 opacity-40' ? 'scale-[0.985] opacity-45 shadow-none'
: 'hover:border-teal-400 hover:shadow-md' : 'hover:-translate-y-1 hover:shadow-lg',
}`} ].join(' ')}
style={{
borderColor: hexToRgba(lineaColor, 0.18),
background: `
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
`,
}}
title={asignatura.nombre}
> >
<div className="mb-1 flex items-start justify-between"> {/* franja */}
<span className="font-mono text-[10px] font-bold text-slate-400"> <div
{asignatura.clave} className="absolute inset-x-0 top-0 h-2"
</span> style={{ backgroundColor: lineaColor }}
<Badge />
variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`} {/* glow decorativo */}
<div
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl transition-transform duration-500 group-hover:scale-110"
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
/>
<div className="relative flex h-full flex-col p-4">
{/* top */}
<div className="flex items-start justify-between gap-2">
<div
className="inline-flex h-8 max-w-[200px] items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
style={{
borderColor: hexToRgba(lineaColor, 0.2),
backgroundColor: hexToRgba(lineaColor, 0.1),
color: lineaColor,
}}
> >
{asignatura.estado} <Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
</Badge> <span className="truncate">{asignatura.clave || 'Sin clave'}</span>
</div> </div>
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
{asignatura.nombre} <div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
</p> <div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
<div className="mt-2 flex items-center justify-between"> <span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
<span className="text-[10px] text-slate-500"> <EstadoIcon
{asignatura.creditos} CR HD:{asignatura.hd} HI:{asignatura.hi} className={[
</span> 'h-3.5 w-3.5 text-foreground/65',
<GripVertical asignatura.estado === 'generando' ? 'animate-spin' : '',
size={12} ].join(' ')}
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
/> />
</div> </div>
<div
className={[
'absolute right-2 flex translate-x-6 items-center gap-1.5 opacity-0 transition-all duration-300',
'group-hover:translate-x-0 group-hover:opacity-100'
].join(' ')}
>
<span className="text-[11px] font-semibold whitespace-nowrap">
{estado.label}
</span>
</div>
</div>
</div>
{/* titulo */}
<div className="mt-4 min-h-[72px]">
<h3
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{asignatura.nombre}
</h3>
</div>
{/* bottom */}
<div className="mt-auto grid grid-cols-3 gap-2">
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Icons.Award className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
CR
</span>
</div>
<div className="text-sm font-bold text-foreground">
{asignatura.creditos}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Icons.Clock3 className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
HD
</span>
</div>
<div className="text-sm font-bold text-foreground">
{asignatura.hd}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Icons.BookOpenText className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
HI
</span>
</div>
<div className="text-sm font-bold text-foreground">
{asignatura.hi}
</div>
</div>
</div>
{/* drag affordance */}
<div className="pointer-events-none absolute right-3 bottom-3 rounded-full bg-background/70 p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
<Icons.GripVertical className="h-4 w-4 text-muted-foreground/55" />
</div>
</div>
</button> </button>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="text-xs">
{lineaNombre ? `${lineaNombre} · ` : ''}
{asignatura.nombre}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) )
} }
@@ -336,6 +516,7 @@ function MapaCurricularPage() {
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes'] horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo'] numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id'] linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
prerrequisito_asignatura_id?: string | null
} }
const patch: Partial<AsignaturaPatch> = { const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre, nombre: editingData.nombre,
@@ -345,6 +526,7 @@ function MapaCurricularPage() {
horas_independientes: editingData.hi, horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo, numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId, linea_plan_id: editingData.lineaCurricularId,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
} }
@@ -490,7 +672,7 @@ function MapaCurricularPage() {
e: React.FocusEvent<HTMLSpanElement>, e: React.FocusEvent<HTMLSpanElement>,
id: string, id: string,
) => { ) => {
const nuevoNombre = e.currentTarget.textContent?.trim() || '' const nuevoNombre = e.currentTarget.textContent.trim() || ''
// Buscamos la línea original para comparar // Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id) const lineaOriginal = lineas.find((l) => l.id === id)
@@ -616,8 +798,7 @@ function MapaCurricularPage() {
return ( return (
<Fragment key={linea.id}> <Fragment key={linea.id}>
<div <div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${ className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`} } ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
> >
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
@@ -633,8 +814,7 @@ function MapaCurricularPage() {
setTempNombreLinea(linea.nombre) setTempNombreLinea(linea.nombre)
} }
}} }}
className={`block w-full text-xs font-bold break-words outline-none ${ className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1' ? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
@@ -675,6 +855,8 @@ function MapaCurricularPage() {
<AsignaturaCardItem <AsignaturaCardItem
key={m.id} key={m.id}
asignatura={m} asignatura={m}
lineaColor={linea.color || '#1976d2'}
lineaNombre={linea.nombre}
isDragging={draggedAsignatura === m.id} isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { onClick={() => {
@@ -725,45 +907,81 @@ function MapaCurricularPage() {
</div> </div>
{/* Asignaturas Sin Asignar */} {/* Asignaturas Sin Asignar */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6"> <div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
<div className="mb-4 flex items-center justify-between"> <div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2 text-slate-600"> <div className="min-w-0">
<h3 className="text-sm font-bold tracking-wider uppercase"> <div className="flex items-center gap-2">
Bandeja de Entrada / Asignaturas sin asignar <div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
</h3> <Icons.Inbox className="h-4.5 w-4.5" />
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
</div> </div>
<p className="text-xs text-slate-400">
Arrastra una asignatura aquí para quitarla del mapa <div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold tracking-wide text-foreground uppercase">
Bandeja de entrada
</h3>
<div className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-[11px] font-semibold text-muted-foreground">
{unassignedAsignaturas.length}
</div>
</div>
<p className="mt-0.5 text-sm text-muted-foreground">
Asignaturas sin ciclo o línea curricular
</p> </p>
</div> </div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-dashed border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground">
<Icons.MoveDown className="h-3.5 w-3.5" />
<span>Arrastra aquí para desasignar</span>
</div>
</div>
<div <div
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
draggedAsignatura
? 'border-teal-300 bg-teal-50/50'
: 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea onDrop={(e) => handleDrop(e, null, null)}
className={[
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
'min-h-[220px]',
draggedAsignatura
? 'border-primary/35 bg-primary/6 shadow-inner'
: 'border-border bg-muted/20',
].join(' ')}
> >
{unassignedAsignaturas.length > 0 ? (
<div className="flex flex-wrap gap-4">
{unassignedAsignaturas.map((m) => ( {unassignedAsignaturas.map((m) => (
<div key={m.id} className="w-[200px]"> <div key={m.id} className="w-[272px] shrink-0">
<AsignaturaCardItem <AsignaturaCardItem
asignatura={m} asignatura={m}
lineaColor="#94A3B8"
lineaNombre="Sin asignar"
isDragging={draggedAsignatura === m.id} isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición setEditingData(m)
setIsEditModalOpen(true) setIsEditModalOpen(true)
}} }}
/> />
</div> </div>
))} ))}
{unassignedAsignaturas.length === 0 && ( </div>
<div className="flex w-full items-center justify-center text-sm text-slate-400"> ) : (
No hay asignaturas pendientes. Arrastra una asignatura aquí para <div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
desasignarla. <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icons.CheckCheck className="h-5 w-5" />
</div>
<p className="text-sm font-semibold text-foreground">
No hay asignaturas pendientes
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
ciclo y línea curricular.
</p>
</div> </div>
)} )}
</div> </div>
@@ -935,65 +1153,55 @@ function MapaCurricularPage() {
{/* Fila 4: Seriación (Prerrequisitos) */} {/* Fila 4: Seriación (Prerrequisitos) */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisitos) Seriación (Prerrequisito)
</label> </label>
<Select <Select
value={seriacionValue} // Cambiamos a manejo de valor único basado en el ID de la columna
value={editingData.prerrequisito_asignatura_id || undefined}
onValueChange={(val) => { onValueChange={(val) => {
if (val === 'none') { console.log(editingData)
setSeriacionValue('')
return
}
if (!editingData.prerrequisitos.includes(val)) {
setEditingData({ setEditingData({
...editingData, ...editingData,
prerrequisitos: [...editingData.prerrequisitos, val], prerrequisito_asignatura_id: val === 'none' ? null : val,
}) })
}
setSeriacionValue('')
}} }}
> >
<SelectTrigger> <SelectTrigger className="w-full bg-white">
<SelectValue placeholder="Seleccionar asignatura..." /> <SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">-- Sin Seriación --</SelectItem> <SelectItem value="none">-- Sin Seriación --</SelectItem>
{asignaturas {asignaturas
.filter((m) => m.id !== editingData.id) .filter((asig) => {
.map((m) => ( // 1. No es la misma materia
<SelectItem key={m.id} value={m.id}> const noEsMisma = asig.id !== editingData.id
{m.nombre} ({m.clave}) // 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> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* Visualización de los prerrequisitos seleccionados */} {/* Visualización del Prerrequisito con el Nombre */}
<div className="mt-2 flex flex-wrap gap-2">
{editingData.prerrequisitos.map((pre) => (
<Badge
key={pre}
variant="secondary"
className="bg-slate-100 text-slate-600"
>
{pre}
<button
className="ml-1 hover:text-red-500"
onClick={() => {
setEditingData({
...editingData,
prerrequisitos: editingData.prerrequisitos.filter(
(p) => p !== pre,
),
})
}}
>
×
</button>
</Badge>
))}
</div>
</div> </div>
{/* Fila 5: Tipo */} {/* Fila 5: Tipo */}

View File

@@ -1,6 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, Outlet } from '@tanstack/react-router'
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia', '/planes/$planId/asignaturas/$asignaturaId/bibliografia',
@@ -9,5 +7,5 @@ export const Route = createFileRoute(
}) })
function RouteComponent() { function RouteComponent() {
return <BibliographyItem /> 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

@@ -1,8 +1,9 @@
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab' import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { fetchPlanPdf } from '@/data/api/document.api' import { useSubject } from '@/data'
import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento', '/planes/$planId/asignaturas/$asignaturaId/documento',
@@ -11,52 +12,79 @@ export const Route = createFileRoute(
}) })
function RouteComponent() { function RouteComponent() {
const { planId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento', from: '/planes/$planId/asignaturas/$asignaturaId/documento',
}) })
const { data: asignatura } = useSubject(asignaturaId)
const asignaturaFileBaseName = sanitizeFileBaseName(
asignatura?.nombre ?? 'documento_sep',
)
const [pdfUrl, setPdfUrl] = useState<string | null>(null) const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false) const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => { const loadPdfPreview = useCallback(async () => {
try { try {
setIsLoading(true) if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchAsignaturaPdf({
plan_estudio_id: planId, asignatura_id: asignaturaId,
convertTo: 'pdf',
}) })
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => { if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
if (prev) window.URL.revokeObjectURL(prev) pdfUrlRef.current = url
return url setPdfUrl(url)
})
} catch (error) { } catch (error) {
console.error('Error cargando PDF:', error) console.error('Error cargando PDF:', error)
} finally { } finally {
setIsLoading(false) if (isMountedRef.current) setIsLoading(false)
} }
}, [planId]) }, [asignaturaId])
useEffect(() => { useEffect(() => {
isMountedRef.current = true
loadPdfPreview() loadPdfPreview()
return () => { return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
const handleDownload = async () => { const handleDownloadPdf = async () => {
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchAsignaturaPdf({
plan_estudio_id: planId, asignatura_id: asignaturaId,
convertTo: 'pdf',
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = 'documento_sep.pdf' link.download = `${asignaturaFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleDownloadWord = async () => {
const docBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${asignaturaFileBaseName}.docx`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
@@ -77,9 +105,28 @@ function RouteComponent() {
<DocumentoSEPTab <DocumentoSEPTab
pdfUrl={pdfUrl} pdfUrl={pdfUrl}
isLoading={isLoading} isLoading={isLoading}
onDownload={handleDownload} onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
isRegenerating={isRegenerating} isRegenerating={isRegenerating}
/> />
) )
} }
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}

View File

@@ -2,6 +2,7 @@ import {
createFileRoute, createFileRoute,
Outlet, Outlet,
Link, Link,
useLocation,
useParams, useParams,
useRouterState, useRouterState,
} from '@tanstack/react-router' } from '@tanstack/react-router'
@@ -9,6 +10,7 @@ import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data' import { useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -62,8 +64,7 @@ interface DatosPlan {
} }
function AsignaturaLayout() { function AsignaturaLayout() {
const routerState = useRouterState() const location = useLocation()
const state = routerState.location.state as any
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
@@ -117,6 +118,14 @@ function AsignaturaLayout() {
select: (state) => state.location.pathname, 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) { if (loadingAsig) {
return ( return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white"> <div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
@@ -130,7 +139,7 @@ function AsignaturaLayout() {
return ( return (
<div> <div>
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> <section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10"> <div className="mx-auto max-w-7xl px-6 py-10">
<Link <Link
to="/planes/$planId/asignaturas" to="/planes/$planId/asignaturas"
@@ -157,30 +166,20 @@ function AsignaturaLayout() {
onSave={(val) => handleUpdateHeader('nombre', val)} onSave={(val) => handleUpdateHeader('nombre', val)}
/> />
</h1> </h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" /> <GraduationCap className="h-4 w-4 shrink-0" />
Pertenece al plan:{' '}
<span className="text-blue-100"> <span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan) {(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre ?? ''}
</span> </span>
</span> </span>
</div> </div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div> </div>
<div className="flex flex-col items-end gap-2 text-right"> <div className="flex flex-col items-end gap-2 text-right">
@@ -223,7 +222,7 @@ function AsignaturaLayout() {
{ label: 'Datos', to: '' }, { label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' }, { label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' }, { label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'asignaturaIa' }, { label: 'IA', to: 'iaasignatura' },
{ label: 'Documento SEP', to: 'documento' }, { label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' }, { label: 'Historial', to: 'historial' },
].map((tab) => { ].map((tab) => {

View File

@@ -4,18 +4,145 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
/* Serif */
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
body { body {
@apply m-0; @apply m-0;
font-family: font-family: var(--font-sans);
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: font-family: var(--font-mono);
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; }
strong,
b,
.font-bold {
font-family: 'Indivisa Sans', serif;
font-weight: 900;
/* Inter letter space */
letter-spacing: -0.025em;
} }
:root { :root {
@@ -51,9 +178,9 @@ code {
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698); --sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
--sidebar-border: oklch(0.9401 0 0); --sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0 0 0); --sidebar-ring: oklch(0 0 0);
--font-sans: Plus Jakarta Sans, sans-serif; --font-sans: 'Indivisa Sans', sans-serif;
--font-serif: Lora, serif; --font-serif: 'Indivisa Serif', serif;
--font-mono: IBM Plex Mono, monospace; --font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem; --radius: 1.4rem;
--shadow-x: 0px; --shadow-x: 0px;
--shadow-y: 2px; --shadow-y: 2px;
@@ -101,7 +228,7 @@ code {
--chart-1: oklch(0.6686 0.1794 251.7436); --chart-1: oklch(0.6686 0.1794 251.7436);
--chart-2: oklch(0.6342 0.2516 22.4415); --chart-2: oklch(0.6342 0.2516 22.4415);
--chart-3: oklch(0.8718 0.1716 90.9505); --chart-3: oklch(0.8718 0.1716 90.9505);
--chart-4: oklch(0.4503 0.229 263.0881); --chart-4: oklch(11.492% 0.00001 271.152);
--chart-5: oklch(0.8322 0.146 185.9404); --chart-5: oklch(0.8322 0.146 185.9404);
--sidebar: oklch(0.1564 0.0688 261.2771); --sidebar: oklch(0.1564 0.0688 261.2771);
--sidebar-foreground: oklch(0.9551 0 0); --sidebar-foreground: oklch(0.9551 0 0);
@@ -111,9 +238,9 @@ code {
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583); --sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
--sidebar-border: oklch(0.3289 0.0092 268.3843); --sidebar-border: oklch(0.3289 0.0092 268.3843);
--sidebar-ring: oklch(0.6048 0.2166 257.2136); --sidebar-ring: oklch(0.6048 0.2166 257.2136);
--font-sans: Plus Jakarta Sans, sans-serif; --font-sans: 'Indivisa Sans', sans-serif;
--font-serif: Lora, serif; --font-serif: 'Indivisa Serif', serif;
--font-mono: IBM Plex Mono, monospace; --font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem; --radius: 1.4rem;
--shadow-x: 0px; --shadow-x: 0px;
--shadow-y: 2px; --shadow-y: 2px;

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

@@ -50,7 +50,7 @@ export interface Asignatura {
orden?: number orden?: number
hd: number // <--- Añadir hd: number // <--- Añadir
hi: number // <--- Añadir hi: number // <--- Añadir
prerrequisitos: Array<string> prerrequisito_asignatura_id: string | null
} }
export interface Plan { export interface Plan {

File diff suppressed because it is too large Load Diff

14
staticwebapp.config.json Normal file
View File

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

View File

@@ -2,6 +2,7 @@
"include": [ "include": [
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
"**/*.d.ts",
"eslint.config.js", "eslint.config.js",
"prettier.config.js", "prettier.config.js",
"vite.config.ts" "vite.config.ts"