34 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
88c6dc6b4d wip 2026-03-12 13:47:56 -06:00
39 changed files with 2327 additions and 683 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"

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",
@@ -138,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=="],
@@ -250,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=="],

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",

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

@@ -15,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
@@ -38,6 +39,10 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos datos: AsignaturaDatos
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
type CriterioEvaluacionRow = { type CriterioEvaluacionRow = {
criterio: string criterio: string
porcentaje: number porcentaje: number
@@ -791,80 +796,3 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
</div> </div>
) )
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}

View File

@@ -1,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),
temas: Array.isArray(u.temas) )
? u.temas.map((t: any, tidx: number) => ({
id: `t-${u.unidad || idx + 1}-${tidx + 1}`, // Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', const incomingPayload = JSON.stringify(
horasEstimadas: t?.horasEstimadas || 0, contenido.map((u, idx) => ({
})) unidad: u.unidad || idx + 1,
: [], titulo: u.titulo || 'Sin título',
})) temas: Array.isArray(u.temas)
? u.temas.map((t) => {
if (typeof t === 'string') {
return {
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,158 +674,182 @@ export function ContenidoTematico() {
</div> </div>
</div> </div>
<div className="space-y-4"> <DragDropProvider onDragEnd={handleReorderEnd}>
{unidades.map((unidad) => ( <div className="space-y-4">
<div {unidades.map((unidad, index) => (
key={unidad.id} <SortableUnidad
ref={(el) => { key={unidad.id}
if (el) unitContainerRefs.current.set(unidad.id, el) id={unidad.id}
else unitContainerRefs.current.delete(unidad.id) index={index}
}} registerContainer={(el) => {
> if (el) unitContainerRefs.current.set(unidad.id, el)
<Card className="overflow-hidden border-slate-200 shadow-sm"> else unitContainerRefs.current.delete(unidad.id)
<Collapsible }}
open={expandedUnits.has(unidad.id)} >
onOpenChange={() => toggleUnit(unidad.id)} {({ handleRef }) => (
> <>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3"> {index === 0 && (
<div className="flex items-center gap-3"> <InsertUnidadOverlay
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" /> position="top"
<CollapsibleTrigger asChild> onInsert={() => insertUnidadAt(index)}
<Button variant="ghost" size="sm" className="h-auto p-0"> />
{expandedUnits.has(unidad.id) ? ( )}
<ChevronDown className="h-4 w-4" /> <InsertUnidadOverlay
) : ( position="bottom"
<ChevronRight className="h-4 w-4" /> onInsert={() => insertUnidadAt(index + 1)}
)} />
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
{editingUnit === unidad.id ? ( <Card className="overflow-hidden border-slate-200 shadow-sm">
<Input <Collapsible
ref={unitTitleInputRef} open={expandedUnits.has(unidad.id)}
value={unitDraftNombre} onOpenChange={() => toggleUnit(unidad.id)}
onChange={(e) => setUnitDraftNombre(e.target.value)} >
onBlur={() => { <CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
if (cancelNextBlurRef.current) { <div className="flex items-center gap-3">
cancelNextBlurRef.current = false <span
return ref={handleRef as any}
} className="inline-flex cursor-grab touch-none items-center text-slate-300"
commitEditUnit() aria-label="Reordenar unidad"
}} >
onKeyDown={(e) => { <GripVertical className="h-4 w-4" />
if (e.key === 'Enter') { </span>
e.preventDefault() <CollapsibleTrigger asChild>
e.currentTarget.blur() <Button
return variant="ghost"
} size="sm"
if (e.key === 'Escape') { className="h-auto cursor-pointer p-0"
e.preventDefault() >
cancelNextBlurRef.current = true {expandedUnits.has(unidad.id) ? (
cancelEditUnit() <ChevronDown className="h-4 w-4" />
e.currentTarget.blur() ) : (
} <ChevronRight className="h-4 w-4" />
}} )}
className="h-8 max-w-md bg-white" </Button>
/> </CollapsibleTrigger>
) : ( <Badge className="bg-blue-600 font-mono">
<CardTitle Unidad {unidad.numero}
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600" </Badge>
onClick={() => beginEditUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
<div className="ml-auto flex items-center gap-3"> {editingUnit === unidad.id ? (
<span className="flex items-center gap-1 text-xs font-medium text-slate-400"> <Input
<Clock className="h-3 w-3" />{' '} ref={unitTitleInputRef}
{unidad.temas.reduce( value={unitDraftNombre}
(sum, t) => sum + (t.horasEstimadas || 0), onChange={(e) =>
0, setUnitDraftNombre(e.target.value)
)} }
h onBlur={() => {
</span> if (cancelNextBlurRef.current) {
<Button cancelNextBlurRef.current = false
variant="ghost" return
size="icon" }
className="h-8 w-8 text-slate-400 hover:text-red-500" commitEditUnit()
onClick={() => }}
setDeleteDialog({ type: 'unidad', id: unidad.id }) onKeyDown={(e) => {
} if (e.key === 'Enter') {
> e.preventDefault()
<Trash2 className="h-4 w-4" /> e.currentTarget.blur()
</Button> return
</div> }
</div> if (e.key === 'Escape') {
</CardHeader> e.preventDefault()
<CollapsibleContent> cancelNextBlurRef.current = true
<CardContent className="bg-white pt-4"> cancelEditUnit()
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4"> e.currentTarget.blur()
{unidad.temas.map((tema, idx) => ( }
<TemaRow }}
key={tema.id} className="h-8 max-w-md bg-white"
tema={tema} />
index={idx + 1} ) : (
isEditing={ <CardTitle
!!editingTema && className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
editingTema.unitId === unidad.id && onClick={() => beginEditUnit(unidad.id)}
editingTema.temaId === tema.id >
} {unidad.nombre}
draftNombre={temaDraftNombre} </CardTitle>
draftHoras={temaDraftHoras} )}
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={handleTemaEditorBlurCapture}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</div>
))}
</div>
<div className="flex justify-center pt-2"> <div className="ml-auto flex items-center gap-3">
<Button <span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
variant="outline" <Clock className="h-3 w-3" />{' '}
className="gap-2" {unidad.temas.reduce(
onClick={(e) => { (sum, t) => sum + (t.horasEstimadas || 0),
// Evita que Enter vuelva a disparar el click sobre el botón. 0,
e.currentTarget.blur() )}
addUnidad() h
}} </span>
> <Button
<Plus className="h-4 w-4" /> Nueva unidad variant="ghost"
</Button> size="icon"
</div> className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
onClick={() =>
setDeleteDialog({
type: 'unidad',
id: unidad.id,
})
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="bg-white pt-4">
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => (
<TemaRow
key={tema.id}
tema={tema}
index={idx + 1}
isEditing={
!!editingTema &&
editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
}
draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras}
onBeginEdit={() =>
beginEditTema(unidad.id, tema.id)
}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={
handleTemaEditorBlurCapture
}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</>
)}
</SortableUnidad>
))}
</div>
</DragDropProvider>
<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" />

View File

@@ -28,6 +28,12 @@ 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 {
useAISubjectChat, useAISubjectChat,
useConversationBySubject, useConversationBySubject,
@@ -371,11 +377,8 @@ export function IAAsignaturaTab({
<Button <Button
onClick={() => { onClick={() => {
// 1. Limpiamos el ID
setActiveChatId(undefined) setActiveChatId(undefined)
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
hasInitialSelected.current = true hasInitialSelected.current = true
// 3. Limpiamos estados visuales
setIsCreatingNewChat(true) setIsCreatingNewChat(true)
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
@@ -389,29 +392,34 @@ export function IAAsignaturaTab({
<MessageSquarePlus size={18} /> Nuevo Chat <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-3"> <div className="flex flex-col gap-1 pr-3">
{/* CORRECCIÓN: Mapear ambos casos */} {' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => ( {(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div <div
key={chat.id} key={chat.id}
className={cn( className={cn(
'group relative flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all', // Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id activeChatId === chat.id
? 'bg-teal-50 text-teal-900' ? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100', : 'text-slate-600 hover:bg-slate-100',
)} )}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
> >
<FileText size={14} className="shrink-0 opacity-50" />
{editingId === chat.id ? ( {editingId === chat.id ? (
<div className="flex flex-1 items-center gap-1"> <div className="flex min-w-0 flex-1 items-center">
<input <input
autoFocus autoFocus
className="w-full rounded bg-white px-1 text-xs ring-1 ring-teal-400 outline-none" className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName} value={tempName}
onChange={(e) => setTempName(e.target.value)} onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)} // Guardar al hacer clic fuera onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id) if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null) if (e.key === 'Escape') setEditingId(null)
@@ -420,54 +428,78 @@ export function IAAsignaturaTab({
</div> </div>
) : ( ) : (
<> <>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span <span
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className="flex-1 cursor-pointer truncate" className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
> >
{/* CORRECCIÓN: Usar 'nombre' si así se llama en tu DB */}
{chat.nombre || chat.titulo || 'Conversación'} {chat.nombre || chat.titulo || 'Conversación'}
</span> </span>
<div className="flex opacity-0 transition-opacity group-hover:opacity-100"> {/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<button <div
onClick={(e) => { className={cn(
e.stopPropagation() 'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
setEditingId(chat.id) activeChatId === chat.id
setTempName(chat.nombre || chat.titulo || '') ? 'bg-teal-50'
}} : 'bg-slate-100',
className="p-1 hover:text-teal-600" )}
> >
<Edit2 size={12} /> <TooltipProvider delayDuration={300}>
</button> <Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
{/* Botón para Archivar/Desarchivar dinámico */} <Tooltip>
<button <TooltipTrigger asChild>
onClick={(e) => { <button
e.stopPropagation() onClick={(e) => {
// Si el estado actual es ACTIVA, mandamos ARCHIVADA. Si no, viceversa. e.stopPropagation()
const nuevoEstado = const nuevoEstado =
chat.estado === 'ACTIVA' ? 'ARCHIVADA' : 'ACTIVA' chat.estado === 'ACTIVA'
updateStatus({ id: chat.id, estado: nuevoEstado }) ? 'ARCHIVADA'
}} : 'ACTIVA'
className={cn( updateStatus({
'p-1 transition-colors', id: chat.id,
chat.estado === 'ACTIVA' estado: nuevoEstado,
? 'hover:text-red-500' })
: 'hover:text-teal-600', }}
)} className={cn(
title={ 'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA' chat.estado === 'ACTIVA'
? 'Archivar chat' ? 'hover:text-red-500'
: 'Desarchivar chat' : 'hover:text-teal-600',
} )}
> >
{chat.estado === 'ACTIVA' ? ( {chat.estado === 'ACTIVA' ? (
<Archive size={12} /> <Archive size={14} />
) : ( ) : (
/* Icono de Desarchivar */ <History size={14} className="scale-x-[-1]" />
<History size={12} className="scale-x-[-1]" /> )}
)} </button>
</button> </TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</> </>
)} )}

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

@@ -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

@@ -1,52 +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'
const DOCUMENT_PDF_ASIGNATURA_URL = import { requireData, throwIfError } from './_helpers'
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
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 { interface GeneratePdfParamsAsignatura {
asignatura_id: string 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,
headers: { {
'Content-Type': 'application/json', action: 'downloadReport',
plan_estudio_id,
body: convertTo ? { convertTo } : {},
}, },
body: JSON.stringify({ plan_estudio_id }), {
}) headers: {
'Content-Type': 'application/json',
if (!response.ok) { },
throw new Error('Error al generar el PDF') responseType: 'blob',
} },
)
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
} }
export async function fetchAsignaturaPdf({ export async function fetchAsignaturaPdf({
asignatura_id, asignatura_id,
convertTo,
}: GeneratePdfParamsAsignatura): Promise<Blob> { }: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, { const supabase = supabaseBrowser()
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asignatura_id }),
})
if (!response.ok) { const { data, error } = await supabase
throw new Error('Error al generar el PDF') .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
// n8n devuelve el archivo → lo tratamos como blob return await invokeEdge<Blob>(
return await response.blob() EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
asignatura_id,
body: {
...body,
},
},
{
headers: {
'Content-Type': 'application/json',
},
responseType: 'blob',
},
)
} }

View File

@@ -207,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

@@ -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
} }

View File

@@ -26,6 +26,12 @@ import type {
import type { TablesInsert } from '@/types/supabase' import type { TablesInsert } from '@/types/supabase'
import { defineStepper } from '@/components/stepper' import { defineStepper } from '@/components/stepper'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -38,6 +44,7 @@ import {
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -45,6 +52,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -57,6 +65,221 @@ import { buscar_bibliografia } from '@/data'
import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { useCreateBibliografia } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type BibliotecaOption = {
id: string
title: string
authors: Array<string>
publisher?: string
year?: number
isbn?: string
shelf?: string
badgeText?: string
}
type BibliotecaOptionTemplate = Omit<BibliotecaOption, 'id'>
// Hardcodeado: 3 conjuntos de coincidencias (0, 2 y 5).
const BIBLIOTECA_MATCH_SETS: Array<Array<BibliotecaOptionTemplate>> = [
[],
[
{
title: 'Coincidencia en biblioteca (Ejemplar 1)',
authors: ['Autor A', 'Autor B'],
publisher: 'Editorial X',
year: 2020,
isbn: '9780000000001',
shelf: 'QA76.9 .A1 2020',
badgeText: 'Coincidencia ISBN',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 2)',
authors: ['Autor C'],
publisher: 'Editorial Y',
year: 2016,
shelf: 'QA76.9 .A2 2016',
},
],
[
{
title: 'Coincidencia en biblioteca (Ejemplar 1)',
authors: ['Autor A', 'Autor B'],
publisher: 'Editorial X',
year: 2020,
isbn: '9780000000001',
shelf: 'QA76.9 .A1 2020',
badgeText: 'Coincidencia ISBN',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 2)',
authors: ['Autor C'],
publisher: 'Editorial Y',
year: 2016,
shelf: 'QA76.9 .A2 2016',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 3)',
authors: ['Autor D', 'Autor E'],
publisher: 'Editorial Z',
year: 2014,
shelf: 'QA76.9 .A3 2014',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 4)',
authors: ['Autor F'],
publisher: 'Editorial W',
year: 2011,
shelf: 'QA76.9 .A4 2011',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 5)',
authors: ['Autor G'],
publisher: 'Editorial V',
year: 2009,
shelf: 'QA76.9 .A5 2009',
},
],
]
export function BookSelectionAccordion({
onlineSourceLabel,
online,
options,
value,
onValueChange,
}: {
onlineSourceLabel: string
online: {
id: string
title: string
authorsLine: string
year?: number
isbn?: string
}
options: Array<BibliotecaOption>
value: string | undefined
onValueChange: (value: string) => void
}) {
// Estado inicial indefinido para que nada esté seleccionado por defecto
const [selectedBook, setSelectedBook] = useState<string | undefined>(value)
useEffect(() => {
setSelectedBook(value)
}, [value])
const onlineValue = `online:${online.id}`
const optionBaseClass =
'relative flex items-start space-x-3 rounded-lg border p-4 transition-colors'
const optionClass = (isSelected: boolean) =>
cn(
optionBaseClass,
isSelected
? 'border-primary bg-primary/5'
: 'hover:border-primary/30 hover:bg-accent/50',
)
return (
<>
{/* Un solo RadioGroup controla ambos lados */}
<RadioGroup
value={selectedBook}
onValueChange={(v) => {
setSelectedBook(v)
onValueChange(v)
}}
className="flex flex-col gap-6 md:flex-row"
>
{/* --- LADO IZQUIERDO: Sugerencia Online --- */}
<div className="flex-1 space-y-4">
<h4 className="text-muted-foreground text-sm font-medium">
Sugerencia Original ({onlineSourceLabel})
</h4>
<div className={optionClass(selectedBook === onlineValue)}>
<RadioGroupItem
value={onlineValue}
id={onlineValue}
className="mt-1"
/>
<Label
htmlFor={onlineValue}
className="flex flex-1 cursor-pointer flex-col"
>
<span className="font-semibold">{online.title}</span>
<span className="text-muted-foreground text-sm">
{online.authorsLine}
{online.year ? ` (${online.year})` : ''}
</span>
{online.isbn ? (
<span className="text-muted-foreground mt-1 text-xs">
ISBN: {online.isbn}
</span>
) : null}
</Label>
</div>
</div>
{/* Separador vertical para escritorio, horizontal en móviles */}
<Separator orientation="vertical" className="hidden h-auto md:block" />
<Separator orientation="horizontal" className="md:hidden" />
{/* --- LADO DERECHO: Alternativas de Biblioteca --- */}
<div className="flex-1 space-y-4">
<h4 className="text-muted-foreground text-sm font-medium">
Disponibles en Biblioteca
</h4>
<div className="max-h-75 space-y-3 overflow-y-auto pr-2">
{options.length === 0 ? (
<div className="text-muted-foreground text-sm">
No se encontraron alternativas.
</div>
) : (
options.map((opt) => {
const optValue = `biblio:${opt.id}`
const authorsLine = opt.authors.join('; ')
const isSelected = selectedBook === optValue
return (
<div key={opt.id} className={optionClass(isSelected)}>
<RadioGroupItem
value={optValue}
id={optValue}
className="mt-1 cursor-pointer"
/>
<Label
htmlFor={optValue}
className="flex flex-1 cursor-pointer flex-col"
>
<div className="flex items-center gap-2">
<span className="font-semibold">{opt.title}</span>
{opt.badgeText ? (
<Badge className="bg-green-600 hover:bg-green-700">
{opt.badgeText}
</Badge>
) : null}
</div>
<span className="text-muted-foreground text-sm">
{authorsLine}
{opt.year ? ` (${opt.year})` : ''}
</span>
{opt.shelf ? (
<span className="bg-muted mt-2 w-fit rounded px-1 font-mono text-xs">
Estante: {opt.shelf}
</span>
) : null}
</Label>
</div>
)
})
)}
</div>
</div>
</RadioGroup>
</>
)
}
type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null
export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago' export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago'
@@ -156,6 +379,10 @@ type WizardState = {
selected: boolean selected: boolean
endpoint: EndpointResult['endpoint'] endpoint: EndpointResult['endpoint']
item: GoogleBooksVolume | OpenLibraryDoc item: GoogleBooksVolume | OpenLibraryDoc
biblioteca?: {
options?: Array<BibliotecaOption>
choiceId?: string
}
}> }>
isLoading: boolean isLoading: boolean
errorMessage: string | null errorMessage: string | null
@@ -192,10 +419,96 @@ const Wizard = defineStepper(
title: 'Datos básicos', title: 'Datos básicos',
description: 'Seleccionar o capturar', description: 'Seleccionar o capturar',
}, },
{
id: 'biblioteca',
title: 'Biblioteca',
description: 'Comparar con alternativas de la biblioteca',
},
{ id: 'paso3', title: 'Detalles', description: 'Formato y citas' }, { id: 'paso3', title: 'Detalles', description: 'Formato y citas' },
{ id: 'resumen', title: 'Resumen', description: 'Confirmar' }, { id: 'resumen', title: 'Resumen', description: 'Confirmar' },
) )
type BibliotecaStepHandle = {
validateBeforeNext: () => boolean
}
function bibliotecaOptionToRef(opt: BibliotecaOption): BibliografiaRef {
return {
id: opt.id,
raw: undefined,
title: opt.title,
subtitle: undefined,
authors: opt.authors,
publisher: opt.publisher,
year: opt.year,
isbn: opt.isbn,
tipo: 'BASICA',
}
}
function getOnlineSuggestionTitle(s: IASugerencia): string {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo ?? {}
return (info.title ?? '').trim() || 'Sin título'
}
const doc = s.item as OpenLibraryDoc
return (
(typeof doc['title'] === 'string' ? doc['title'] : '').trim() ||
'Sin título'
)
}
function getOnlineSuggestionAuthors(s: IASugerencia): Array<string> {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo ?? {}
return Array.isArray(info.authors) ? info.authors : []
}
const doc = s.item as OpenLibraryDoc
return Array.isArray(doc['author_name'])
? (doc['author_name'] as Array<unknown>).filter(
(a): a is string => typeof a === 'string',
)
: []
}
function getOnlineSuggestionIsbn(s: IASugerencia): string | undefined {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo
const isbn = info?.industryIdentifiers?.find(
(x) => x.identifier,
)?.identifier
return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined
}
const doc = s.item as OpenLibraryDoc
const isbn = Array.isArray(doc['isbn'])
? (doc['isbn'] as Array<unknown>).find(
(x): x is string => typeof x === 'string',
)
: undefined
return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined
}
function getOnlineSuggestionYear(s: IASugerencia): number | undefined {
return s.endpoint === 'google'
? tryParseYear((s.item as GoogleBooksVolume).volumeInfo?.publishedDate)
: tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc)
}
function iaSugerenciaToChosenRef(s: IASugerencia): BibliografiaRef {
const choiceId = s.biblioteca?.choiceId
const options = s.biblioteca?.options
if (choiceId && choiceId !== 'online' && Array.isArray(options)) {
const chosen = options.find((o) => o.id === choiceId)
if (chosen) return bibliotecaOptionToRef(chosen)
}
return endpointResultToRef(iaSugerenciaToEndpointResult(s))
}
function parsearAutor(nombreCompleto: string): CSLAuthor { function parsearAutor(nombreCompleto: string): CSLAuthor {
if (nombreCompleto.includes(',')) { if (nombreCompleto.includes(',')) {
return { return {
@@ -446,6 +759,7 @@ export function NuevaBibliografiaModalContainer({
const createBibliografia = useCreateBibliografia() const createBibliografia = useCreateBibliografia()
const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null) const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null)
const bibliotecaStepRef = useRef<BibliotecaStepHandle | null>(null)
const [wizard, setWizard] = useState<WizardState>({ const [wizard, setWizard] = useState<WizardState>({
metodo: null, metodo: null,
@@ -483,9 +797,9 @@ export function NuevaBibliografiaModalContainer({
const styleCacheRef = useRef(new Map<string, string>()) const styleCacheRef = useRef(new Map<string, string>())
const localeCacheRef = useRef(new Map<string, string>()) const localeCacheRef = useRef(new Map<string, string>())
const titleOverrides = const titleOverrides: Record<string, string> =
wizard.metodo === 'EN_LINEA' wizard.metodo === 'EN_LINEA'
? { paso2: 'Sugerencias', paso3: 'Estructura' } ? { paso2: 'Sugerencias', biblioteca: 'Biblioteca', paso3: 'Estructura' }
: { paso2: 'Datos básicos', paso3: 'Detalles' } : { paso2: 'Datos básicos', paso3: 'Detalles' }
const handleClose = () => { const handleClose = () => {
@@ -499,7 +813,7 @@ export function NuevaBibliografiaModalContainer({
wizard.metodo === 'EN_LINEA' wizard.metodo === 'EN_LINEA'
? wizard.ia.sugerencias ? wizard.ia.sugerencias
.filter((s) => s.selected) .filter((s) => s.selected)
.map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s))) .map((s) => iaSugerenciaToChosenRef(s))
: wizard.manual.refs : wizard.manual.refs
// Mantener `wizard.refs` como snapshot para pasos 3/4. // Mantener `wizard.refs` como snapshot para pasos 3/4.
@@ -789,14 +1103,17 @@ export function NuevaBibliografiaModalContainer({
} }
} }
const WizardDef = Wizard as any
return ( return (
<Wizard.Stepper.Provider <WizardDef.Stepper.Provider
initialStep={Wizard.utils.getFirst().id} initialStep={WizardDef.utils.getFirst().id}
className="flex h-full flex-col" className="flex h-full flex-col"
> >
{({ methods }) => { {({ methods }: any) => {
const idx = Wizard.utils.getIndex(methods.current.id) const idx = WizardDef.utils.getIndex(methods.current.id)
const isLast = idx >= Wizard.steps.length - 1 const isLast = idx >= WizardDef.steps.length - 1
const currentId = methods.current.id as string
return ( return (
<WizardLayout <WizardLayout
@@ -804,17 +1121,59 @@ export function NuevaBibliografiaModalContainer({
onClose={handleClose} onClose={handleClose}
headerSlot={ headerSlot={
<WizardResponsiveHeader <WizardResponsiveHeader
wizard={Wizard} wizard={WizardDef}
methods={methods} methods={methods}
titleOverrides={titleOverrides} titleOverrides={titleOverrides}
hiddenStepIds={
wizard.metodo === 'MANUAL' ? ['biblioteca'] : undefined
}
/> />
} }
footerSlot={ footerSlot={
<Wizard.Stepper.Controls> <WizardDef.Stepper.Controls>
<div className="flex grow items-center justify-between"> <div className="flex grow items-center justify-between">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => methods.prev()} onClick={() => {
const goToStep = (targetId: string) => {
if (typeof methods?.goTo === 'function') {
methods.goTo(targetId)
return
}
if (typeof methods?.setStep === 'function') {
methods.setStep(targetId)
return
}
if (typeof methods?.navigation?.goTo === 'function') {
methods.navigation.goTo(targetId)
return
}
const targetIdx = WizardDef.utils.getIndex(targetId)
const stepOnce = () => {
const currentIdx = WizardDef.utils.getIndex(
methods.current.id,
)
if (currentIdx === targetIdx) return
if (currentIdx < targetIdx) methods.next()
else methods.prev()
queueMicrotask(stepOnce)
}
stepOnce()
}
if (
wizard.metodo === 'MANUAL' &&
methods.current.id === 'paso3'
) {
goToStep('paso2')
return
}
methods.prev()
}}
disabled={ disabled={
idx === 0 || wizard.ia.isLoading || wizard.isSaving idx === 0 || wizard.ia.isLoading || wizard.isSaving
} }
@@ -830,26 +1189,79 @@ export function NuevaBibliografiaModalContainer({
) : ( ) : (
<Button <Button
onClick={() => { onClick={() => {
if (idx === 2) { const goToStep = (targetId: string) => {
if (typeof methods?.goTo === 'function') {
methods.goTo(targetId)
return
}
if (typeof methods?.setStep === 'function') {
methods.setStep(targetId)
return
}
if (typeof methods?.navigation?.goTo === 'function') {
methods.navigation.goTo(targetId)
return
}
const targetIdx = WizardDef.utils.getIndex(targetId)
const stepOnce = () => {
const currentIdx = WizardDef.utils.getIndex(
methods.current.id,
)
if (currentIdx === targetIdx) return
if (currentIdx < targetIdx) methods.next()
else methods.prev()
queueMicrotask(stepOnce)
}
stepOnce()
}
if (
wizard.metodo === 'MANUAL' &&
currentId === 'paso2'
) {
goToStep('paso3')
return
}
if (currentId === 'biblioteca') {
const ok =
bibliotecaStepRef.current?.validateBeforeNext() ??
true
if (!ok) return
}
if (currentId === 'paso3') {
const ok = const ok =
formatoStepRef.current?.validateBeforeNext() ?? true formatoStepRef.current?.validateBeforeNext() ?? true
if (!ok) return if (!ok) return
if (wizard.metodo === 'EN_LINEA' && wizard.formato) {
void generateCitasForFormato(
wizard.formato,
wizard.refs,
{
force: true,
},
)
}
} }
methods.next() methods.next()
}} }}
disabled={ disabled={
wizard.ia.isLoading || wizard.ia.isLoading ||
wizard.isSaving || wizard.isSaving ||
(idx === 0 && !canContinueDesdeMetodo) || (currentId === 'metodo' && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdePaso2) || (currentId === 'paso2' && !canContinueDesdePaso2) ||
(idx === 2 && !canContinueDesdePaso3) (currentId === 'paso3' && !canContinueDesdePaso3)
} }
> >
Siguiente Siguiente
</Button> </Button>
)} )}
</div> </div>
</Wizard.Stepper.Controls> </WizardDef.Stepper.Controls>
} }
> >
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
@@ -863,8 +1275,8 @@ export function NuevaBibliografiaModalContainer({
</Card> </Card>
) : null} ) : null}
{idx === 0 && ( {currentId === 'metodo' && (
<Wizard.Stepper.Panel> <WizardDef.Stepper.Panel>
<MetodoStep <MetodoStep
metodo={wizard.metodo} metodo={wizard.metodo}
onChange={(metodo) => onChange={(metodo) =>
@@ -876,11 +1288,11 @@ export function NuevaBibliografiaModalContainer({
})) }))
} }
/> />
</Wizard.Stepper.Panel> </WizardDef.Stepper.Panel>
)} )}
{idx === 1 && ( {currentId === 'paso2' && (
<Wizard.Stepper.Panel> <WizardDef.Stepper.Panel>
{wizard.metodo === 'EN_LINEA' ? ( {wizard.metodo === 'EN_LINEA' ? (
<SugerenciasStep <SugerenciasStep
q={wizard.ia.q} q={wizard.ia.q}
@@ -941,11 +1353,33 @@ export function NuevaBibliografiaModalContainer({
} }
/> />
)} )}
</Wizard.Stepper.Panel> </WizardDef.Stepper.Panel>
)} )}
{idx === 2 && ( {currentId === 'biblioteca' && wizard.metodo === 'EN_LINEA' && (
<Wizard.Stepper.Panel> <WizardDef.Stepper.Panel>
<BibliotecaStep
ref={bibliotecaStepRef}
sugerencias={wizard.ia.sugerencias.filter(
(s) => s.selected,
)}
onPatchSugerencia={(id, patch) =>
setWizard((w) => ({
...w,
ia: {
...w.ia,
sugerencias: w.ia.sugerencias.map((s) =>
s.id === id ? { ...s, ...patch } : s,
),
},
}))
}
/>
</WizardDef.Stepper.Panel>
)}
{currentId === 'paso3' && (
<WizardDef.Stepper.Panel>
<FormatoYCitasStep <FormatoYCitasStep
ref={formatoStepRef} ref={formatoStepRef}
refs={wizard.refs} refs={wizard.refs}
@@ -985,11 +1419,11 @@ export function NuevaBibliografiaModalContainer({
})) }))
} }
/> />
</Wizard.Stepper.Panel> </WizardDef.Stepper.Panel>
)} )}
{idx === 3 && ( {currentId === 'resumen' && (
<Wizard.Stepper.Panel> <WizardDef.Stepper.Panel>
<ResumenStep <ResumenStep
metodo={wizard.metodo} metodo={wizard.metodo}
formato={wizard.formato} formato={wizard.formato}
@@ -998,13 +1432,13 @@ export function NuevaBibliografiaModalContainer({
wizard.formato ? wizard.citaEdits[wizard.formato] : {} wizard.formato ? wizard.citaEdits[wizard.formato] : {}
} }
/> />
</Wizard.Stepper.Panel> </WizardDef.Stepper.Panel>
)} )}
</div> </div>
</WizardLayout> </WizardLayout>
) )
}} }}
</Wizard.Stepper.Provider> </WizardDef.Stepper.Provider>
) )
} }
@@ -1348,6 +1782,189 @@ function SugerenciasStep({
) )
} }
type BibliotecaStepProps = {
sugerencias: Array<IASugerencia>
onPatchSugerencia: (id: string, patch: Partial<IASugerencia>) => void
}
const BibliotecaStep = forwardRef<BibliotecaStepHandle, BibliotecaStepProps>(
function BibliotecaStep({ sugerencias, onPatchSugerencia }, ref) {
const [openIds, setOpenIds] = useState<Array<string>>([])
const anchorRefs = useRef<Record<string, HTMLDivElement | null>>({})
const initializedRef = useRef(new Set<string>())
const scrollToAccordion = (id: string) => {
const el = anchorRefs.current[id]
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
useEffect(() => {
for (const s of sugerencias) {
const b = s.biblioteca
const hasOptions = Array.isArray(b?.options)
if (hasOptions) continue
if (initializedRef.current.has(s.id)) continue
initializedRef.current.add(s.id)
const setIdx = Math.floor(Math.random() * 3)
const templates = BIBLIOTECA_MATCH_SETS[setIdx] ?? []
const options: Array<BibliotecaOption> = templates.map((t, i) => ({
id: `biblio:${s.id}:${i + 1}`,
...t,
}))
onPatchSugerencia(s.id, {
biblioteca: {
options,
choiceId: options.length === 0 ? 'online' : undefined,
},
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sugerencias])
const validateBeforeNext = () => {
const unresolved = sugerencias.find((s) => {
const b = s.biblioteca
if (!b || !Array.isArray(b.options)) return true
if (b.options.length === 0) return false
return !b.choiceId
})
if (!unresolved) return true
setOpenIds((prev) =>
prev.includes(unresolved.id) ? prev : [...prev, unresolved.id],
)
requestAnimationFrame(() => scrollToAccordion(unresolved.id))
return false
}
useImperativeHandle(ref, () => ({ validateBeforeNext }))
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Comparar con alternativas de la biblioteca</CardTitle>
<CardDescription>
Conserva la sugerencia original o sustitúyela por una
coincidencia.
</CardDescription>
</CardHeader>
</Card>
<Accordion
type="multiple"
value={openIds}
onValueChange={setOpenIds}
className="w-full space-y-2"
>
{sugerencias.map((s) => {
const title = getOnlineSuggestionTitle(s)
const authors = getOnlineSuggestionAuthors(s)
const authorsLine = authors.join('; ') || '—'
const year = getOnlineSuggestionYear(s)
const isbn = getOnlineSuggestionIsbn(s)
const sourceLabel =
s.endpoint === 'google' ? 'Google Books' : 'Open Library'
const b = s.biblioteca
const options = b?.options ?? []
const badgeState: 'por_revisar' | 'sustituido' | 'mantenido' =
!b || !Array.isArray(b.options)
? 'por_revisar'
: options.length === 0
? 'mantenido'
: !b.choiceId
? 'por_revisar'
: b.choiceId === 'online'
? 'mantenido'
: 'sustituido'
const badge =
badgeState === 'por_revisar' ? (
<Badge className="bg-yellow-500 text-black hover:bg-yellow-500">
Por revisar
</Badge>
) : badgeState === 'sustituido' ? (
<Badge className="bg-green-600 text-white hover:bg-green-700">
Sustituido
</Badge>
) : (
<Badge className="bg-blue-600 text-white hover:bg-blue-700">
Mantenido
</Badge>
)
const radioValue =
b?.choiceId === 'online' || (options.length === 0 && !b?.choiceId)
? `online:${s.id}`
: typeof b?.choiceId === 'string'
? `biblio:${b.choiceId}`
: undefined
return (
<AccordionItem
key={s.id}
value={s.id}
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
>
<div
ref={(el) => {
anchorRefs.current[s.id] = el
}}
/>
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
<div className="flex w-full items-center justify-between gap-3">
<span className="min-w-0 text-wrap">{title}</span>
{badge}
</div>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground mt-4">
<div className="mx-1 grid gap-3 pb-2">
<BookSelectionAccordion
onlineSourceLabel={sourceLabel}
online={{
id: s.id,
title,
authorsLine,
year,
isbn,
}}
options={options}
value={radioValue}
onValueChange={(v) => {
const nextChoiceId = v.startsWith('online:')
? 'online'
: v.startsWith('biblio:')
? v.slice('biblio:'.length)
: undefined
if (!nextChoiceId) return
onPatchSugerencia(s.id, {
biblioteca: {
options,
choiceId: nextChoiceId,
},
})
}}
/>
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</div>
)
},
)
function DatosBasicosManualStep({ function DatosBasicosManualStep({
draft, draft,
refs, refs,
@@ -1419,6 +2036,12 @@ function DatosBasicosManualStep({
publisher: e.target.value.slice(0, 300), publisher: e.target.value.slice(0, 300),
}) })
} }
onBlur={() => {
const trimmed = draft.publisher.trim()
if (trimmed !== draft.publisher) {
onChangeDraft({ ...draft, publisher: trimmed })
}
}}
maxLength={300} maxLength={300}
/> />
</div> </div>
@@ -1817,9 +2440,17 @@ const FormatoYCitasStep = forwardRef<
onChange={(e) => { onChange={(e) => {
const raw = e.currentTarget.value.slice(0, 300) const raw = e.currentTarget.value.slice(0, 300)
onChangeRef(r.id, { onChangeRef(r.id, {
publisher: raw.trim() || undefined, publisher: raw.length > 0 ? raw : undefined,
}) })
}} }}
onBlur={() => {
const trimmed = publisherText.trim()
if (trimmed !== publisherText) {
onChangeRef(r.id, {
publisher: trimmed || undefined,
})
}
}}
/> />
</div> </div>
</div> </div>

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

@@ -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,8 +13,9 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
RotateCcw,
Loader2, Loader2,
Sparkles,
RotateCcw,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -22,10 +23,17 @@ 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,
useConversationByPlan, useConversationByPlan,
@@ -121,6 +129,7 @@ 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)
@@ -197,20 +206,20 @@ function RouteComponent() {
return messages return messages
}) })
}, [mensajesDelChat, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => { const scrollToBottom = (behavior = 'smooth') => {
if (scrollRef.current) { if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector( const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]', '[data-radix-scroll-area-viewport]',
) )
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: 'smooth', behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
}) })
} }
} }
} }
const { activeChats, archivedChats } = useMemo(() => { const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || [] const allChats = lastConversation || []
return { return {
@@ -222,22 +231,22 @@ function RouteComponent() {
}, [lastConversation]) }, [lastConversation])
useEffect(() => { useEffect(() => {
console.log(mensajesDelChat) if (chatMessages.length > 0) {
if (isInitialLoad.current) {
scrollToBottom() // Si es el primer render con mensajes, vamos al final al instante
}, [chatMessages, isLoading]) scrollToBottom('instant')
isInitialLoad.current = false
/* useEffect(() => { } else {
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input // Si ya estaba cargado y llegan nuevos, hacemos el smooth
const camposActualizados = selectedFields.filter((field) => scrollToBottom('smooth')
input.includes(field.label), }
)
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
} }
}, [input, selectedFields]) */ }, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
useEffect(() => { useEffect(() => {
if (isLoadingConv || isSending) return if (isLoadingConv || isSending) return
@@ -501,82 +510,114 @@ 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'
}`} }`}
> >
<FileText size={16} className="shrink-0 opacity-40" /> {/* LADO IZQUIERDO: Icono + Texto */}
<div
<span className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
ref={editingChatId === chat.id ? editableRef : null} style={{
contentEditable={editingChatId === chat.id} // Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
suppressContentEditableWarning={true} // donde aparecen los botones
className={`truncate pr-14 transition-all outline-none ${ maskImage:
editingChatId === chat.id 'linear-gradient(to right, black 70%, transparent 95%)',
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500' WebkitMaskImage:
: 'cursor-pointer' 'linear-gradient(to right, black 70%, transparent 95%)',
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const newTitle = e.currentTarget.textContent || ''
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent = chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || ''
if (newTitle !== chat.nombre) {
updateTitleMutation({ id: chat.id, nombre: newTitle })
}
setEditingChatId(null)
}
}}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}} }}
> >
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} {/* pr-12 reserva espacio para los botones absolutos */}
</span> <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
ref={
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true}
className={`block truncate outline-none ${
editingChatId === chat.id
? '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'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent =
chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle =
e.currentTarget.textContent?.trim() || ''
if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
}
setEditingChatId(null)
}
}}
>
{chat.nombre ||
`Chat ${chat.creado_en.split('T')[0]}`}
</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>
@@ -584,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"
> >
<Archive size={14} className="shrink-0 opacity-30" /> <div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<span className="truncate pr-8"> <Archive size={14} className="shrink-0 opacity-30" />
{chat.nombre || <span className="block truncate">
`Archivado ${chat.creado_en.split('T')[0]}`} {chat.nombre ||
</span> `Archivado ${chat.creado_en.split('T')[0]}`}
</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>
@@ -721,33 +764,24 @@ function RouteComponent() {
) )
})} })}
{(isSending || isSyncing) &&
optimisticMessage &&
!chatMessages.some(
(m) => m.content === optimisticMessage,
) && (
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
{optimisticMessage}
</div>
</div>
)}
{(isSending || isSyncing) && ( {(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300"> <div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm"> <Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<div className="flex items-center gap-2"> <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"> <div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<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-slate-400 [animation-delay:-0.15s]"></span>
<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-slate-400"></span>
</div> </div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
{isSyncing
? 'Actualizando historial...'
: 'Esperando respuesta...'}
</span>
</div> </div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</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 (
<button <TooltipProvider delayDuration={150}>
draggable <Tooltip>
onDragStart={(e) => onDragStart(e, asignatura.id)} <TooltipTrigger asChild>
onClick={onClick} <button
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${ draggable
isDragging onDragStart={(e) => onDragStart(e, asignatura.id)}
? 'scale-95 opacity-40' onClick={onClick}
: 'hover:border-teal-400 hover:shadow-md' className={[
}`} 'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
> 'transition-all duration-300 ease-out',
<div className="mb-1 flex items-start justify-between"> 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
<span className="font-mono text-[10px] font-bold text-slate-400"> 'active:cursor-grabbing cursor-grab',
{asignatura.clave} isDragging
</span> ? 'scale-[0.985] opacity-45 shadow-none'
<Badge : 'hover:-translate-y-1 hover:shadow-lg',
variant="outline" ].join(' ')}
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`} style={{
> borderColor: hexToRgba(lineaColor, 0.18),
{asignatura.estado} background: `
</Badge> radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
</div> linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
<p className="mb-1 text-xs leading-tight font-bold text-slate-700"> `,
{asignatura.nombre} }}
</p> title={asignatura.nombre}
<div className="mt-2 flex items-center justify-between"> >
<span className="text-[10px] text-slate-500"> {/* franja */}
{asignatura.creditos} CR HD:{asignatura.hd} HI:{asignatura.hi} <div
</span> className="absolute inset-x-0 top-0 h-2"
<GripVertical style={{ backgroundColor: lineaColor }}
size={12} />
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
/> {/* glow decorativo */}
</div> <div
</button> 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,
}}
>
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
</div>
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
<EstadoIcon
className={[
'h-3.5 w-3.5 text-foreground/65',
asignatura.estado === 'generando' ? 'animate-spin' : '',
].join(' ')}
/>
</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>
</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)
@@ -522,15 +704,15 @@ function MapaCurricularPage() {
</Button> </Button>
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length > {asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
0 && ( 0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50"> <Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '} <AlertTriangle size={14} className="mr-1" />{' '}
{ {
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId) asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
.length .length
}{' '} }{' '}
sin asignar sin asignar
</Badge> </Badge>
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="bg-teal-700 text-white hover:bg-teal-800"> <Button className="bg-teal-700 text-white hover:bg-teal-800">
@@ -616,9 +798,8 @@ 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">
<span <span
@@ -633,11 +814,10 @@ 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' }`}
}`}
> >
{linea.nombre} {linea.nombre}
</span> </span>
@@ -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 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>
</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>
<p className="text-xs text-slate-400">
Arrastra una asignatura aquí para quitarla del mapa
</p>
</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.map((m) => ( {unassignedAsignaturas.length > 0 ? (
<div key={m.id} className="w-[200px]"> <div className="flex flex-wrap gap-4">
<AsignaturaCardItem {unassignedAsignaturas.map((m) => (
asignatura={m} <div key={m.id} className="w-[272px] shrink-0">
isDragging={draggedAsignatura === m.id} <AsignaturaCardItem
onDragStart={handleDragStart} asignatura={m}
onClick={() => { lineaColor="#94A3B8"
setEditingData(m) // Cargamos los datos en el estado de edición lineaNombre="Sin asignar"
setIsEditModalOpen(true) isDragging={draggedAsignatura === m.id}
}} onDragStart={handleDragStart}
/> onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
</div>
))}
</div> </div>
))} ) : (
{unassignedAsignaturas.length === 0 && ( <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">
<div className="flex w-full items-center justify-center text-sm text-slate-400"> <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
No hay asignaturas pendientes. Arrastra una asignatura aquí para <Icons.CheckCheck className="h-5 w-5" />
desasignarla. </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 setEditingData({
} ...editingData,
if (!editingData.prerrequisitos.includes(val)) { prerrequisito_asignatura_id: val === 'none' ? null : val,
setEditingData({ })
...editingData,
prerrequisitos: [...editingData.prerrequisitos, 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,7 +1,8 @@
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 { useSubject } from '@/data'
import { fetchAsignaturaPdf } from '@/data/api/document.api' import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -15,48 +16,75 @@ function RouteComponent() {
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 fetchAsignaturaPdf({ const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId, 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)
} }
}, [asignaturaId]) }, [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 fetchAsignaturaPdf({ const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId, 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

@@ -166,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">

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;

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 {

View File

@@ -154,6 +154,7 @@ export type Database = {
numero_ciclo: number | null numero_ciclo: number | null
orden_celda: number | null orden_celda: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id: string | null
tipo: Database['public']['Enums']['tipo_asignatura'] tipo: Database['public']['Enums']['tipo_asignatura']
tipo_origen: Database['public']['Enums']['tipo_origen'] | null tipo_origen: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -179,6 +180,7 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -204,6 +206,7 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id?: string plan_estudio_id?: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -257,6 +260,20 @@ export type Database = {
referencedRelation: 'plantilla_plan' referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id'] referencedColumns: ['plan_estudio_id']
}, },
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'asignaturas'
referencedColumns: ['id']
},
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
] ]
} }
bibliografia_asignatura: { bibliografia_asignatura: {
@@ -1376,6 +1393,7 @@ export type Database = {
Args: { p_append: Json; p_id: string } Args: { p_append: Json; p_id: string }
Returns: undefined Returns: undefined
} }
suma_porcentajes: { Args: { '': Json }; Returns: number }
unaccent: { Args: { '': string }; Returns: string } unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string }
} }

14
staticwebapp.config.json Normal file
View File

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