79 Commits

Author SHA1 Message Date
Guillermo.Arrieta 1ede5bca32 Merge branch 'main' into issue/225-mejoras-en-el-diseo 2026-04-14 12:52:14 -06:00
Guillermo.Arrieta b37d36fe15 Resumen de cambios del commit y de los dos wips anteriores:
• Cambio del estilo visual del layout del detalle de asignatura
	• El nivel de un plan de estudios se muestra como editable con un select
	• se quitó 'disabled:pointer-events-none' del componente button para permitir que el cursor cambie a cursor-not-allowed sobre botones disabled
	• todos los botones y elementos con role de button no deshabilitados tienen cursor-pointer
	• todos los botones y elementos con role de button deshabilitados tienen cursor-not-allowed
cada cambio tanto en el historial de un plan como de una asignatura se puede abrir picandole en cualquier parte del cambio
2026-04-13 13:54:28 -06:00
Guillermo.Arrieta d3267e5890 wip 2026-04-13 11:04:28 -06:00
Guillermo.Arrieta b77906e2e9 wip 2026-03-27 19:32:37 -06:00
roberto.silva 03cdd24c1d Merge pull request 'dialog warning al cambiar de ciclo una asignatura que rompería la seriación #210' (#230) from issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura- into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m12s
Reviewed-on: #230
2026-03-27 21:57:13 +00:00
roberto.silva a98982d228 Merge branch 'main' into issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura- 2026-03-27 15:56:19 -06:00
roberto.silva c710b85090 dialog warning al cambiar de ciclo una asignatura que rompería la seriación
fix #210
2026-03-27 15:17:04 -06:00
Guillermo.Arrieta 36b9c559fa Nuevo fondo y logo de La Salle en login 2026-03-27 13:51:12 -06:00
Guillermo.Arrieta 434b50cfc3 Se quitó teal y otros estilos hardcoded de la página 2026-03-27 13:27:53 -06:00
Guillermo.Arrieta 51f967a955 Merge pull request 'Primera parte de arreglos en el diseño' (#229) from issue/225-mejoras-en-el-diseo into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 2m2s
Reviewed-on: #229
2026-03-27 17:29:18 +00:00
Guillermo.Arrieta f5a4b0b9af Merge branch 'main' into issue/225-mejoras-en-el-diseo 2026-03-27 11:13:20 -06:00
Guillermo.Arrieta 8ae3469e10 mejoras de diseño de mapa curricular parte 3
- Se cambió el layout para ser más claro de usar
- El botón de agregar linea abre un dialog donde eliges como llamar tu nueva línea curricular
- Al agregar lineas curriculares se les asigna un color al azar que persiste en la base de datos
- al quitarle zoom a la pagina los margenes permanecen
2026-03-26 22:24:17 -06:00
Guillermo.Arrieta ddb83782a6 mejoras de diseño de mapa curricular parte 2
- color picker añadido por linea
- bote de basura para borrar linea alineado abajo a la derecha
- se quitó degradado y linea de color superior de las tarjetas de asignaturas
2026-03-26 22:18:16 -06:00
Guillermo.Arrieta 085e6fa0e9 mejoras de diseño de mapa curricular parte 1
- ancho de ciclos fijo
- alineacion correcta de CR, HD y HI
- tamaño de nombre de asignatura pasó de md a sm
2026-03-26 19:06:54 -06:00
roberto.silva b3e9d63833 Merge pull request 'dialog warning al cambiar de ciclo una asignatura que rompería la seriación #210' (#228) from issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura- into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m26s
Reviewed-on: #228
2026-03-26 22:07:02 +00:00
roberto.silva 7a2f16b160 Que se valide que al cambiar de ciclo un prerrequisito no quede inválida fix#79 2026-03-26 16:04:53 -06:00
Guillermo.Arrieta 54200986ab Espaciado ahora es consistente 2026-03-26 14:11:09 -06:00
Guillermo.Arrieta bfafb8999e Cambios a la card de plan de estudio 2026-03-26 12:54:12 -06:00
Guillermo.Arrieta 883e6e7fa4 Merge pull request 'Clonacion de asignaturas a partir del sistema' (#227) from issue/212-nueva-asignatura-clonada-desde-el-sistema into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #227
2026-03-25 22:46:57 +00:00
Guillermo.Arrieta dd11c2707e Búsqueda correcta y optimizada con un único rpc a search_asignaturas 2026-03-25 16:09:04 -06:00
Guillermo.Arrieta 60c2e8402d generación automática de vectores de búsqueda de asignaturas 2026-03-25 15:33:26 -06:00
roberto.silva 350d7c0f69 Merge pull request 'Que se puedan aplicar todos los campos y todas las mejoras fix #165' (#226) from issue/165-que-se-puedan-aplicar-todos-los-campos-y-todas-las into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #226
2026-03-25 21:14:55 +00:00
roberto.silva f2900f3d22 Que se puedan aplicar todos los campos y todas las mejoras
fix #165
2026-03-25 15:14:29 -06:00
roberto.silva 1f27ea6252 Merge pull request 'Que sirvan la redirección de la IA fix #192' (#224) from issue/192-que-sirvan-la-redireccin-de-la-ia into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m17s
Reviewed-on: #224
2026-03-25 15:38:54 +00:00
roberto.silva 0e710f9637 Que sirvan la redirección de la IA
fix #192
2026-03-25 09:38:09 -06:00
Guillermo.Arrieta 3bb3a11779 wip
ya funciona la busqueda de asignaturas, pero ocurre un bug en la insercion de la asignatura clonada
2026-03-24 20:43:29 -06:00
roberto.silva 75784658bf Merge pull request 'Se corrige desbordamiento en historial y en bibliografia fix #219' (#223) from issue/219-mejorar-responsividad into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m11s
Reviewed-on: #223
2026-03-24 20:58:16 +00:00
roberto.silva d2f12d96de Merge branch 'main' into issue/219-mejorar-responsividad 2026-03-24 20:58:11 +00:00
roberto.silva 7f9c3162a0 Se corrige desbordamiento en historial y en bibliografia
fix #219
2026-03-24 14:57:49 -06:00
roberto.silva 03ef51d8a6 Merge pull request 'Se corrige desborde de chat de ia en mobile' (#222) from issue/219-mejorar-responsividad into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m17s
Reviewed-on: #222
2026-03-24 19:04:12 +00:00
roberto.silva f81b404223 Merge branch 'main' into issue/219-mejorar-responsividad 2026-03-24 19:04:04 +00:00
roberto.silva 614150f8d1 Se corrige desborde de chat de ia en mobile 2026-03-24 13:03:33 -06:00
roberto.silva 54a3b74836 Merge pull request 'Mejorar responsividad fix#219' (#221) from issue/219-mejorar-responsividad into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m18s
Reviewed-on: #221
2026-03-24 16:55:32 +00:00
roberto.silva 0820bf19a5 Merge branch 'main' into issue/219-mejorar-responsividad 2026-03-24 16:55:20 +00:00
roberto.silva 7ddc5c7f1f Mejorar responsividad
fix #219
2026-03-24 10:54:40 -06:00
alexrg ad7a741d1e Merge pull request 'Refactor MapaCurricularPage: update state variables and enhance UI components for better clarity and usability' (#220) from features/mejora-de-estilos into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m34s
Reviewed-on: #220
2026-03-24 15:12:44 +00:00
alexrg 60bfa31186 Refactor MapaCurricularPage: improve line editing functionality and enhance tooltip interactions 2026-03-24 09:11:40 -06:00
alexrg 1bd37c7e27 Refactor MapaCurricularPage: update state variables and enhance UI components for better clarity and usability 2026-03-24 08:45:59 -06:00
roberto.silva 7eb9ca0bec Merge pull request 'Que el contenido temático se muestre en el historial , Actualizar esta sección de seriación fix#197 fix #195' (#218) from issue/197-que-el-contenido-temtico-se-muestre-en-el-historia into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m15s
Reviewed-on: #218
2026-03-23 22:34:21 +00:00
roberto.silva 10dc299311 Merge branch 'main' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia 2026-03-23 22:34:11 +00:00
roberto.silva b3954ab16c Merge branch 'issue/195-actualizar-esta-seccin-de-seriacin' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia 2026-03-23 16:29:27 -06:00
alexrg 88a2a28a8d Enhance table styling and improve empty state message in AsignaturasPage for better user experience
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m25s
2026-03-23 16:16:42 -06:00
alexrg 36a11e3793 Improve conditional rendering and styling for empty state in MapaCurricularPage 2026-03-23 16:15:43 -06:00
alexrg 33efaed03f Adjust text sizes and spacing in AsignaturaCardItem and MapaCurricularPage for improved readability
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m22s
2026-03-23 16:05:20 -06:00
alexrg d481e9706c Fix estado assignment in AsignaturaCardItem and adjust width for improved layout
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m19s
2026-03-23 15:54:12 -06:00
alexrg ee3b7a56ec Merge pull request 'Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular' (#217) from issue/214-mejorar-el-espaciado-de-la-interfaz into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #217
2026-03-23 20:31:46 +00:00
alexrg 2359e38f85 Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular 2026-03-23 14:30:16 -06:00
Guillermo.Arrieta ed318fa67b Funcional pero falta arreglar diseño y responsividad 2026-03-23 13:11:24 -06:00
roberto.silva c262cd16be Merge pull request 'Que aparezcan los campos a mitad de texto fix #213' (#216) from issue/213-que-aparezcan-los-campos-a-mitad-de-texto into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #216
2026-03-23 19:02:56 +00:00
roberto.silva a08b2abf87 Que aparezcan los campos a mitad de texto
fix #213
2026-03-23 13:02:31 -06:00
roberto.silva a07213d959 Merge pull request 'Mejorar la responsividad de los chats fix #202 fix #203' (#215) from issue/202-mejorar-la-responsividad-de-los-chats into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 2m36s
Reviewed-on: #215
2026-03-23 18:34:30 +00:00
roberto.silva a2234e5022 Mejorar la responsividad de los chats fix #202 2026-03-23 12:33:12 -06:00
roberto.silva 658c392f96 Mejorar la responsividad de los chats fix #202 2026-03-23 11:59:58 -06:00
Guillermo.Arrieta 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
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #211
2026-03-20 23:47:37 +00:00
Guillermo.Arrieta 2abe296b9e close #200: Se guardan los docx y pdf con el nombre del plan/asignatura 2026-03-20 17:44:36 -06:00
Guillermo.Arrieta 1bce226d15 Se descargan correctamente los docx del plan y de la asignatura 2026-03-20 17:31:59 -06:00
Guillermo.Arrieta b986ec343e Se visualiza y descarga el pdf de la asignatura 2026-03-20 17:31:07 -06:00
roberto.silva 9fd816bfa1 Actualizar esta sección de seriación fix #195 2026-03-20 16:09:39 -06:00
alexrg 379e2d3826 Actualizar src/routes/planes/$planId/_detalle/mapa.tsx
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
2026-03-20 21:30:16 +00:00
alexrg 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
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
Guillermo.Arrieta d9a5cec3c5 En el body se manda el parámetro para convertir el documento a pdf 2026-03-20 13:22:23 -06:00
Guillermo.Arrieta 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
alexrg cbaf96c6b5 Merge pull request 'Add letter-spacing to font-bold class in styles.css' (#206) from agregar-tipografía into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m13s
Reviewed-on: #206
2026-03-20 17:37:05 +00:00
alexrg 0fb831fb58 Merge branch 'main' into agregar-tipografía 2026-03-20 17:36:58 +00:00
alexrg 0d1aa61022 Add letter-spacing to font-bold class in styles.css 2026-03-20 11:35:51 -06:00
alexrg 84281a88f2 Merge pull request 'Add Indivisa font family and update styles.css' (#205) from agregar-tipografía into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
Reviewed-on: #205
2026-03-20 17:33:03 +00:00
alexrg d91018c612 Add Indivisa font family and update styles.css 2026-03-20 11:30:39 -06:00
roberto.silva 658b2e245c Merge pull request 'Que no haga scroll fix #193' (#199) from issue/193-que-no-haga-scroll into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
Reviewed-on: #199
2026-03-19 20:20:45 +00:00
roberto.silva 30562fead0 Merge branch 'main' into issue/193-que-no-haga-scroll 2026-03-19 20:20:30 +00:00
roberto.silva 2b91004129 Que no haga scroll #193 2026-03-19 14:18:21 -06:00
alexrg 96a045dc67 Añadir staticwebapp.config.json
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m7s
2026-03-19 13:58:43 +00:00
alexrg a8229f12d5 Actualizar .gitea/workflows/deploy.yaml
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
2026-03-19 13:56:12 +00:00
alexrg dd4ac5374a Añadir .gitea/workflows/deploy.yaml
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 48s
2026-03-18 22:39:50 +00:00
roberto.silva 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
roberto.silva 93fe247a19 Merge branch 'main' into issue/175-que-se-guarden-las-seriaciones 2026-03-18 22:10:09 +00:00
roberto.silva 32ebfde9ed Que se guarden las seriaciones
fix #175
fix #151
fix #180
2026-03-18 15:48:49 -06:00
Guillermo.Arrieta 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
Guillermo.Arrieta 6a520ef6b1 close #186: se agregó botón de Nueva Unidad al inicio del contenido temático 2026-03-17 15:45:36 -06:00
72 changed files with 4400 additions and 1645 deletions
+37
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"
+2 -2
View File
@@ -41,7 +41,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0", "use-debounce": "^10.1.0",
@@ -1327,7 +1327,7 @@
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
+1 -1
View File
@@ -54,7 +54,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0", "use-debounce": "^10.1.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

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.
@@ -7,6 +7,13 @@ import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -14,7 +21,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlanAsignaturas } from '@/data'
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 +47,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
@@ -59,8 +72,12 @@ export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId) const { data: asignaturaApi } = useSubject(asignaturaId)
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null) const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
@@ -81,16 +98,52 @@ export default function AsignaturaDetailPage() {
}, },
}) })
} }
const asignaturaSeriada = useMemo(() => {
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
return null
return asignaturasApi.find(
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
)
}, [asignaturaApi, asignaturasApi])
const requisitosFormateados = useMemo(() => {
if (!asignaturaSeriada) return []
return [
{
type: 'Pre-requisito',
code: asignaturaSeriada.codigo,
name: asignaturaSeriada.nombre,
id: asignaturaSeriada.id, // Guardamos el ID para el select
},
]
}, [asignaturaSeriada])
const handleUpdatePrerrequisito = (newId: string | null) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: newId,
},
})
}
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi]) }, [asignaturaApi, requisitosFormateados])
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} /> return (
<DatosGenerales
pre={requisitosFormateados}
availableSubjects={asignaturasApi}
onPersistDato={handlePersistDatoGeneral}
/>
)
} }
function DatosGenerales({ function DatosGenerales({
onPersistDato, onPersistDato,
pre,
availableSubjects,
}: { }: {
onPersistDato: (clave: string, value: string) => void onPersistDato: (clave: string, value: string) => void
}) { }) {
@@ -127,7 +180,6 @@ function DatosGenerales({
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => { const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
const raw = (data as any)?.criterios_de_evaluacion const raw = (data as any)?.criterios_de_evaluacion
console.log(raw)
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
@@ -263,24 +315,32 @@ function DatosGenerales({
<div className="space-y-6"> <div className="space-y-6">
{/* Tarjeta de Requisitos */} {/* Tarjeta de Requisitos */}
<InfoCard <InfoCard
asignaturaId={asignaturaId}
title="Requisitos y Seriación" title="Requisitos y Seriación"
type="requirements" type="requirements"
initialContent={[ initialContent={pre}
{ // Pasamos las materias del plan para el Select (excluyendo la actual)
type: 'Pre-requisito', availableSubjects={
code: 'PA-301', availableSubjects?.filter(
name: 'Programación Avanzada', (a) =>
}, a.id !== asignaturaId &&
{ a.numero_ciclo < data?.numero_ciclo &&
type: 'Co-requisito', a.numero_ciclo,
code: 'MAT-201', ) || []
name: 'Matemáticas Discretas', }
}, onPersist={({ value }) => {
]} updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
},
})
}}
/> />
{/* Tarjeta de Evaluación */} {/* Tarjeta de Evaluación */}
<InfoCard <InfoCard
asignaturaId={asignaturaId}
title="Sistema de Evaluación" title="Sistema de Evaluación"
type="evaluation" type="evaluation"
initialContent={criteriosEvaluacion} initialContent={criteriosEvaluacion}
@@ -316,6 +376,7 @@ interface InfoCardProps {
containerRef?: React.RefObject<HTMLDivElement | null> containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number forceEditToken?: number
highlightToken?: number highlightToken?: number
availableSubjects?: any
} }
function InfoCard({ function InfoCard({
@@ -332,6 +393,7 @@ function InfoCard({
containerRef, containerRef,
forceEditToken, forceEditToken,
highlightToken, highlightToken,
availableSubjects,
}: InfoCardProps) { }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false)
@@ -391,8 +453,6 @@ function InfoCard({
}, [highlightToken]) }, [highlightToken])
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
if (type === 'evaluation') { if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = [] const cleaned: Array<CriterioEvaluacionRow> = []
for (const r of evalRows) { for (const r of evalRows) {
@@ -422,6 +482,23 @@ function InfoCard({
void onPersist?.({ type, clave, value: cleaned }) void onPersist?.({ type, clave, value: cleaned })
return return
} }
if (type === 'requirements') {
// Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId =
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
setData(tempText) // Actualiza la vista local
setIsEditing(false)
// Mandamos el ID específico a la base de datos
void onPersist?.({
type,
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
value: prerequisiteId,
})
return
}
setData(tempText) setData(tempText)
setIsEditing(false) setIsEditing(false)
@@ -432,16 +509,21 @@ function InfoCard({
} }
const handleIARequest = (campoClave: string) => { const handleIARequest = (campoClave: string) => {
console.log(placeholder) let targetClave = campoClave
if (type === 'evaluation' && !targetClave) {
targetClave = 'criterios_de_evaluacion'
}
if (targetClave === 'contenido') {
targetClave = 'contenido_tematico'
}
// Añadimos un timestamp a la state para forzar que la navegación
// genere una nueva ubicación incluso si la ruta y los params son iguales.
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! }, params: { planId, asignaturaId: asignaturaId! },
state: { state: {
activeTab: 'ia', activeTab: 'ia',
prefillCampo: campoClave, prefillCampo: targetClave,
prefillContenido: data, prefillContenido: data,
_ts: Date.now(), _ts: Date.now(),
} as any, } as any,
@@ -496,19 +578,21 @@ function InfoCard({
{!isEditing && ( {!isEditing && (
<div className="flex gap-1"> <div className="flex gap-1">
<Tooltip> {type !== 'requirements' && (
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-8 w-8 text-blue-500 hover:bg-blue-100" size="icon"
onClick={() => clave && handleIARequest(clave)} className="h-8 w-8 text-blue-500 hover:bg-blue-100"
> onClick={() => handleIARequest(clave)}
<Sparkles className="h-4 w-4" /> >
</Button> <Sparkles className="h-4 w-4" />
</TooltipTrigger> </Button>
<TooltipContent>Mejorar con IA</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -541,7 +625,69 @@ function InfoCard({
<CardContent className="pt-4"> <CardContent className="pt-4">
{isEditing ? ( {isEditing ? (
<div className="space-y-3"> <div className="space-y-3">
{type === 'evaluation' ? ( {/* Condicionales de edición según el tipo */}
{type === 'requirements' ? (
<div className="space-y-3">
<label className="text-xs font-medium text-slate-500">
Materia de Seriación
</label>
<Select
value={
Array.isArray(tempText) && tempText.length > 0
? tempText[0].id
: 'none'
}
onValueChange={(val) => {
const selected = availableSubjects?.find(
(s) => s.id === val,
)
if (val === 'none' || !selected) {
setTempText([])
} else {
setTempText([
{
id: selected.id,
type: 'Pre-requisito',
code: selected.codigo,
name: selected.nombre,
},
])
}
}}
>
<SelectTrigger className="w-full">
<div className="flex-1 truncate text-left">
<SelectValue placeholder="Selecciona una materia">
{Array.isArray(tempText) && tempText.length > 0
? `${tempText[0].code} - ${tempText[0].name}`
: undefined}
</SelectValue>
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Ninguna (Sin seriación)
</SelectItem>
{availableSubjects?.map((asig) => (
<SelectItem
key={asig.id}
value={asig.id}
className="max-w-[300px] sm:max-w-[500px]"
>
<span className="text-primary font-bold">
[C{asig.numero_ciclo}]
</span>{' '}
<span className="inline-block truncate">
{asig.codigo} - {asig.nombre}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : type === 'evaluation' ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
{evalRows.map((row) => ( {evalRows.map((row) => (
@@ -563,85 +709,36 @@ function InfoCard({
) )
}} }}
/> />
<Input <Input
value={row.porcentaje} value={row.porcentaje}
placeholder="%" placeholder="%"
type="number" type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
onChange={(e) => { onChange={(e) => {
const raw = e.target.value const raw = e.target.value
// Solo permitir '' o dígitos
if (raw !== '' && !/^\d+$/.test(raw)) return if (raw !== '' && !/^\d+$/.test(raw)) return
if (raw === '') {
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: '',
}
: r,
),
)
return
}
const n = Number(raw)
if (!Number.isFinite(n)) return
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return
// No permitir suma > 100
setEvalRows((prev) => { setEvalRows((prev) => {
const next = prev.map((r) => const next = prev.map((r) =>
r.id === row.id r.id === row.id ? { ...r, porcentaje: raw } : r,
? { )
id: r.id, const total = next.reduce(
criterio: r.criterio, (acc, r) => acc + (Number(r.porcentaje) || 0),
porcentaje: raw, 0,
}
: r,
) )
const total = next.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const nn = Number(v)
if (!Number.isFinite(nn)) return acc
const vv = Math.trunc(nn)
if (vv < 1 || vv > 100) return acc
return acc + vv
}, 0)
return total > 100 ? prev : next return total > 100 ? prev : next
}) })
}} }}
/> />
<div className="text-sm text-slate-600">%</div>
<div
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
aria-hidden
>
%
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50" className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() => { onClick={() =>
setEvalRows((prev) => setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id), prev.filter((r) => r.id !== row.id),
) )
}} }
aria-label="Quitar renglón"
title="Quitar"
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
@@ -651,22 +748,15 @@ function InfoCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={ className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
'text-sm ' +
(evaluationTotal === 100
? 'text-muted-foreground'
: 'text-destructive font-semibold')
}
> >
Total: {evaluationTotal}/100 Total: {evaluationTotal}/100
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-emerald-700 hover:bg-emerald-50" className="text-emerald-700 hover:bg-emerald-50"
onClick={() => { onClick={() =>
// Agregar una fila vacía (siempre permitido)
setEvalRows((prev) => [ setEvalRows((prev) => [
...prev, ...prev,
{ {
@@ -675,7 +765,7 @@ function InfoCard({
porcentaje: '', porcentaje: '',
}, },
]) ])
}} }
> >
<Plus className="mr-2 h-4 w-4" /> Agregar renglón <Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button> </Button>
@@ -689,28 +779,15 @@ function InfoCard({
className="min-h-30 text-sm leading-relaxed" className="min-h-30 text-sm leading-relaxed"
/> />
)} )}
{/* Botones de acción comunes */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setIsEditing(false) setIsEditing(false)
if (type === 'evaluation') { // Lógica de reset si es necesario...
const raw = Array.isArray(data) ? data : []
setEvalRows(
raw.map((r: CriterioEvaluacionRow) => ({
id: crypto.randomUUID(),
criterio:
typeof r.criterio === 'string' ? r.criterio : '',
porcentaje:
typeof r.porcentaje === 'number'
? String(Math.trunc(r.porcentaje))
: typeof r.porcentaje === 'string'
? String(Math.trunc(Number(r.porcentaje)))
: '',
})),
)
}
}} }}
> >
Cancelar Cancelar
@@ -726,6 +803,7 @@ function InfoCard({
</div> </div>
</div> </div>
) : ( ) : (
/* Modo Visualización */
<div className="text-sm leading-relaxed text-slate-600"> <div className="text-sm leading-relaxed text-slate-600">
{type === 'text' && {type === 'text' &&
(data ? ( (data ? (
@@ -734,9 +812,7 @@ function InfoCard({
<p className="text-slate-400 italic">Sin información.</p> <p className="text-slate-400 italic">Sin información.</p>
))} ))}
{type === 'requirements' && <RequirementsView items={data} />} {type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && ( {type === 'evaluation' && <EvaluationView items={data} />}
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -791,80 +867,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,
}
@@ -125,8 +125,8 @@ export function BibliographyItem() {
return <div className="p-10 text-center">Cargando bibliografía...</div> return <div className="p-10 text-center">Cargando bibliografía...</div>
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500"> <div className="animate-in fade-in space-y-8 pb-8 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900"> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
Bibliografía Bibliografía
@@ -169,7 +169,7 @@ export function BibliographyItem() {
resetScroll: false, resetScroll: false,
}) })
} }
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors" className="shadow-md"
> >
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía <Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
</Button> </Button>
@@ -662,7 +662,7 @@ export function ContenidoTematico() {
} }
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500"> <div className="animate-in fade-in space-y-6 pb-8 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900"> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
@@ -688,6 +688,12 @@ export function ContenidoTematico() {
> >
{({ handleRef }) => ( {({ handleRef }) => (
<> <>
{index === 0 && (
<InsertUnidadOverlay
position="top"
onInsert={() => insertUnidadAt(index)}
/>
)}
<InsertUnidadOverlay <InsertUnidadOverlay
position="bottom" position="bottom"
onInsert={() => insertUnidadAt(index + 1)} onInsert={() => insertUnidadAt(index + 1)}
@@ -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,27 @@ export function DocumentoSEPTab({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{pdfUrl && !isLoading && (
<>
<Button size="sm" className="gap-2" 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" />
@@ -11,7 +11,6 @@ import {
Filter, Filter,
Calendar, Calendar,
Loader2, Loader2,
Eye,
} from 'lucide-react' } from 'lucide-react'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
@@ -249,7 +248,16 @@ export function HistorialTab() {
`bg-current ${config.color}`, `bg-current ${config.color}`,
)} )}
/> />
<Card className="card-interactive"> <Card
className="border-border card-interactive hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
onClick={() => openCompareModal(cambio)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
openCompareModal(cambio)
}}
>
<CardContent className="py-4"> <CardContent className="py-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div
@@ -265,16 +273,7 @@ export function HistorialTab() {
<p className="font-medium"> <p className="font-medium">
{cambio.descripcion} {cambio.descripcion}
</p> </p>
{/* BOTÓN PARA VER CAMBIOS */}
<Button
variant="ghost"
size="sm"
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => openCompareModal(cambio)}
>
<Eye className="h-4 w-4" />
Ver cambios
</Button>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{format(cambio.fecha, 'HH:mm')} {format(cambio.fecha, 'HH:mm')}
</span> </span>
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ interface ImprovementCardProps {
sug: IASugerencia sug: IASugerencia
asignaturaId: string asignaturaId: string
onApplied: (campoKey: string) => void onApplied: (campoKey: string) => void
isSelected?: boolean
} }
export function ImprovementCard({ export function ImprovementCard({
@@ -87,19 +88,19 @@ export function ImprovementCard({
{valor.map((u: any, idx: number) => ( {valor.map((u: any, idx: number) => (
<div <div
key={idx} key={idx}
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm" className="bg-card border-primary/20 rounded-md border p-2 shadow-sm"
> >
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800"> <div className="border-border/50 text-primary mb-1 flex items-center gap-2 border-b pb-1 text-[11px] font-bold">
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo} <BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{u.temas?.map((t: any, tidx: number) => ( {u.temas?.map((t: any, tidx: number) => (
<li <li
key={tidx} key={tidx}
className="flex items-start justify-between gap-2 text-[10px] text-slate-600" className="text-muted-foreground flex items-start justify-between gap-2 text-[10px]"
> >
<span className="leading-tight"> {t.nombre}</span> <span className="leading-tight"> {t.nombre}</span>
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400"> <span className="text-muted-foreground/70 flex shrink-0 items-center gap-0.5 font-mono">
<Clock size={10} /> {t.horasEstimadas}h <Clock size={10} /> {t.horasEstimadas}h
</span> </span>
</li> </li>
@@ -115,24 +116,24 @@ export function ImprovementCard({
if (valor[0]?.hasOwnProperty('criterio')) { if (valor[0]?.hasOwnProperty('criterio')) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase"> <div className="text-muted-foreground/70 mb-1 flex items-center gap-2 text-[10px] font-bold uppercase">
<ListChecks size={12} /> Desglose de evaluación <ListChecks size={12} /> Desglose de evaluación
</div> </div>
{valor.map((c: any, idx: number) => ( {valor.map((c: any, idx: number) => (
<div <div
key={idx} key={idx}
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm" className="bg-card border-border flex items-center justify-between gap-3 rounded-md border p-2 shadow-sm"
> >
<span className="text-[11px] leading-tight text-slate-700"> <span className="text-foreground text-[11px] leading-tight">
{c.criterio} {c.criterio}
</span> </span>
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600"> <div className="border-accent/30 bg-accent/10 text-accent flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold">
{c.porcentaje}% {c.porcentaje}%
</div> </div>
</div> </div>
))} ))}
{/* Opcional: Suma total para verificar que de 100% */} {/* Opcional: Suma total para verificar que de 100% */}
<div className="pt-1 text-right text-[9px] font-medium text-slate-400"> <div className="text-muted-foreground/70 pt-1 text-right text-[9px] font-medium">
Total:{' '} Total:{' '}
{valor.reduce( {valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0), (acc: number, curr: any) => acc + (curr.porcentaje || 0),
@@ -155,17 +156,17 @@ export function ImprovementCard({
// --- ESTADO APLICADO --- // --- ESTADO APLICADO ---
if (sug.aceptada) { if (sug.aceptada) {
return ( return (
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm"> <div className="bg-card border-border flex flex-col rounded-xl border p-3 opacity-80 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-4"> <div className="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-slate-800"> <span className="text-foreground text-sm font-bold">
{sug.campoNombre} {sug.campoNombre}
</span> </span>
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400"> <div className="border-border bg-muted/50 text-muted-foreground flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium">
<Check size={14} /> <Check size={14} />
Aplicado Aplicado
</div> </div>
</div> </div>
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500"> <div className="border-primary/20 bg-primary/5 text-muted-foreground rounded-lg border p-3 text-xs leading-relaxed">
{renderContenido(sug.valorSugerido)} {renderContenido(sug.valorSugerido)}
</div> </div>
</div> </div>
@@ -174,16 +175,16 @@ export function ImprovementCard({
// --- ESTADO PENDIENTE --- // --- ESTADO PENDIENTE ---
return ( return (
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200"> <div className="bg-card border-primary/20 hover:border-primary/40 group flex flex-col rounded-xl border p-3 shadow-sm transition-all">
<div className="mb-3 flex items-center justify-between gap-4"> <div className="mb-3 flex items-center justify-between gap-4">
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase"> <span className="border-primary/20 bg-primary/10 text-primary max-w-[150px] truncate rounded-lg border px-2.5 py-1 text-[10px] font-bold tracking-wider uppercase">
{sug.campoNombre} {sug.campoNombre}
</span> </span>
<Button <Button
size="sm" size="sm"
disabled={isApplying || !asignatura} disabled={isApplying || !asignatura}
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700" className="h-8 w-auto px-4 text-xs font-semibold shadow-sm"
onClick={handleApply} onClick={handleApply}
> >
{isApplying ? ( {isApplying ? (
@@ -197,7 +198,7 @@ export function ImprovementCard({
<div <div
className={cn( className={cn(
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600', 'border-border/60 bg-muted/30 text-muted-foreground rounded-lg border border-dashed p-3 text-xs leading-relaxed',
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic', !Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)} )}
> >
@@ -0,0 +1,83 @@
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface Props {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
titulo?: string
descripcion?: string
}
export const AlertaConflicto = ({
isOpen,
onOpenChange,
onConfirm,
titulo,
descripcion,
}: Props) => {
// Intentamos parsear el mensaje si viene como JSON para la lista de materias
let contenido
try {
const data = JSON.parse(descripcion as any)
contenido = (
<div className="space-y-3">
<p className="text-sm text-slate-600">{data.main}</p>
<div className="flex flex-wrap gap-2 py-2">
{data.materias.map((m: string, i: number) => (
<span
key={i}
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
>
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
{m}
</span>
))}
</div>
<p className="mt-2 text-xs font-semibold text-slate-500">
¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
seriación)?
</p>
</div>
)
} catch {
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
}
return (
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-6 w-6" />
</div>
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
{titulo}
</AlertDialog.Title>
</div>
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
<AlertDialog.Cancel asChild>
<Button variant="ghost">Cancelar</Button>
{/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
onClick={onConfirm}
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
>
Mover de todos modos
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
@@ -0,0 +1,93 @@
import * as Icons from 'lucide-react'
import { useEffect, useRef } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useSubject } from '@/data'
export function PasoBasicosClonadoInterno({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
const { data: source, isLoading, isError } = useSubject(sourceId)
const lastAppliedRef = useRef<string | null>(null)
useEffect(() => {
if (!source) return
if (lastAppliedRef.current === source.id) return
lastAppliedRef.current = source.id
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
nombre: source.nombre,
codigo: source.codigo ?? '',
tipo: (source.tipo as any) ?? null,
creditos: source.creditos,
horasAcademicas: (source as any).horas_academicas ?? null,
horasIndependientes: (source as any).horas_independientes ?? null,
estructuraId: (source.estructura_id ??
w.datosBasicos.estructuraId) as any,
},
}))
}, [onChange, source])
if (!sourceId) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Datos básicos</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Selecciona una asignatura fuente para continuar.
</CardContent>
</Card>
)
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Datos básicos</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Cargando información de la asignatura fuente
</CardContent>
</Card>
)
}
if (isError || !source) {
return (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2 text-base">
<Icons.AlertTriangle className="h-5 w-5" />
No se pudo cargar la fuente
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Intenta seleccionar otra asignatura.
</CardContent>
</Card>
)
}
return (
<PasoBasicosForm
wizard={wizard}
onChange={onChange}
estructuraFuenteId={source.estructura_id ?? null}
/>
)
}
@@ -1,3 +1,4 @@
import * as Icons from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import PasoSugerenciasForm from './PasoSugerenciasForm' import PasoSugerenciasForm from './PasoSugerenciasForm'
@@ -21,9 +22,11 @@ import { cn } from '@/lib/utils'
export function PasoBasicosForm({ export function PasoBasicosForm({
wizard, wizard,
onChange, onChange,
estructuraFuenteId,
}: { }: {
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>> onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
estructuraFuenteId?: string | null
}) { }) {
const { data: estructuras } = useSubjectEstructuras() const { data: estructuras } = useSubjectEstructuras()
@@ -258,6 +261,17 @@ export function PasoBasicosForm({
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
{estructuraFuenteId &&
wizard.datosBasicos.estructuraId &&
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
<span>
Es posible que se pierdan datos generales al seleccionar otra
estructura.
</span>
</div>
) : null}
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación). Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p> </p>
@@ -0,0 +1,359 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import * as Icons from 'lucide-react'
import { useEffect, useMemo } from 'react'
import { useDebounce } from 'use-debounce'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase'
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
import { cn } from '@/lib/utils'
type SourceSubjectRow = {
id: string
nombre: string
codigo: string | null
creditos: number
tipo: any
plan_estudio_id: string
estructura_id: string | null
rank?: number
}
const ALL = '__all__'
type SearchAsignaturasRow =
Database['public']['Functions']['search_asignaturas']['Returns'][number]
export function PasoFuenteClonadoInterno({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const pageSize = 20
const facultadId = wizard.clonInterno?.facultadId ?? null
const carreraId = wizard.clonInterno?.carreraId ?? null
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
const search = wizard.clonInterno?.search ?? ''
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
const [debouncedSearch] = useDebounce(search, 350)
const { data: catalogos } = useCatalogosPlanes()
const carrerasOptions = useMemo(() => {
const raw = catalogos?.carreras ?? []
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
}, [catalogos?.carreras, facultadId])
const planesQuery = usePlanes({
search: '',
facultadId: facultadId ?? 'todas',
carreraId: carreraId ?? 'todas',
estadoId: 'todos',
limit: 500,
offset: 0,
} as any)
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
const plansForFilter = planesQuery.data?.data ?? []
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
queryKey: [
'asignaturas',
'clone-source',
{
facultadId,
carreraId,
planOrigenId,
search: debouncedSearch,
page,
pageSize,
planIdsKey: needPlansForFilter
? plansForFilter.map((p) => p.id).join(',')
: null,
},
],
enabled: !needPlansForFilter || !planesQuery.isLoading,
placeholderData: keepPreviousData,
queryFn: async () => {
const supabase = supabaseBrowser()
const from = (page - 1) * pageSize
const term = debouncedSearch.trim()
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
const { data, error } = await supabase.rpc('search_asignaturas', {
p_search: term,
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
p_limit: pageSize,
p_offset: from,
})
if (error) throw new Error(error.message)
// Extraemos el conteo total de la primera fila.
// ESLint feliz: "data" ya no puede ser null a este punto.
const count = data.length > 0 ? Number(data[0].total_count) : 0
return {
// ESLint feliz: map directo sobre data
data: data.map((r) => ({
id: r.id,
nombre: r.nombre,
codigo: r.codigo,
creditos: Number(r.creditos),
tipo: r.tipo,
plan_estudio_id: r.plan_estudio_id,
estructura_id: null,
rank: r.rank,
})),
count,
}
},
})
const subjects = subjectsPaged?.data ?? []
const total = subjectsPaged?.count ?? 0
const pageCount = Math.max(1, Math.ceil(total / pageSize))
useEffect(() => {
// clamp page if results shrink
if (page > pageCount) {
onChange((w) => ({
...w,
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
}))
}
}, [onChange, page, pageCount])
const patchClonInterno = (
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
) =>
onChange((w) => ({
...w,
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
}))
const hasAnyFilter = Boolean(
facultadId || carreraId || planOrigenId || search.trim().length,
)
const clearDisabled = !hasAnyFilter
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
return (
<div className="grid gap-4">
<Card className="gap-4">
<CardHeader>
<CardTitle className="text-base">Fuente</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-1">
<Label>Facultad</Label>
<Select
value={facultadId ?? ALL}
onValueChange={(val) => {
const next = val === ALL ? null : val
patchClonInterno({
facultadId: next,
carreraId: null,
planOrigenId: null,
page: 1,
})
resetSelection()
}}
>
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Todas</SelectItem>
{(catalogos?.facultades ?? []).map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label>Carrera</Label>
<Select
value={carreraId ?? ALL}
onValueChange={(val) => {
const next = val === ALL ? null : val
patchClonInterno({
carreraId: next,
planOrigenId: null,
page: 1,
})
resetSelection()
}}
disabled={!facultadId}
>
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
<SelectValue
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Todas</SelectItem>
{carrerasOptions.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label>Plan</Label>
<Select
value={planOrigenId ?? ALL}
onValueChange={(val) => {
const next = val === ALL ? null : val
patchClonInterno({ planOrigenId: next, page: 1 })
resetSelection()
}}
>
<SelectTrigger
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
disabled={!carreraId && !facultadId}
>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Todos</SelectItem>
{(planesQuery.data?.data ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<div className="grid gap-1">
<Label>Buscar</Label>
<Input
placeholder="Nombre o código..."
value={search}
onChange={(e) =>
patchClonInterno({ search: e.target.value, page: 1 })
}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="secondary"
onClick={() => {
patchClonInterno({
facultadId: null,
carreraId: null,
planOrigenId: null,
search: '',
page: 1,
asignaturaOrigenId: null,
})
}}
disabled={clearDisabled}
>
<Icons.X className="mr-2 h-4 w-4" />
Limpiar filtros
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-2">
<div className="text-muted-foreground text-xs">
Selecciona una asignatura fuente (solo una).
</div>
<div className="grid max-h-80 gap-2 overflow-y-auto px-1">
{subjectsLoading ? (
<div className="text-muted-foreground text-sm">
Cargando asignaturas
</div>
) : subjects.length === 0 ? (
<div className="text-muted-foreground text-sm">
No hay asignaturas con esos filtros.
</div>
) : (
subjects.map((m) => {
const active = String(selectedId) === String(m.id)
return (
<label
key={m.id}
className={cn(
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
active && 'border-primary bg-primary/5 ring-primary ring-1',
)}
>
<input
className="sr-only"
type="radio"
name="asignaturaFuente"
checked={active}
onChange={() =>
patchClonInterno({ asignaturaOrigenId: m.id, page })
}
/>
<div className="min-w-0">
<div className="truncate font-medium">{m.nombre}</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{(m.codigo ? m.codigo : '—') +
' • ' +
String(m.creditos) +
' créditos'}
</div>
</div>
{active ? (
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
) : (
<span className="h-5 w-5 flex-none" aria-hidden />
)}
</label>
)
})
)}
</div>
{pageCount > 1 ? (
<Pagination03
page={page}
pageCount={pageCount}
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
/>
) : null}
</div>
</div>
)
}
@@ -15,6 +15,7 @@ import {
qk, qk,
useCreateSubjectManual, useCreateSubjectManual,
subjects_get_maybe, subjects_get_maybe,
subjects_get,
} from '@/data' } from '@/data'
export function WizardControls({ export function WizardControls({
@@ -201,6 +202,87 @@ export function WizardControls({
let startedWaiting = false let startedWaiting = false
try { try {
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
if (!asignaturaOrigenId) {
throw new Error('Selecciona una asignatura fuente.')
}
if (!wizard.datosBasicos.estructuraId) {
throw new Error('Estructura inválida.')
}
if (!wizard.datosBasicos.nombre.trim()) {
throw new Error('Nombre inválido.')
}
if (wizard.datosBasicos.tipo == null) {
throw new Error('Tipo inválido.')
}
if (wizard.datosBasicos.creditos == null) {
throw new Error('Créditos inválidos.')
}
const fuente = await subjects_get(asignaturaOrigenId as any)
const supabase = supabaseBrowser()
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
const payload: TablesInsert<'asignaturas'> = {
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
codigo: codigo ? codigo : null,
nombre: wizard.datosBasicos.nombre,
tipo: wizard.datosBasicos.tipo,
creditos: wizard.datosBasicos.creditos,
datos: (fuente as any).datos,
contenido_tematico: (fuente as any).contenido_tematico,
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
tipo_origen: 'CLONADO_INTERNO',
meta_origen: {
...(fuente as any).meta_origen,
asignatura_origen_id: fuente.id,
plan_origen_id: (fuente as any).plan_estudio_id,
},
horas_academicas:
wizard.datosBasicos.horasAcademicas ??
(fuente as any).horas_academicas ??
null,
horas_independientes:
wizard.datosBasicos.horasIndependientes ??
(fuente as any).horas_independientes ??
null,
}
console.log('payload:', payload)
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(payload)
.select('id,plan_estudio_id')
.single()
if (insertError) throw new Error(insertError.message)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(wizard.plan_estudio_id),
})
navigate({
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
state: { showConfetti: true },
resetScroll: false,
})
return
}
if (wizard.tipoOrigen === 'IA_SIMPLE') { if (wizard.tipoOrigen === 'IA_SIMPLE') {
if (!wizard.plan_estudio_id) { if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.') throw new Error('Plan de estudio inválido.')
+7
View File
@@ -9,6 +9,13 @@ export function LoginCard() {
return ( return (
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl"> <div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
<div className="flex justify-center">
<img
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
alt="La Salle México"
className="mb-6 h-20 w-auto"
/>
</div>
<h1 className="mb-1 text-center text-2xl font-semibold"> <h1 className="mb-1 text-center text-2xl font-semibold">
Iniciar sesión Iniciar sesión
</h1> </h1>
+48 -40
View File
@@ -4,20 +4,18 @@ import type { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { cn } from '@/lib/utils' // Asegúrate de tener tu utilidad cn import { cn } from '@/lib/utils'
interface PlanEstudiosCardProps { interface PlanEstudiosCardProps {
/** El componente del ícono importado de lucide-react (ej. BookOpen) */
Icono: LucideIcon Icono: LucideIcon
nombrePrograma: string nombrePrograma: string
nivel: string nivel: string
ciclos: string | number // Acepta "8" o "8 semestres" ciclos: string | number
facultad: string facultad: string
estado: string estado: string
/** Código hex o variable CSS (ej. "#ef4444" o "var(--primary)") */
claseColorEstado?: string claseColorEstado?: string
colorEstadoHex?: string
colorFacultad: string colorFacultad: string
/** Opcional: para manejar el click en la tarjeta */
onClick?: () => void onClick?: () => void
} }
@@ -29,52 +27,62 @@ export default function PlanEstudiosCard({
facultad, facultad,
estado, estado,
claseColorEstado = '', claseColorEstado = '',
colorEstadoHex,
colorFacultad, colorFacultad,
onClick, onClick,
}: PlanEstudiosCardProps) { }: PlanEstudiosCardProps) {
const badgeStyle = colorEstadoHex
? ({
backgroundColor: colorEstadoHex,
borderColor: colorEstadoHex,
} as const)
: undefined
return ( return (
<Card <Card
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'group relative flex h-full cursor-pointer flex-col justify-between gap-2 overflow-hidden border-l-4 transition-all hover:shadow-lg', 'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
)} )}
// Aplicamos el color de la facultad dinámicamente al borde y un fondo muy sutil
style={{
borderLeftColor: colorFacultad,
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 95%)`, // Truco CSS moderno para fondo tintado
}}
> >
<CardHeader className="pb-2"> <div className="flex grow flex-col">
{/* Ícono con el color de la facultad */} <CardHeader className="pb-2">
<div {/* Círculo del ícono con el color de la facultad */}
className="mb-2 w-fit rounded-md p-2" <div
style={{ className="mb-2 w-fit rounded-full p-2.5"
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`, style={{
}} backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
}}
>
<Icono size={24} style={{ color: colorFacultad }} />
</div>
{/* Título del Programa */}
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
{nombrePrograma}
</h4>
</CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm">
<p className="text-foreground font-medium">
{nivel} {ciclos}
</p>
<p>{facultad}</p>
</CardContent>
</div>
<CardFooter className="flex items-center justify-between pt-0 pb-6">
<Badge
style={badgeStyle}
className={cn(
'text-sm font-semibold',
!colorEstadoHex && claseColorEstado,
)}
> >
<Icono size={24} style={{ color: colorFacultad }} /> <span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
</div> {estado}
</span>
{/* Título del Programa */}
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
{nombrePrograma}
</h4>
</CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm">
<p className="text-foreground font-medium">
{nivel} {ciclos}
</p>
<p>{facultad}</p>
</CardContent>
<CardFooter className="bg-background/50 flex items-center justify-between border-t px-6 pb-3 backdrop-blur-sm [.border-t]:pt-3">
<Badge className={`text-sm font-semibold ${claseColorEstado}`}>
{estado}
</Badge> </Badge>
{/* <span className="text-foreground/80 text-sm font-semibold">
{estado}
</span> */}
{/* Flecha animada */} {/* Flecha animada */}
<div <div
@@ -56,10 +56,9 @@ export const ImprovementCard = ({
if (onApplySuccess) onApplySuccess(key) if (onApplySuccess) onApplySuccess(key)
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) { if (dbMessageId) {
updateAppliedStatus.mutate({ updateAppliedStatus.mutate({
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario conversacionId: dbMessageId,
campoAfectado: key, campoAfectado: key,
}) })
} }
@@ -81,21 +80,18 @@ export const ImprovementCard = ({
return ( return (
<div <div
key={sug.key} key={sug.key}
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${ className={`bg-card rounded-2xl border p-5 shadow-sm transition-all ${
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100' isApplied ? 'border-primary/30 bg-primary/5' : 'border-border'
}`} }`}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3> <h3 className="text-foreground text-sm font-bold">{sug.label}</h3>
<Button <Button
size="sm" size="sm"
onClick={() => handleApply(sug.key, sug.newValue)} onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied || !!isUpdating} disabled={isApplied || !!isUpdating}
className={`h-8 rounded-full px-4 text-xs transition-all ${ variant={isApplied ? 'secondary' : 'default'}
isApplied className="h-8 rounded-full px-4 text-xs transition-all"
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
}`}
> >
{isUpdating ? ( {isUpdating ? (
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin" />
@@ -112,8 +108,8 @@ export const ImprovementCard = ({
<div <div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${ className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied isApplied
? 'border-teal-100 bg-teal-50/50 text-slate-700' ? 'border-primary/20 bg-primary/10 text-foreground'
: 'border-slate-200 bg-slate-50 text-slate-500' : 'border-border bg-muted/50 text-muted-foreground'
}`} }`}
> >
{sug.newValue} {sug.newValue}
-26
View File
@@ -1,26 +0,0 @@
import { StatusBadge } from "./StatusBadge";
export function PlanCard({ plan }: any) {
return (
<div className="bg-[#eaf6fa] rounded-2xl p-6 border hover:shadow-md transition">
<div className="flex justify-between items-start mb-4">
<span className="text-sm text-gray-500"> Ingeniería</span>
<StatusBadge status={plan.status} />
</div>
<h3 className="text-lg font-semibold text-gray-900">
{plan.title}
</h3>
<p className="text-sm text-gray-600 mb-6">
{plan.subtitle}
</p>
<div className="flex justify-between text-sm text-gray-500">
<span>{plan.cycles} ciclos</span>
<span>{plan.credits} créditos</span>
<span></span>
</div>
</div>
)
}
-38
View File
@@ -1,38 +0,0 @@
import { PlanCard } from './PlanCard'
const mockPlans = [
{
id: 1,
name: 'Ingeniería en Sistemas',
level: 'Licenciatura',
status: 'Activo',
},
{
id: 2,
name: 'Arquitectura',
level: 'Licenciatura',
status: 'Activo',
},
{
id: 3,
name: 'Maestría en Educación',
level: 'Maestría',
status: 'Inactivo',
},
]
export function PlanGrid() {
return (
<div>
<h2 className="text-lg font-semibold mb-4">
Planes disponibles
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{mockPlans.map(plan => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
</div>
)
}
-11
View File
@@ -1,11 +0,0 @@
export function StatCard({ icon, value, label }: any) {
return (
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center gap-3 mb-2">
<span className="text-xl">{icon}</span>
<span className="text-2xl font-semibold">{value}</span>
</div>
<p className="text-sm text-gray-500">{label}</p>
</div>
)
}
-12
View File
@@ -1,12 +0,0 @@
import { StatCard } from "./StatCard";
export function StatsGrid() {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard icon="📘" value="12" label="Planes Activos" />
<StatCard icon="🕒" value="4" label="En Revisión" />
<StatCard icon="✅" value="8" label="Aprobados" />
<StatCard icon="👥" value="6" label="Carreras" />
</div>
)
}
-19
View File
@@ -1,19 +0,0 @@
export function StatusBadge({ status }: { status: string }) {
const styles: any = {
revision: 'bg-blue-100 text-blue-700',
aprobado: 'bg-green-100 text-green-700',
borrador: 'bg-gray-200 text-gray-600',
}
return (
<span
className={`text-xs px-3 py-1 rounded-full font-medium ${styles[status]}`}
>
{status === 'revision'
? 'En Revisión'
: status === 'aprobado'
? 'Aprobado'
: 'Borrador'}
</span>
)
}
@@ -0,0 +1,128 @@
import { buttonVariants } from '@/components/ui/button'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { cn } from '@/lib/utils'
export type Pagination03Props = {
page: number
pageCount: number
onPageChange: (page: number) => void
className?: string
}
const toInt = (n: unknown, fallback: number) => {
const x = typeof n === 'number' ? n : Number(n)
return Number.isFinite(x) ? Math.floor(x) : fallback
}
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, i) => i + 1)
}
const items: Array<number | '...'> = []
const safePage = Math.min(Math.max(page, 1), pageCount)
items.push(1)
const start = Math.max(2, safePage - 1)
const end = Math.min(pageCount - 1, safePage + 1)
if (start > 2) items.push('...')
for (let p = start; p <= end; p++) items.push(p)
if (end < pageCount - 1) items.push('...')
items.push(pageCount)
return items
}
export function Pagination03({
page,
pageCount,
onPageChange,
className,
}: Pagination03Props) {
const safePageCount = Math.max(1, toInt(pageCount, 1))
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
const items = getPageItems(safePage, safePageCount)
const canPrev = safePage > 1
const canNext = safePage < safePageCount
const go = (p: number) => {
const next = Math.min(Math.max(p, 1), safePageCount)
if (next === safePage) return
onPageChange(next)
}
return (
<Pagination className={className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
className={cn(!canPrev && 'pointer-events-none opacity-50')}
onClick={(e) => {
e.preventDefault()
if (!canPrev) return
go(safePage - 1)
}}
/>
</PaginationItem>
{items.map((it, idx) =>
it === '...' ? (
<PaginationItem key={`ellipsis-${idx}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={it}>
<PaginationLink
href="#"
isActive={it === safePage}
onClick={(e) => {
e.preventDefault()
go(it)
}}
className={
it === safePage
? cn(
buttonVariants({
variant: 'default',
size: 'icon',
}),
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
)
: undefined
}
>
{it}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href="#"
className={cn(!canNext && 'pointer-events-none opacity-50')}
onClick={(e) => {
e.preventDefault()
if (!canNext) return
go(safePage + 1)
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
export default Pagination03
+1 -1
View File
@@ -7,7 +7,7 @@ import type { VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+3 -3
View File
@@ -25,7 +25,7 @@ export function WizardLayout({
}} }}
> >
<div className="z-10 flex-none border-b bg-white"> <div className="z-10 flex-none border-b bg-white">
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4"> <CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-3">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<button <button
onClick={onClose} onClick={onClose}
@@ -36,10 +36,10 @@ export function WizardLayout({
</button> </button>
</CardHeader> </CardHeader>
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null} {headerSlot ? <div className="px-6 pb-3">{headerSlot}</div> : null}
</div> </div>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6"> <div className="flex-1 overflow-y-auto bg-gray-50/30 px-4 py-3 xl:px-6">
{children} {children}
</div> </div>
+62 -28
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',
},
)
} }
+40 -2
View File
@@ -52,6 +52,35 @@ const cleanText = (text: string) => {
.toLowerCase() .toLowerCase()
} }
const recalculoVectoresAsignaturasInFlight = new Set<string>()
function triggerRecalculoVectoresAsignaturasNonBlocking(
supabase: ReturnType<typeof supabaseBrowser>,
planId: UUID,
) {
const key = String(planId)
if (recalculoVectoresAsignaturasInFlight.has(key)) return
recalculoVectoresAsignaturasInFlight.add(key)
void (async () => {
const { error } = await supabase.rpc('recalcular_vectores_asignaturas')
if (error) {
// No debe bloquear ni romper el flujo principal.
console.warn(
'[recalcular_vectores_asignaturas] RPC error:',
error.message,
)
}
})()
.catch((err: unknown) => {
console.warn('[recalcular_vectores_asignaturas] RPC failed:', err)
})
.finally(() => {
recalculoVectoresAsignaturasInFlight.delete(key)
})
}
export async function plans_list( export async function plans_list(
filters: PlanListFilters = {}, filters: PlanListFilters = {},
): Promise<Paged<PlanEstudio>> { ): Promise<Paged<PlanEstudio>> {
@@ -192,7 +221,9 @@ export async function plan_lineas_list(
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('lineas_plan') .from('lineas_plan')
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en') .select(
'id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en,color',
)
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('orden', { ascending: true }) .order('orden', { ascending: true })
@@ -207,7 +238,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,criterios_de_evaluacion,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id,search_vector',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -215,6 +246,13 @@ export async function plan_asignaturas_list(
.order('nombre', { ascending: true }) .order('nombre', { ascending: true })
throwIfError(error) throwIfError(error)
// No bloqueante: si el primer registro viene sin vector, dispara el recalculo.
const first: any = (data as any)?.[0]
if (first && first.search_vector === null) {
triggerRecalculoVectoresAsignaturasNonBlocking(supabase, planId)
}
return data ?? [] return data ?? []
} }
+54 -2
View File
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion, id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
planes_estudio( planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
@@ -499,6 +499,7 @@ export async function lineas_insert(linea: {
plan_estudio_id: string plan_estudio_id: string
orden: number orden: number
area?: string area?: string
color?: string | null
}) { }) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
@@ -514,7 +515,12 @@ export async function lineas_insert(linea: {
// Actualizar una línea existente // Actualizar una línea existente
export async function lineas_update( export async function lineas_update(
lineaId: string, lineaId: string,
patch: { nombre?: string; orden?: number; area?: string }, patch: {
nombre?: string
orden?: number
area?: string
color?: string | null
},
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
@@ -585,3 +591,49 @@ export async function bibliografia_delete(id: string) {
if (error) throw error if (error) throw error
return id return id
} }
export async function checkPrerrequisitoConflicts(
asignaturaId: string,
nuevoCiclo: number,
): Promise<Array<string>> {
const supabase = supabaseBrowser()
// CORRECCIÓN 1: Agregamos 'id' al select
// CORRECCIÓN 2: Quitamos espacios en el .or()
const { data, error } = await supabase
.from('asignaturas')
.select('id, nombre, numero_ciclo, prerrequisito_asignatura_id')
.or(`prerrequisito_asignatura_id.eq.${asignaturaId},id.eq.${asignaturaId}`)
if (error) throw error
// CORRECCIÓN 3: 'data' ahora está disponible porque lo desestructuramos arriba
if (!data) return []
const conflictos: Array<string> = []
data.forEach((asig) => {
// Caso 1: Materias que tienen a esta como prerrequisito (Hijas)
// "Si yo me muevo al ciclo 5, y mi hija está en el 4, se rompe la regla"
if (asig.prerrequisito_asignatura_id === asignaturaId) {
if (asig.numero_ciclo !== null && asig.numero_ciclo <= nuevoCiclo) {
conflictos.push(asig.nombre)
}
}
// Caso 2: El prerrequisito de la materia que estoy moviendo (Padre)
// "Si yo me muevo al ciclo 2, y mi padre está en el 3, se rompe la regla"
if (asig.id === asignaturaId && asig.prerrequisito_asignatura_id) {
const padre = data.find((p) => p.id === asig.prerrequisito_asignatura_id)
if (
padre &&
padre.numero_ciclo !== null &&
padre.numero_ciclo >= nuevoCiclo
) {
conflictos.push(padre.nombre)
}
}
})
return conflictos
}
+33
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { import {
ai_generate_subject, ai_generate_subject,
@@ -6,6 +7,7 @@ import {
bibliografia_delete, bibliografia_delete,
bibliografia_insert, bibliografia_insert,
bibliografia_update, bibliografia_update,
checkPrerrequisitoConflicts,
lineas_insert, lineas_insert,
lineas_update, lineas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
@@ -317,3 +319,34 @@ export function useDeleteBibliografia(asignaturaId: string) {
}, },
}) })
} }
export function useAsignaturaConflictos() {
const [isValidating, setIsValidating] = useState(false)
const validarCambioCiclo = async (
asignaturaId: string,
nuevoCiclo: number,
) => {
setIsValidating(true)
try {
const nombresConflictivos = await checkPrerrequisitoConflicts(
asignaturaId,
nuevoCiclo,
)
if (nombresConflictivos.length > 0) {
const mensaje = `Si mueves esta materia al ciclo ${nuevoCiclo}, se perderá la seriación con:\n\n• ${nombresConflictivos.join('\n• ')}\n\n¿Deseas continuar?`
return confirm(mensaje) // Puedes usar un Modal de Shadcn aquí en lugar de confirm
}
return true // Sin conflictos
} catch (error) {
console.error(error)
return false
} finally {
setIsValidating(false)
}
}
return { validarCambioCiclo, isValidating }
}
+72 -1
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
} }
@@ -3,8 +3,10 @@ import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard' import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosClonadoInterno } from '@/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm' import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel' import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoFuenteClonadoInterno } from '@/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup' import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard' import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls' import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
@@ -63,7 +65,12 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
basicos: 'Sugerencias', basicos: 'Sugerencias',
detalles: 'Estructura', detalles: 'Estructura',
} }
: undefined : wizard.tipoOrigen === 'CLONADO_INTERNO'
? {
basicos: 'Fuente',
detalles: 'Datos básicos',
}
: undefined
const handleClose = () => { const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false }) navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
@@ -99,6 +106,21 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
> >
{({ methods }) => { {({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id) const idx = Wizard.utils.getIndex(methods.current.id)
const stepId = methods.current.id
const disableNext =
wizard.isLoading ||
(stepId === 'metodo'
? !canContinueDesdeMetodo
: stepId === 'basicos'
? wizard.tipoOrigen === 'CLONADO_INTERNO'
? !canContinueDesdeDetalles
: !canContinueDesdeBasicos
: stepId === 'detalles'
? wizard.tipoOrigen === 'CLONADO_INTERNO'
? !canContinueDesdeBasicos
: !canContinueDesdeDetalles
: false)
return ( return (
<WizardLayout <WizardLayout
@@ -118,12 +140,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
onPrev={() => methods.prev()} onPrev={() => methods.prev()}
onNext={() => methods.next()} onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading} disablePrev={idx === 0 || wizard.isLoading}
disableNext={ disableNext={disableNext}
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading} disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1} isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard} wizard={wizard}
@@ -141,13 +158,27 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
{idx === 1 && ( {idx === 1 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} /> {wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
<PasoFuenteClonadoInterno
wizard={wizard}
onChange={setWizard}
/>
) : (
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
)}
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{idx === 2 && ( {idx === 2 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoDetallesPanel wizard={wizard} onChange={setWizard} /> {wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
<PasoBasicosClonadoInterno
wizard={wizard}
onChange={setWizard}
/>
) : (
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
)}
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
+5 -3
View File
@@ -50,10 +50,12 @@ export type NewSubjectWizardState = {
} }
sugerencias: Array<AsignaturaSugerida> sugerencias: Array<AsignaturaSugerida>
clonInterno?: { clonInterno?: {
facultadId?: string facultadId?: string | null
carreraId?: string carreraId?: string | null
planOrigenId?: string planOrigenId?: string | null
asignaturaOrigenId?: string | null asignaturaOrigenId?: string | null
search?: string
page?: number
} }
clonTradicional?: { clonTradicional?: {
archivoWordAsignaturaId: string | null archivoWordAsignaturaId: string | null
@@ -2036,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>
@@ -2434,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>
+78
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,
}
+1 -1
View File
@@ -30,7 +30,7 @@ function RouteComponent() {
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop). 4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
5. py-6: Padding vertical (opcional, para separarse del header). 5. py-6: Padding vertical (opcional, para separarse del header).
*/} */}
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8"> <div className="mx-auto flex w-full flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
<DashboardHeader <DashboardHeader
nombre="Dr. Carlos Mendoza" nombre="Dr. Carlos Mendoza"
rol="Jefe de Carrera" rol="Jefe de Carrera"
+1 -1
View File
@@ -8,7 +8,7 @@ export const Route = createFileRoute('/login')({
function LoginPage() { function LoginPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#7b0f1d] via-[#6b0d1a] to-[#3a050a]"> <div className="login-bg flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat">
<LoginCard /> <LoginCard />
</div> </div>
) )
+98 -69
View File
@@ -8,18 +8,24 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react' import { useState, useEffect, forwardRef } from 'react'
import type { Database } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api' import { plans_get } from '@/data/api/plans.api'
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans' import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
import { cn } from '@/lib/utils'
type NivelPlanEstudio = Database['public']['Enums']['nivel_plan_estudio']
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => { loader: async ({ context: { queryClient }, params: { planId } }) => {
@@ -54,28 +60,26 @@ function RouteComponent() {
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
const [nivelPlan, setNivelPlan] = useState('') const [nivelPlan, setNivelPlan] = useState<NivelPlanEstudio | undefined>(
const [isDirty, setIsDirty] = useState(false) undefined,
)
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setNombrePlan(data.nombre || '') setNombrePlan(data.nombre || '')
setNivelPlan(data.nivel || '') setNivelPlan(data.nivel)
} }
}, [data]) }, [data])
const niveles = [ const niveles: Array<NivelPlanEstudio> = [
'Licenciatura', 'Licenciatura',
'Maestría', 'Maestría',
'Doctorado', 'Doctorado',
'Diplomado',
'Especialidad', 'Especialidad',
'Diplomado',
'Otro',
] ]
const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const MAX_CHARACTERS = 200 const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -114,20 +118,20 @@ function RouteComponent() {
} }
} }
return ( return (
<div className="min-h-screen bg-white"> <div className="bg-background min-h-screen">
{/* 1. Header Superior */} {/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm"> <div className="bg-background/80 sticky top-0 z-20 border-b shadow-sm backdrop-blur-sm">
<div className="px-6 py-2"> <div className="px-6 py-2">
<Link <Link
to="/planes" to="/planes"
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800" className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-xs transition-colors"
> >
<ChevronLeft size={14} /> Volver a planes <ChevronLeft size={14} /> Volver a planes
</Link> </Link>
</div> </div>
</div> </div>
<div className="mx-auto max-w-400 space-y-8 p-8"> <div className="mx-auto space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
{/* 2. Header del Plan */} {/* 2. Header del Plan */}
{isLoading ? ( {isLoading ? (
/* ===== SKELETON ===== */ /* ===== SKELETON ===== */
@@ -139,7 +143,7 @@ function RouteComponent() {
) : ( ) : (
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div> <div>
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900"> <h1 className="text-foreground flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight">
{/* El prefijo "Nivel en" lo mantenemos simple */} {/* El prefijo "Nivel en" lo mantenemos simple */}
<span className="shrink-0">{nivelPlan} en</span> <span className="shrink-0">{nivelPlan} en</span>
<span <span
@@ -148,80 +152,105 @@ function RouteComponent() {
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
spellCheck={false} spellCheck={false}
aria-label="Nombre del plan"
title="Nombre del plan"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} // Añadido para controlar lo que pegan onPaste={handlePaste}
onBlur={(e) => { onBlur={(e) => {
const nuevoNombre = const nuevoNombre = e.currentTarget.textContent.trim()
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre) setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) { if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } }) mutate({ planId, patch: { nombre: nuevoNombre } })
} }
}} }}
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent wrap-break-word whitespace-pre-wrap no-underline transition-colors outline-none select-text sm:inline-block sm:w-auto"
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
style={{ textDecoration: 'none' }}
> >
{nombrePlan} {nombrePlan}
</span> </span>
</h1> </h1>
<p className="mt-1 text-lg font-medium text-slate-500"> <p className="text-muted-foreground mt-1 text-lg font-medium">
{data?.carreras?.facultades?.nombre}{' '} {data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto} {data?.carreras?.nombre_corto}
</p> </p>
</div> </div>
<div className="flex gap-2"> {(() => {
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100"> const estadoColorHex = (data?.estados_plan as any)?.color as
{data?.estados_plan?.etiqueta} | string
</Badge> | undefined
</div> const badgeStyle = estadoColorHex
? ({
backgroundColor: estadoColorHex,
borderColor: estadoColorHex,
} as const)
: undefined
return (
<Badge
style={badgeStyle}
className={cn(
'text-sm font-semibold',
!estadoColorHex &&
'border-primary/20 bg-primary/10 text-primary hover:bg-primary/20',
)}
>
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
{data?.estados_plan?.etiqueta}
</span>
</Badge>
)
})()}
</div> </div>
)} )}
{/* 3. Cards de Información */} {/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu> <div className="border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all">
<DropdownMenuTrigger asChild> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<InfoCard <GraduationCap className="text-muted-foreground" />
icon={<GraduationCap className="text-slate-400" />} </div>
label="Nivel" <div className="min-w-0 flex-1">
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
Nivel
</p>
<Select
value={nivelPlan} value={nivelPlan}
isEditable onValueChange={(value) => {
/> const nuevoNivel = value as NivelPlanEstudio
</DropdownMenuTrigger> setNivelPlan(nuevoNivel)
if (nuevoNivel !== data?.nivel) {
<DropdownMenuContent className="w-48"> mutate({ planId, patch: { nivel: nuevoNivel } })
{niveles.map((n) => ( }
<DropdownMenuItem }}
key={n} >
onClick={() => { <SelectTrigger className="w-full" size="sm">
setNivelPlan(n) <SelectValue placeholder="---" />
if (n !== data?.nivel) { </SelectTrigger>
mutate({ planId, patch: { nivel: n } }) <SelectContent>
} {niveles.map((n) => (
}} <SelectItem key={n} value={n}>
> {n}
{n} </SelectItem>
</DropdownMenuItem> ))}
))} </SelectContent>
</DropdownMenuContent> </Select>
</DropdownMenu> </div>
</div>
<InfoCard <InfoCard
icon={<Clock className="text-slate-400" />} icon={<Clock className="text-muted-foreground" />}
label="Duración" label="Duración"
value={`${data?.numero_ciclos || 0} Ciclos`} value={`${data?.numero_ciclos || 0} Ciclos`}
/> />
<InfoCard <InfoCard
icon={<Hash className="text-slate-400" />} icon={<Hash className="text-muted-foreground" />}
label="Créditos" label="Créditos"
value="320" value="320"
/> />
<InfoCard <InfoCard
icon={<CalendarDays className="text-slate-400" />} icon={<CalendarDays className="text-muted-foreground" />}
label="Creación" label="Creación"
value={data?.creado_en?.split('T')[0]} value={data?.creado_en.split('T')[0]}
/> />
</div> </div>
@@ -276,20 +305,20 @@ const InfoCard = forwardRef<
<div <div
ref={ref} ref={ref}
{...props} {...props}
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${ className={`border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all ${
isEditable isEditable
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40' ? 'hover:border-primary/50 hover:bg-accent focus-visible:ring-primary/40 cursor-pointer focus:outline-none focus-visible:ring-2'
: '' : ''
} ${className ?? ''}`} } ${className ?? ''}`}
> >
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm"> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
{icon} {icon}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase"> <p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
{label} {label}
</p> </p>
<p className="truncate text-sm font-semibold text-slate-700"> <p className="text-foreground truncate text-sm font-semibold">
{value || '---'} {value || '---'}
</p> </p>
</div> </div>
@@ -311,8 +340,8 @@ function Tab({
<Link <Link
to={to} to={to}
params={params} params={params}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800" className="text-muted-foreground hover:text-foreground border-b-2 border-transparent pb-3 text-sm font-medium transition-all"
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }} activeProps={{ className: 'border-primary text-primary font-bold' }}
activeOptions={{ activeOptions={{
exact: true, exact: true,
}} }}
@@ -324,7 +353,7 @@ function Tab({
function DatosGeneralesSkeleton() { function DatosGeneralesSkeleton() {
return ( return (
<div className="rounded-xl border bg-white"> <div className="bg-card rounded-xl border">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b px-5 py-3"> <div className="flex items-center justify-between border-b px-5 py-3">
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
@@ -73,7 +73,7 @@ const mapAsignaturas = (
estado: asig.estado, estado: asig.estado,
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,
})) }))
} }
@@ -129,7 +129,7 @@ function AsignaturasPage() {
} }
return ( return (
<div className="container mx-auto space-y-6 px-6 py-6"> <div className="w-full space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
@@ -152,7 +152,7 @@ function AsignaturasPage() {
resetScroll: false, resetScroll: false,
}) })
}} }}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors" className="shadow-md"
> >
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura <Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
</Button> </Button>
@@ -218,26 +218,30 @@ function AsignaturasPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50/50"> <TableRow className="bg-slate-50/50">
<TableHead className="w-30">Clave</TableHead> <TableHead className="w-30 px-6 py-4">Clave</TableHead>
<TableHead>Nombre</TableHead> <TableHead className="px-6 py-4">Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead> <TableHead className="px-6 py-4 text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead> <TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead> <TableHead className="px-6 py-4">Línea Curricular</TableHead>
<TableHead>Tipo</TableHead> <TableHead className="px-6 py-4">Tipo</TableHead>
<TableHead>Estado</TableHead> <TableHead className="px-6 py-4">Estado</TableHead>
<TableHead className="w-12.5"></TableHead> <TableHead className="w-12.5 px-6 py-4"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredAsignaturas.length === 0 ? ( {filteredAsignaturas.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-40 text-center"> <TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center"> <div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
<BookOpen className="mb-2 h-10 w-10 opacity-20" /> <BookOpen className="h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p> <div>
<p className="text-xs"> <p className="font-medium">
Intenta cambiar los filtros de búsqueda No se encontraron asignaturas
</p> </p>
<p className="mt-1 text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -251,25 +255,25 @@ function AsignaturasPage() {
to: '/planes/$planId/asignaturas/$asignaturaId', to: '/planes/$planId/asignaturas/$asignaturaId',
params: { params: {
planId, planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug asignaturaId: asignatura.id,
}, },
state: { state: {
realId: asignatura.id, // 👈 ID largo oculto realId: asignatura.id,
asignaturaId: asignatura.id, asignaturaId: asignatura.id,
} as any, } as any,
}) })
} }
> >
<TableCell className="font-mono text-xs font-bold text-slate-400"> <TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
{asignatura.clave} {asignatura.clave}
</TableCell> </TableCell>
<TableCell className="font-semibold text-slate-700"> <TableCell className="px-6 py-4 font-semibold text-slate-700">
{asignatura.nombre} {asignatura.nombre}
</TableCell> </TableCell>
<TableCell className="text-center font-medium"> <TableCell className="px-6 py-4 text-center font-medium">
{asignatura.creditos} {asignatura.creditos}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="px-6 py-4 text-center">
{asignatura.ciclo ? ( {asignatura.ciclo ? (
<Badge variant="outline" className="font-normal"> <Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo} Ciclo {asignatura.ciclo}
@@ -278,10 +282,10 @@ function AsignaturasPage() {
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-sm text-slate-600"> <TableCell className="px-6 py-4 text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)} {getLineaNombre(asignatura.lineaCurricularId)}
</TableCell> </TableCell>
<TableCell> <TableCell className="px-6 py-4">
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`} className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
@@ -289,7 +293,7 @@ function AsignaturasPage() {
{tipoConfig[asignatura.tipo].label} {tipoConfig[asignatura.tipo].label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="px-6 py-4">
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`} className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
@@ -297,7 +301,7 @@ function AsignaturasPage() {
{statusConfig[asignatura.estado].label} {statusConfig[asignatura.estado].label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="px-6 py-4">
<div className="opacity-0 transition-opacity group-hover:opacity-100"> <div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" /> <ChevronRight className="h-5 w-5 text-slate-400" />
</div> </div>
@@ -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,8 +80,29 @@ 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">
{/* HEADER DE ACCIONES */} {/* HEADER DE ACCIONES */}
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div> <div>
@@ -88,12 +122,13 @@ function RouteComponent() {
> >
<RefreshCcw size={16} /> Regenerar <RefreshCcw size={16} /> Regenerar
</Button> </Button>
<Button variant="outline" size="sm" className="gap-2"> <Button size="sm" className="gap-2" onClick={handleDownloadWord}>
<Download size={16} /> Descargar Word <Download size={16} /> Descargar Word
</Button> </Button>
<Button <Button
variant="outline"
size="sm" size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800" className="gap-2"
onClick={handleDownloadPdf} onClick={handleDownloadPdf}
> >
<Download size={16} /> Descargar PDF <Download size={16} /> Descargar PDF
@@ -139,7 +174,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 +184,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 +198,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,
+2 -2
View File
@@ -18,7 +18,7 @@ function RouteComponent() {
console.log(rawData) console.log(rawData)
return ( return (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6">
{/* Header Informativo (Opcional, si no viene del layout padre) */} {/* Header Informativo (Opcional, si no viene del layout padre) */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -146,7 +146,7 @@ function RouteComponent() {
/> />
</div> </div>
<Button className="w-full bg-teal-600 hover:bg-teal-700" disabled> <Button className="w-full" disabled>
Avanzar a Revisión Expertos Avanzar a Revisión Expertos
</Button> </Button>
</CardContent> </CardContent>
@@ -9,7 +9,6 @@ import {
User, User,
Loader2, Loader2,
Clock, Clock,
Eye,
History, History,
Calendar, Calendar,
ChevronLeft, ChevronLeft,
@@ -37,24 +36,24 @@ const getEventConfig = (tipo: string, campo: string) => {
return { return {
label: 'Creación', label: 'Creación',
icon: <PlusCircle className="h-4 w-4" />, icon: <PlusCircle className="h-4 w-4" />,
color: 'teal', color: 'primary',
} }
if (campo === 'estado') if (campo === 'estado')
return { return {
label: 'Cambio de estado', label: 'Cambio de estado',
icon: <GitBranch className="h-4 w-4" />, icon: <GitBranch className="h-4 w-4" />,
color: 'blue', color: 'secondary',
} }
if (campo === 'datos') if (campo === 'datos')
return { return {
label: 'Edición de Datos', label: 'Edición de Datos',
icon: <Edit3 className="h-4 w-4" />, icon: <Edit3 className="h-4 w-4" />,
color: 'amber', color: 'accent',
} }
return { return {
label: 'Actualización', label: 'Actualización',
icon: <RefreshCw className="h-4 w-4" />, icon: <RefreshCw className="h-4 w-4" />,
color: 'slate', color: 'muted',
} }
} }
@@ -104,7 +103,7 @@ function RouteComponent() {
}, },
} }
}) })
}, [rawData]) }, [rawData, structure, data])
const openCompareModal = (event: any) => { const openCompareModal = (event: any) => {
setSelectedEvent(event) setSelectedEvent(event)
@@ -120,16 +119,16 @@ function RouteComponent() {
if (isLoading) if (isLoading)
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-teal-600" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</div> </div>
) )
return ( return (
<div className="mx-auto max-w-5xl p-6"> <div className="mx-auto">
<div className="mb-8 flex items-end justify-between"> <div className="mb-8 flex items-end justify-between">
<div> <div>
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800"> <h1 className="text-foreground flex items-center gap-2 text-xl font-bold">
<Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del <Clock className="text-primary h-5 w-5" /> Historial de Cambios del
Plan Plan
</h1> </h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -139,25 +138,39 @@ function RouteComponent() {
</div> </div>
<div className="relative space-y-0"> <div className="relative space-y-0">
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" /> <div className="bg-border absolute top-0 bottom-0 left-6 w-px md:left-9" />
{historyEvents.length === 0 ? ( {historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500">No hay registros.</div> <div className="text-muted-foreground ml-20 py-10">
No hay registros.
</div>
) : ( ) : (
historyEvents.map((event) => ( historyEvents.map((event) => (
<div key={event.id} className="group relative flex gap-6 pb-8"> <div
<div className="relative z-10 flex h-18 flex-col items-center"> key={event.id}
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600"> className="group relative flex gap-3 pb-8 md:gap-6"
>
<div className="relative z-10 flex flex-col items-center">
<div className="border-background bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 shadow-sm transition-colors">
{event.icon} {event.icon}
</div> </div>
</div> </div>
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200"> <Card
className="border-border hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
onClick={() => openCompareModal(event)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
openCompareModal(event)
}}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-2">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */} {/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<div className="flex items-center justify-between"> <div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold text-slate-800"> <span className="text-foreground text-sm font-bold">
{event.type} {event.type}
</span> </span>
<Badge <Badge
@@ -172,26 +185,17 @@ function RouteComponent() {
</div> </div>
{/* Grupo de elementos alineados a la derecha */} {/* Grupo de elementos alineados a la derecha */}
<div className="flex items-center gap-4 text-slate-500"> <div className="text-muted-foreground flex flex-wrap items-center gap-3 md:gap-4">
{/* Usuario e Icono */} {/* Usuario e Icono */}
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-1.5 text-xs">
<User className="h-3.5 w-3.5" /> <User className="h-3.5 w-3.5" />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{event.user} {event.user}
</span> </span>
</div> </div>
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
>
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */} {/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="hidden text-[11px] text-slate-400 md:block"> <span className="text-muted-foreground/70 hidden text-[11px] lg:block">
{format(event.date, 'yyyy-MM-dd HH:mm')} {format(event.date, 'yyyy-MM-dd HH:mm')}
</span> </span>
</div> </div>
@@ -199,7 +203,7 @@ function RouteComponent() {
{/* LÍNEA INFERIOR: Descripción */} {/* LÍNEA INFERIOR: Descripción */}
<div className="mt-1"> <div className="mt-1">
<p className="text-sm text-slate-600"> <p className="text-muted-foreground text-sm">
{event.description} {event.description}
</p> </p>
@@ -210,16 +214,16 @@ function RouteComponent() {
<div className="mt-2 flex items-center gap-1.5"> <div className="mt-2 flex items-center gap-1.5">
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-red-50 px-1.5 text-[9px] text-red-700" className="bg-destructive/10 text-destructive px-1.5 text-[9px]"
> >
{event.details.from} {event.details.from}
</Badge> </Badge>
<span className="text-[10px] text-slate-400"> <span className="text-muted-foreground/70 text-[10px]">
</span> </span>
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700" className="bg-primary/10 text-primary px-1.5 text-[9px]"
> >
{event.details.to} {event.details.to}
</Badge> </Badge>
@@ -233,8 +237,8 @@ function RouteComponent() {
)) ))
)} )}
{historyEvents.length > 0 && ( {historyEvents.length > 0 && (
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4"> <div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-slate-500"> <p className="text-muted-foreground text-xs">
Mostrando {rawData.length} de {totalRecords} cambios Mostrando {rawData.length} de {totalRecords} cambios
</p> </p>
@@ -252,7 +256,7 @@ function RouteComponent() {
Anterior Anterior
</Button> </Button>
<span className="text-sm font-medium text-slate-700"> <span className="text-foreground text-sm font-medium">
Página {page + 1} de {totalPages || 1} Página {page + 1} de {totalPages || 1}
</span> </span>
@@ -263,7 +267,6 @@ function RouteComponent() {
setPage((p) => p + 1) setPage((p) => p + 1)
window.scrollTo(0, 0) window.scrollTo(0, 0)
}} }}
// Ahora se deshabilita si llegamos a la última página real
disabled={page + 1 >= totalPages || isLoading} disabled={page + 1 >= totalPages || isLoading}
> >
Siguiente Siguiente
@@ -277,9 +280,9 @@ function RouteComponent() {
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */} {/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0"> <DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b bg-slate-50/50 p-6"> <DialogHeader className="bg-muted/50 border-b p-6">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5 text-teal-600" /> Comparación de <History className="text-primary h-5 w-5" /> Comparación de
Versiones Versiones
</DialogTitle> </DialogTitle>
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs"> <div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
@@ -298,17 +301,16 @@ function RouteComponent() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6"> <div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
{/* Lado Antes: Solo se renderiza si existe valor_anterior */} {/* Lado Antes: Solo se renderiza si existe valor_anterior */}
{selectedEvent?.details.from && ( {selectedEvent?.details.from && (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1"> <div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
<div className="h-2 w-2 rounded-full bg-red-400" /> <div className="bg-destructive h-2 w-2 rounded-full" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase"> <span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior Versión Anterior
</span> </span>
</div> </div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700"> <div className="border-destructive/20 bg-destructive/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{renderValue(selectedEvent.details.from)} {renderValue(selectedEvent.details.from)}
</div> </div>
</div> </div>
@@ -316,27 +318,22 @@ function RouteComponent() {
{/* Lado Después */} {/* Lado Después */}
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1"> <div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
<div className="h-2 w-2 rounded-full bg-emerald-400" /> <div className="bg-primary h-2 w-2 rounded-full" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase"> <span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Nueva Versión Nueva Versión
</span> </span>
</div> </div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700"> <div className="border-primary/20 bg-primary/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{renderValue(selectedEvent?.details.to)} {renderValue(selectedEvent?.details.to)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-center border-t bg-slate-50 p-4"> <div className="bg-muted/30 flex justify-center border-t p-4">
<Badge variant="outline" className="font-mono text-[10px]"> <Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo} Campo: {selectedEvent?.campo}
{console.log(
data?.estructuras_plan?.definicion?.properties?.[
selectedEvent?.campo
]?.title,
)}
</Badge> </Badge>
</div> </div>
</DialogContent> </DialogContent>
+379 -185
View File
@@ -15,6 +15,7 @@ import {
Archive, Archive,
Loader2, Loader2,
Sparkles, Sparkles,
RotateCcw,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -39,6 +40,8 @@ import {
useMessagesByChat, useMessagesByChat,
useUpdateConversationStatus, useUpdateConversationStatus,
useUpdateConversationTitle, useUpdateConversationTitle,
useUpdatePlanFields,
useUpdateRecommendationApplied,
} from '@/data' } from '@/data'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
@@ -112,7 +115,7 @@ function RouteComponent() {
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } = const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null useMessagesByChat(activeChatId ?? null)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -128,6 +131,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)
@@ -137,12 +141,21 @@ function RouteComponent() {
null, null,
) )
const [filterQuery, setFilterQuery] = useState('') const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const [selectedImprovements, setSelectedImprovements] = useState<
Array<string>
>([])
const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion ?.definicion as EstructuraDefinicion
// Encadenamiento opcional para evitar errores si data es null if (!definicion?.properties) return []
if (!definicion.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({ return Object.entries(definicion.properties).map(([key, value]) => ({
key, key,
@@ -155,28 +168,24 @@ function RouteComponent() {
return availableFields.filter( return availableFields.filter(
(field) => (field) =>
field.label.toLowerCase().includes(filterQuery.toLowerCase()) && field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados !selectedFields.some((s) => s.key === field.key),
) )
}, [availableFields, filterQuery, selectedFields]) }, [availableFields, filterQuery, selectedFields])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
if (!activeChatId || !mensajesDelChat) return [] if (!activeChatId || !mensajesDelChat) return []
// flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
return mensajesDelChat.flatMap((msg: any) => { return mensajesDelChat.flatMap((msg: any) => {
const messages = [] const messages = []
// 1. Mensaje del Usuario
messages.push({ messages.push({
id: `${msg.id}-user`, id: `${msg.id}-user`,
role: 'user', role: 'user',
content: msg.mensaje, content: msg.mensaje,
selectedFields: msg.campos || [], // Aquí están tus campos selectedFields: msg.campos || [],
}) })
// 2. Mensaje del Asistente (si hay respuesta)
if (msg.respuesta) { if (msg.respuesta) {
// Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
const rawRecommendations = msg.propuesta?.recommendations || [] const rawRecommendations = msg.propuesta?.recommendations || []
messages.push({ messages.push({
@@ -204,20 +213,105 @@ function RouteComponent() {
return messages return messages
}) })
}, [mensajesDelChat, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => {
const handleApplyMultiple = async (
sugerencias: Array<any>,
dbMessageId: string,
) => {
if (!planId || !data?.datos || sugerencias.length === 0) return
setIsSending(true)
try {
const datosActualizados = { ...data.datos }
for (const sug of sugerencias) {
const key = sug.key
const newValue = sug.newValue
const currentValue = datosActualizados[key]
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
datosActualizados[key] = { ...currentValue, description: newValue }
} else {
datosActualizados[key] = newValue
}
}
await updatePlan.mutateAsync({
planId: planId as any,
patch: { datos: datosActualizados },
})
for (const sug of sugerencias) {
try {
await updateAppliedStatus.mutateAsync({
conversacionId: dbMessageId,
campoAfectado: sug.key,
})
removeSelectedField(sug.key)
} catch (err) {
console.error(
`Error al marcar aplicada la sugerencia: ${sug.key}`,
err,
)
}
}
setSelectedImprovements([])
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['plan', planId] }),
queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }),
])
} catch (error) {
console.error('Error crítico en aplicación masiva:', error)
} finally {
setIsSending(false)
}
}
const toggleImprovementSelection = (sugKey: string) => {
setSelectedImprovements((prev) =>
prev.includes(sugKey)
? prev.filter((key) => key !== sugKey)
: [...prev, sugKey],
)
}
const toggleAllFromMessage = (suggestions: Array<any>) => {
const pending = suggestions.filter((s) => !s.applied)
const allKeys = pending.map((s) => s.key)
const allSelected = allKeys.every((key) =>
selectedImprovements.includes(key),
)
if (allSelected) {
setSelectedImprovements((prev) =>
prev.filter((key) => !allKeys.includes(key)),
)
} else {
setSelectedImprovements((prev) =>
Array.from(new Set([...prev, ...allKeys])),
)
}
}
const scrollToBottom = (behavior: ScrollBehavior = '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,
}) })
} }
} }
} }
const { activeChats, archivedChats } = useMemo(() => { const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || [] const allChats = lastConversation || []
return { return {
@@ -229,22 +323,19 @@ function RouteComponent() {
}, [lastConversation]) }, [lastConversation])
useEffect(() => { useEffect(() => {
console.log(mensajesDelChat) if (chatMessages.length > 0) {
if (isInitialLoad.current) {
scrollToBottom() scrollToBottom('instant')
}, [chatMessages, isLoading]) isInitialLoad.current = false
} else {
/* useEffect(() => { scrollToBottom('smooth')
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input }
const camposActualizados = selectedFields.filter((field) =>
input.includes(field.label),
)
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
} }
}, [input, selectedFields]) */ }, [chatMessages])
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
useEffect(() => { useEffect(() => {
if (isLoadingConv || isSending) return if (isLoadingConv || isSending) return
@@ -254,14 +345,12 @@ function RouteComponent() {
) )
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !currentChatExists && !isCreationMode) { if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
return return
} }
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if ( if (
!activeChatId && !activeChatId &&
activeChats.length > 0 && activeChats.length > 0 &&
@@ -295,7 +384,7 @@ function RouteComponent() {
}, [availableFields, routerState.location.state]) }, [availableFields, routerState.location.state])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo setActiveChatId(undefined)
setMessages([ setMessages([
{ {
id: 'welcome', id: 'welcome',
@@ -304,7 +393,6 @@ function RouteComponent() {
}, },
]) ])
setInput('') setInput('')
// setSelectedFields([])
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -346,16 +434,15 @@ function RouteComponent() {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value const val = e.target.value
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario const cursorPosition = e.target.selectionStart
setInput(val) setInput(val)
// Busca un ":" seguido de letras justo antes del cursor
const textBeforeCursor = val.slice(0, cursorPosition) const textBeforeCursor = val.slice(0, cursorPosition)
const match = textBeforeCursor.match(/:(\w*)$/) const match = textBeforeCursor.match(/:(\w*)$/)
if (match) { if (match) {
setShowSuggestions(true) setShowSuggestions(true)
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado setFilterQuery(match[1])
} else { } else {
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('') setFilterQuery('')
@@ -366,40 +453,31 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
// 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
// Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim() const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ') const fieldLabels = fields.map((f) => f.label).join(', ')
// 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
return `${cleaned}: ${fieldLabels}` return `${cleaned}: ${fieldLabels}`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => { setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key) const isSelected = prev.find((f) => f.key === field.key)
return isSelected ? prev : [...prev, field] return isSelected ? prev : [...prev, field]
}) })
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
setInput((prev) => { setInput((prev) => {
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
const nuevoTexto = prev.replace(/:(\w*)$/, field.label) const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo return nuevoTexto + ' '
}) })
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('') setFilterQuery('')
} }
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => { const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
if (fields.length === 0) return userInput if (fields.length === 0) return userInput
return ` ${userInput}` return ` ${userInput}`
} }
@@ -412,7 +490,6 @@ function RouteComponent() {
setIsSending(true) setIsSending(true)
setOptimisticMessage(finalContent) setOptimisticMessage(finalContent)
setInput('') setInput('')
// setSelectedFields([])
try { try {
const payload = { const payload = {
@@ -431,7 +508,6 @@ function RouteComponent() {
setActiveChatId(response.conversacionId) setActiveChatId(response.conversacionId)
} }
// ESPERAMOS a que la caché se actualice antes de quitar el "isSending"
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], queryKey: ['conversation-by-plan', planId],
@@ -444,19 +520,15 @@ function RouteComponent() {
console.error('Error:', error) console.error('Error:', error)
setOptimisticMessage(null) setOptimisticMessage(null)
} finally { } finally {
// Solo ahora quitamos los indicadores de carga
setIsSending(false) setIsSending(false)
// setOptimisticMessage(null)
} }
} }
useEffect(() => { useEffect(() => {
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
// Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
// Ahora la validación es directa y no debería dar avisos de "unnecessary"
if (ultimoMensajeDB?.respuesta) { if (ultimoMensajeDB?.respuesta) {
setIsSyncing(false) setIsSyncing(false)
setOptimisticMessage(null) setOptimisticMessage(null)
@@ -476,59 +548,64 @@ function RouteComponent() {
} }
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
{/* --- HEADER MÓVIL --- */}
<div className="bg-background flex shrink-0 items-center justify-between rounded-lg border p-2 shadow-sm md:hidden">
<Button
variant="ghost"
size="sm"
onClick={() => setIsHistoryOpen(true)}
>
<Archive size={18} className="mr-2" /> Historial
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsActionsOpen(true)}
>
<Lightbulb size={18} className="text-primary mr-2" /> Acciones
</Button>
</div>
{/* --- PANEL IZQUIERDO: HISTORIAL --- */} {/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4"> <div className="hidden w-64 flex-col border-r pr-4 md:flex">
<div className="mb-4"> <h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
<div className="mb-4 flex items-center justify-between px-2"> Chats
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase"> </h2>
Chats <Button
</h2> onClick={createNewChat}
{/* Botón de toggle archivados movido aquí arriba */} variant="outline"
<button className="mt-2 mb-4 w-full justify-start gap-2"
onClick={() => setShowArchived(!showArchived)} >
className={`rounded-md p-1.5 transition-colors ${ <MessageSquarePlus size={18} /> Nuevo chat
showArchived </Button>
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
</div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1"> <div className="space-y-1 pr-2">
{!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 items-center justify-between overflow-hidden 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-accent text-foreground font-medium'
: 'text-slate-600 hover:bg-slate-50' : 'text-muted-foreground hover:bg-accent/50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto con Tooltip */} <div
<div className="flex min-w-0 flex-1 items-center gap-3"> className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{
maskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
}}
>
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}> <TooltipProvider delayDuration={400}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild className="min-w-0 flex-1">
{/* Este contenedor es el que obliga al span a truncarse */} <div className="min-w-0 flex-1">
<div className="max-w-[calc(100%-48px)] min-w-0 flex-1">
<span <span
ref={ ref={
editingChatId === chat.id ? editableRef : null editingChatId === chat.id ? editableRef : null
@@ -537,7 +614,7 @@ function RouteComponent() {
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
className={`block truncate outline-none ${ className={`block truncate outline-none ${
editingChatId === chat.id 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' ? 'bg-background ring-primary max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded px-1 break-all shadow-sm ring-1'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -558,7 +635,7 @@ function RouteComponent() {
onBlur={(e) => { onBlur={(e) => {
if (editingChatId === chat.id) { if (editingChatId === chat.id) {
const newTitle = const newTitle =
e.currentTarget.textContent?.trim() || '' e.currentTarget.textContent.trim() || ''
if (newTitle && newTitle !== chat.nombre) { if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({ updateTitleMutation({
id: chat.id, id: chat.id,
@@ -574,8 +651,6 @@ function RouteComponent() {
</span> </span>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
{/* Tooltip: Solo aparece si no estás editando y el texto es largo */}
{editingChatId !== chat.id && ( {editingChatId !== chat.id && (
<TooltipContent <TooltipContent
side="right" side="right"
@@ -588,10 +663,9 @@ function RouteComponent() {
</TooltipProvider> </TooltipProvider>
</div> </div>
{/* LADO DERECHO: Acciones con shrink-0 para que no se muevan */}
<div <div
className={`flex shrink-0 items-center gap-1 pl-2 opacity-0 transition-opacity group-hover:opacity-100 ${ 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' activeChatId === chat.id ? 'bg-accent' : 'bg-transparent'
}`} }`}
> >
<button <button
@@ -600,13 +674,13 @@ function RouteComponent() {
setEditingChatId(chat.id) setEditingChatId(chat.id)
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600" className="text-muted-foreground hover:text-primary rounded-md p-1 transition-colors"
> >
<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="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600" className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -614,26 +688,25 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* Sección de archivados */
<div className="animate-in fade-in slide-in-from-left-2 px-1"> <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="text-muted-foreground mb-2 px-2 text-[10px] font-bold 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 justify-between overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="bg-muted/50 text-muted-foreground group relative mb-1 flex w-full items-center overflow-hidden rounded-lg px-3 py-2 text-sm"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="block min-w-0 flex-1 truncate"> <span className="block truncate">
{chat.nombre || {chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`} `Archivado ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div> </div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="ml-2 shrink-0 rounded bg-slate-50/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600" className="bg-accent hover:text-primary absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -644,22 +717,22 @@ function RouteComponent() {
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
{/* PANEL DE CHAT PRINCIPAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm"> {/* --- PANEL DE CHAT PRINCIPAL --- */}
{/* NUEVO: Barra superior de campos seleccionados */} <div className="border-border/60 bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm md:h-full md:flex-[3]">
<div className="shrink-0 border-b bg-white p-3"> <div className="bg-background z-10 shrink-0 border-b p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase"> <span className="text-muted-foreground text-[10px] font-bold uppercase">
Mejorar con IA Mejorar con IA
</span> </span>
<button <button
onClick={() => setOpenIA(true)} onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200" className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition"
> >
<Archive size={14} className="text-slate-500" /> <Archive size={14} className="opacity-70" />
Referencias Referencias
{totalReferencias > 0 && ( {totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white"> <span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]">
{totalReferencias} {totalReferencias}
</span> </span>
)} )}
@@ -667,22 +740,21 @@ function RouteComponent() {
</div> </div>
</div> </div>
{/* CONTENIDO DEL CHAT */} <div className="relative flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{!activeChatId && {!activeChatId &&
chatMessages.length === 0 && chatMessages.length === 0 &&
!optimisticMessage ? ( !optimisticMessage ? (
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40"> <div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus <MessageSquarePlus
size={48} size={48}
className="mb-4 text-slate-300" className="text-muted-foreground/50 mb-4"
/> />
<h3 className="text-lg font-medium text-slate-900"> <h3 className="text-foreground text-lg font-medium">
No hay un chat seleccionado No hay un chat seleccionado
</h3> </h3>
<p className="text-sm text-slate-500"> <p className="text-muted-foreground text-sm">
Selecciona un chat del historial o crea uno nuevo para Selecciona un chat del historial o crea uno nuevo para
empezar. empezar.
</p> </p>
@@ -692,7 +764,6 @@ function RouteComponent() {
{chatMessages.map((msg: any) => { {chatMessages.map((msg: any) => {
const isAI = msg.role === 'assistant' const isAI = msg.role === 'assistant'
const isUser = msg.role === 'user' const isUser = msg.role === 'user'
// IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
const isProcessing = msg.isProcessing const isProcessing = msg.isProcessing
return ( return (
@@ -705,47 +776,123 @@ function RouteComponent() {
<div <div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${ className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
isUser isUser
? 'rounded-tr-none bg-teal-600 text-white' ? 'bg-primary text-primary-foreground rounded-tr-none'
: `rounded-tl-none border bg-white text-slate-700 ${ : `bg-card text-card-foreground rounded-tl-none border ${
msg.isRefusal msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100' ? 'border-destructive/50 bg-destructive/10 ring-destructive/20 ring-1'
: 'border-slate-100' : 'border-border'
}` }`
}`} }`}
> >
{/* Aviso de Refusal */}
{msg.isRefusal && ( {msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase"> <div className="text-destructive mb-1 flex items-center gap-1 text-[10px] font-bold uppercase">
<span>Aviso del Asistente</span> <span>Aviso del Asistente</span>
</div> </div>
)} )}
{/* CONTENIDO CORRECTO: Usamos msg.content */}
{isAI && isProcessing ? ( {isAI && isProcessing ? (
<div className="flex items-center gap-2 py-1"> <div className="flex items-center gap-2 py-1">
<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" /> <span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" /> <span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" /> <span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]" />
</div> </div>
</div> </div>
) : ( ) : (
msg.content // <--- CAMBIO CLAVE msg.content
)} )}
{/* Recomendaciones */}
{isAI && msg.suggestions?.length > 0 && ( {isAI && msg.suggestions?.length > 0 && (
<div className="mt-4"> <div className="border-border/60 bg-muted/50 mt-4 w-full space-y-3 rounded-xl border p-3">
<ImprovementCard <div className="flex items-center justify-between px-1">
suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap <span className="text-muted-foreground text-[10px] font-bold uppercase">
dbMessageId={msg.dbMessageId} Sugerencias de mejora
planId={planId} </span>
currentDatos={data?.datos} {msg.suggestions.some(
activeChatId={activeChatId} (s: any) => !s.applied,
onApplySuccess={(key) => ) && (
removeSelectedField(key) <Button
} variant="ghost"
/> className="text-primary hover:bg-primary/10 h-6 px-2 text-[10px]"
onClick={() =>
toggleAllFromMessage(msg.suggestions)
}
>
{msg.suggestions
.filter((s: any) => !s.applied)
.every((s: any) =>
selectedImprovements.includes(s.key),
)
? 'Desmarcar todo'
: 'Seleccionar todo'}
</Button>
)}
</div>
{msg.suggestions.map((sug: any) => (
<div key={sug.key} className="flex gap-2">
{!sug.applied && (
<input
type="checkbox"
className="border-input accent-primary mt-4 h-4 w-4 shrink-0 rounded"
checked={selectedImprovements.includes(
sug.key,
)}
onChange={() =>
toggleImprovementSelection(sug.key)
}
/>
)}
<div className="flex-1">
<ImprovementCard
suggestions={[sug]}
dbMessageId={msg.dbMessageId}
planId={planId}
currentDatos={data?.datos}
activeChatId={activeChatId}
onApplySuccess={(key) =>
removeSelectedField(key)
}
/>
</div>
</div>
))}
{msg.suggestions.some((s: any) =>
selectedImprovements.includes(s.key),
) && (
<Button
size="sm"
disabled={isSending}
className="w-full py-1 text-xs font-bold"
onClick={() => {
const seleccionadas =
msg.suggestions.filter((s: any) =>
selectedImprovements.includes(s.key),
)
handleApplyMultiple(
seleccionadas,
msg.dbMessageId,
)
}}
>
{isSending ? (
<Loader2
className="mr-2 animate-spin"
size={12}
/>
) : (
<Check className="mr-2" size={12} />
)}
Aplicar seleccionadas (
{
msg.suggestions.filter((s: any) =>
selectedImprovements.includes(s.key),
).length
}
)
</Button>
)}
</div> </div>
)} )}
</div> </div>
@@ -755,20 +902,20 @@ function RouteComponent() {
{(isSending || isSyncing) && ( {(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4"> <div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm"> <Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm">
<AvatarFallback> <AvatarFallback>
<Sparkles size={16} className="animate-pulse" /> <Sparkles size={16} className="animate-pulse" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col items-start gap-2"> <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="bg-card border-border rounded-2xl rounded-tl-none border 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-slate-400 [animation-delay:-0.3s]"></span> <span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span> <span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span> <span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span>
</div> </div>
</div> </div>
<span className="text-[10px] font-medium text-slate-400 italic"> <span className="text-muted-foreground text-[10px] font-medium italic">
La IA está analizando tu solicitud... La IA está analizando tu solicitud...
</span> </span>
</div> </div>
@@ -779,9 +926,8 @@ function RouteComponent() {
</div> </div>
</ScrollArea> </ScrollArea>
{/* Botones flotantes de aplicación */}
{pendingSuggestion && !isLoading && ( {pendingSuggestion && !isLoading && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl"> <div className="animate-in fade-in slide-in-from-bottom-2 bg-card border-border absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border p-1.5 shadow-2xl">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -790,23 +936,19 @@ function RouteComponent() {
> >
<X className="mr-1 h-3 w-3" /> Descartar <X className="mr-1 h-3 w-3" /> Descartar
</Button> </Button>
<Button <Button size="sm" className="h-8 rounded-full text-xs">
size="sm"
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
>
<Check className="mr-1 h-3 w-3" /> Aplicar cambios <Check className="mr-1 h-3 w-3" /> Aplicar cambios
</Button> </Button>
</div> </div>
)} )}
</div> </div>
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */} {/* INPUT FIJO AL FONDO */}
<div className="shrink-0 border-t bg-white p-4"> <div className="bg-background border-border shrink-0 border-t p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl"> <div className="animate-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full mb-2 w-full rounded-xl border shadow-2xl">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase"> <div className="bg-muted text-muted-foreground border-b px-3 py-2 text-[10px] font-bold uppercase">
Resultados para "{filterQuery}" Resultados para "{filterQuery}"
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
@@ -817,8 +959,8 @@ function RouteComponent() {
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${ className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
index === 0 index === 0
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset' ? 'bg-primary/10 text-primary ring-primary/30 ring-1 ring-inset'
: 'hover:bg-slate-50' : 'hover:bg-accent'
}`} }`}
> >
<span>{field.label}</span> <span>{field.label}</span>
@@ -830,7 +972,7 @@ function RouteComponent() {
</button> </button>
)) ))
) : ( ) : (
<div className="p-3 text-center text-xs text-slate-400"> <div className="text-muted-foreground p-3 text-center text-xs">
No hay coincidencias No hay coincidencias
</div> </div>
)} )}
@@ -838,20 +980,18 @@ function RouteComponent() {
</div> </div>
)} )}
{/* CONTENEDOR DEL INPUT TRANSFORMADO */} <div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1">
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{/* 1. Visualización de campos dentro del input ) */}
{selectedFields.length > 0 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1"> <div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
<div <div
key={field.key} key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800" className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-semibold"
> >
<span className="opacity-70">Campo:</span> {field.label} <span className="opacity-70">Campo:</span> {field.label}
<button <button
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200" className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors"
> >
<X size={10} /> <X size={10} />
</button> </button>
@@ -860,7 +1000,6 @@ function RouteComponent() {
</div> </div>
)} )}
{/* 2. Área de escritura */}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<Textarea <Textarea
value={input} value={input}
@@ -879,7 +1018,6 @@ function RouteComponent() {
setFilterQuery('') setFilterQuery('')
} }
} else { } else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if ( if (
e.key === 'Backspace' && e.key === 'Backspace' &&
input === '' && input === '' &&
@@ -907,7 +1045,7 @@ function RouteComponent() {
isSending || (!input.trim() && selectedFields.length === 0) isSending || (!input.trim() && selectedFields.length === 0)
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700" className="mb-1 h-9 w-9 shrink-0"
> >
{isSending ? ( {isSending ? (
<Loader2 className="animate-spin" size={16} /> <Loader2 className="animate-spin" size={16} />
@@ -920,44 +1058,100 @@ function RouteComponent() {
</div> </div>
</div> </div>
</div> </div>
{/* PANEL LATERAL */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2"> {/* --- PANEL LATERAL: ACCIONES RÁPIDAS --- */}
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800"> <div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas <h4 className="text-foreground flex items-center gap-2 text-left text-sm font-bold">
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2 p-1">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
onClick={() => handleSend(preset.prompt)} onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50" className="bg-card hover:border-primary hover:bg-primary/5 group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm shadow-sm transition-all"
> >
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600"> <div className="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors">
<preset.icon size={16} /> <preset.icon size={16} />
</div> </div>
<span className="leading-tight font-medium text-slate-700"> <span className="text-foreground leading-tight font-medium">
{preset.label} {preset.label}
</span> </span>
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DrawerContent className="h-[80vh] p-4">
<Button
onClick={() => {
createNewChat()
setIsHistoryOpen(false)
}}
className="mb-4 w-full"
>
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
</Button>
<ScrollArea className="flex-1">
<p className="text-muted-foreground mb-4 text-xs font-bold uppercase">
Historial Reciente
</p>
{activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsHistoryOpen(false)
}}
className="border-border border-b p-3 text-sm"
>
{chat.nombre || 'Chat sin nombre'}
</div>
))}
</ScrollArea>
</DrawerContent>
</Drawer>
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DrawerContent className="h-[60vh] p-4">
<h4 className="mb-4 flex items-center gap-2 font-bold">
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => {
handleSend(preset.prompt)
setIsActionsOpen(false)
}}
className="border-border flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
>
<preset.icon size={16} />
<span>{preset.label}</span>
</button>
))}
</div>
</DrawerContent>
</Drawer>
<Drawer open={openIA} onOpenChange={setOpenIA}> <Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl"> <DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl">
{/* Cabecera más compacta */} <div className="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3"> <h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Referencias para la IA Referencias para la IA
</h2> </h2>
<button <button
onClick={() => setOpenIA(false)} onClick={() => setOpenIA(false)}
className="text-slate-400 transition-colors hover:text-slate-600" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* Contenido con scroll interno */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA <ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds} selectedArchivoIds={selectedArchivoIds}
+21 -40
View File
@@ -3,7 +3,6 @@ import {
useNavigate, useNavigate,
useLocation, useLocation,
} from '@tanstack/react-router' } from '@tanstack/react-router'
// import confetti from 'canvas-confetti'
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react' import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
@@ -20,7 +19,6 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlan, useUpdatePlanFields } from '@/data' import { usePlan, useUpdatePlanFields } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/')({ export const Route = createFileRoute('/planes/$planId/_detalle/')({
component: DatosGeneralesPage, component: DatosGeneralesPage,
}) })
@@ -34,21 +32,20 @@ function DatosGeneralesPage() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId) const { data, isLoading } = usePlan(planId)
const navigate = useNavigate() const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([]) const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const location = useLocation() const location = useLocation()
const updatePlan = useUpdatePlanFields() const updatePlan = useUpdatePlanFields()
// Confetti al llegar desde creación
useEffect(() => { useEffect(() => {
if (location.state.showConfetti) { if (location.state.showConfetti) {
lateralConfetti() lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita window.history.replaceState({}, document.title)
} }
}, [location.state]) }, [location.state])
// Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => { useEffect(() => {
const definicion = data?.estructuras_plan?.definicion as any const definicion = data?.estructuras_plan?.definicion as any
const properties = definicion?.properties const properties = definicion?.properties
@@ -59,18 +56,13 @@ function DatosGeneralesPage() {
if (properties && typeof properties === 'object') { if (properties && typeof properties === 'object') {
let keys = Object.keys(properties) let keys = Object.keys(properties)
// Ordenar llaves basado en la lista "required" si existe
if (Array.isArray(requiredOrder)) { if (Array.isArray(requiredOrder)) {
keys = keys.sort((a, b) => { keys = keys.sort((a, b) => {
const indexA = requiredOrder.indexOf(a) const indexA = requiredOrder.indexOf(a)
const indexB = requiredOrder.indexOf(b) const indexB = requiredOrder.indexOf(b)
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
if (indexA !== -1 && indexB === -1) return -1 if (indexA !== -1 && indexB === -1) return -1
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
if (indexA === -1 && indexB !== -1) return 1 if (indexA === -1 && indexB !== -1) return 1
// Si ambos están, comparar índices
if (indexA !== -1 && indexB !== -1) return indexA - indexB if (indexA !== -1 && indexB !== -1) return indexA - indexB
// Ninguno en la lista, mantener orden relativo
return 0 return 0
}) })
} }
@@ -108,18 +100,14 @@ function DatosGeneralesPage() {
} }
}, [data]) }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (nuevoCampo: DatosGeneralesField) => { const handleEdit = (nuevoCampo: DatosGeneralesField) => {
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
if (editingId && editingId !== nuevoCampo.id) { if (editingId && editingId !== nuevoCampo.id) {
const campoAnterior = campos.find((c) => c.id === editingId) const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) { if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue) ejecutarGuardadoSilencioso(campoAnterior, editValue)
} }
} }
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id) setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value) setEditValue(nuevoCampo.value)
} }
@@ -128,7 +116,7 @@ function DatosGeneralesPage() {
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = ( const prepararDatosActualizados = (
data: any, data: any,
campo: DatosGeneralesField, campo: DatosGeneralesField,
@@ -167,7 +155,6 @@ function DatosGeneralesPage() {
patch: { datos: datosActualizados }, patch: { datos: datosActualizados },
}) })
// Actualizar UI localmente
setCampos((prev) => setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
) )
@@ -185,13 +172,11 @@ function DatosGeneralesPage() {
currentValue !== null && currentValue !== null &&
'description' in currentValue 'description' in currentValue
) { ) {
// Caso 1: objeto con description
newValue = { newValue = {
...currentValue, ...currentValue,
description: editValue, description: editValue,
} }
} else { } else {
// Caso 2: valor plano (string, number, etc)
newValue = editValue newValue = editValue
} }
@@ -207,7 +192,6 @@ function DatosGeneralesPage() {
}, },
}) })
// UI optimista
setCampos((prev) => setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
) )
@@ -217,19 +201,20 @@ function DatosGeneralesPage() {
setEditValue('') setEditValue('')
} }
const handleIARequest = (clave: string) => { const handleIARequest = (campo: DatosGeneralesField) => {
navigate({ navigate({
to: '/planes/$planId/iaplan', to: '/planes/$planId/iaplan',
params: { params: {
planId: planId, // o dinámico planId: planId,
}, },
state: { state: {
campo_edit: clave, campo_edit: campo.clave,
} as any, } as any,
}) })
} }
return ( return (
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500"> <div className="animate-in fade-in duration-500">
<div className="mb-6"> <div className="mb-6">
<h2 className="text-foreground text-lg font-semibold"> <h2 className="text-foreground text-lg font-semibold">
Datos Generales del Plan Datos Generales del Plan
@@ -246,19 +231,19 @@ function DatosGeneralesPage() {
return ( return (
<div <div
key={campo.id} key={campo.id}
className={`rounded-xl border transition-all ${ className={`bg-card rounded-xl border transition-all ${
isEditing isEditing
? 'border-teal-500 shadow-lg ring-2 ring-teal-50' ? 'border-primary ring-primary/20 shadow-lg ring-2'
: 'bg-white hover:shadow-md' : 'hover:shadow-md'
}`} }`}
> >
{/* Header de la Card */} {/* Header de la Card */}
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3"> <div className="bg-muted/30 flex items-center justify-between border-b px-5 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<h3 className="cursor-help text-sm font-medium text-slate-700"> <h3 className="text-foreground cursor-help text-sm font-medium">
{campo.label} {campo.label}
</h3> </h3>
</TooltipTrigger> </TooltipTrigger>
@@ -268,7 +253,7 @@ function DatosGeneralesPage() {
</Tooltip> </Tooltip>
{campo.requerido && ( {campo.requerido && (
<span className="text-xs text-red-500">*</span> <span className="text-destructive text-xs">*</span>
)} )}
</div> </div>
@@ -279,7 +264,7 @@ function DatosGeneralesPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-teal-600" className="text-primary hover:text-primary/90 h-8 w-8"
onClick={() => handleIARequest(campo)} onClick={() => handleIARequest(campo)}
> >
<Sparkles size={14} /> <Sparkles size={14} />
@@ -293,7 +278,7 @@ function DatosGeneralesPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="text-muted-foreground hover:text-foreground h-8 w-8"
onClick={() => handleEdit(campo)} onClick={() => handleEdit(campo)}
> >
<Pencil size={14} /> <Pencil size={14} />
@@ -324,11 +309,7 @@ function DatosGeneralesPage() {
> >
<X size={14} className="mr-1" /> Cancelar <X size={14} className="mr-1" /> Cancelar
</Button> </Button>
<Button <Button size="sm" onClick={() => handleSave(campo)}>
size="sm"
className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo)}
>
<Check size={14} className="mr-1" /> Guardar <Check size={14} className="mr-1" /> Guardar
</Button> </Button>
</div> </div>
@@ -336,12 +317,12 @@ function DatosGeneralesPage() {
) : ( ) : (
<div className="min-h-25"> <div className="min-h-25">
{campo.value ? ( {campo.value ? (
<div className="text-sm leading-relaxed text-slate-600"> <div className="text-muted-foreground text-sm leading-relaxed">
{campo.tipo === 'lista' ? ( {campo.tipo === 'lista' ? (
<ul className="space-y-1"> <ul className="space-y-1">
{campo.value.split('\n').map((item, i) => ( {campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2"> <li key={i} className="flex gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" /> <span className="bg-primary mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" />
{item} {item}
</li> </li>
))} ))}
@@ -351,7 +332,7 @@ function DatosGeneralesPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 text-sm text-slate-400"> <div className="text-muted-foreground/70 flex items-center gap-2 text-sm">
<AlertCircle size={14} /> <AlertCircle size={14} />
<span>Sin contenido.</span> <span>Sin contenido.</span>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -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)
}
@@ -6,12 +6,22 @@ import {
useParams, useParams,
useRouterState, useRouterState,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { ArrowLeft, GraduationCap } from 'lucide-react' import {
ArrowLeft,
GraduationCap,
Pencil,
Hash,
BookOpen,
CalendarDays,
Tag,
} from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti' import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data' import { useSubject, useUpdateAsignatura, usePlanAsignaturas } from '@/data'
import { cn } from '@/lib/utils'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId', '/planes/$planId/asignaturas/$asignaturaId',
@@ -19,65 +29,186 @@ export const Route = createFileRoute(
component: AsignaturaLayout, component: AsignaturaLayout,
}) })
function EditableHeaderField({ // --- 1. COMPONENTE PARA EDITAR EL TÍTULO SOBRE FONDO AZUL ---
function InlineEditTitle({
value, value,
onSave, onSave,
className,
}: { }: {
value: string | number value: string
onSave: (val: string) => void onSave: (val: string) => void
className?: string
}) { }) {
const textValue = String(value) const [isEditing, setIsEditing] = useState(false)
const [tempVal, setTempVal] = useState(value)
// Manejador para cuando el usuario termina de editar (pierde el foco) useEffect(() => setTempVal(value), [value])
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText const handleSave = () => {
if (newValue !== textValue) { setIsEditing(false)
onSave(newValue) if (tempVal.trim() && tempVal !== value) onSave(tempVal.trim())
} else setTempVal(value) // Revertir si está vacío
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { if (isEditing) {
if (e.key === 'Enter') { return (
e.preventDefault() <input
e.currentTarget.blur() // Forzamos el guardado al presionar Enter autoFocus
} value={tempVal}
onChange={(e) => setTempVal(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setTempVal(value)
setIsEditing(false)
}
}}
// Input estilizado para fondo oscuro: borde blanco sutil, texto blanco
className="focus:ring-primary/40 w-full rounded-md border-2 border-white/20 bg-transparent px-2 py-1 text-3xl font-bold text-white shadow-sm outline-none focus:ring-4"
/>
)
} }
return ( return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions <h1
<span onClick={() => setIsEditing(true)}
contentEditable // Texto blanco por defecto, fondo blanco sutil al hover
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable className="group flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 text-3xl font-bold text-white transition-colors hover:bg-white/5"
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
> >
{textValue} {value}
</span> {/* Lápiz blanco sutil */}
<Pencil className="h-5 w-5 text-white/50 opacity-0 transition-all group-hover:opacity-100 hover:text-white" />
</h1>
) )
} }
// --- 2. COMPONENTE PARA EDITAR LOS BADGES SOBRE FONDO AZUL ---
function InlineEditBadge({
icon,
label,
value,
suffix = '',
type = 'text',
onSave,
}: {
icon: React.ReactNode
label: string
value: string | number
suffix?: string
type?: 'text' | 'number'
onSave: (val: string) => void
}) {
const [isEditing, setIsEditing] = useState(false)
const [tempVal, setTempVal] = useState(value)
useEffect(() => setTempVal(value), [value])
const handleSave = () => {
setIsEditing(false)
if (String(tempVal).trim() !== String(value)) {
onSave(String(tempVal))
}
}
if (isEditing) {
return (
// Contenedor del input con estética de badge oscuro
<div className="focus:ring-primary/40 flex h-8 items-center gap-2 rounded-md border border-white/20 bg-white/5 px-3 shadow-sm ring-1 focus-within:ring-2">
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
{label}:
</span>
<input
autoFocus
type={type}
value={tempVal}
onChange={(e) => setTempVal(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setTempVal(value)
setIsEditing(false)
}
}}
// Texto blanco dentro del input
className="w-16 bg-transparent text-sm font-semibold text-white outline-none"
/>
</div>
)
}
return (
<button
onClick={() => setIsEditing(true)}
// Badge oscuro: borde blanco sutil, texto blanco, fondo más claro al hover
className="group flex h-8 items-center gap-2 rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white transition-all hover:border-white/20 hover:bg-white/10"
>
{/* Ícono blanco sutil */}
<span className="text-white/70">{icon}</span>
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
{label}:
</span>
<span className="font-semibold text-white">
{value} {suffix}
</span>
{/* Lápiz blanco sutil */}
<Pencil className="h-3 w-3 text-white/50 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)
}
interface DatosPlan { interface DatosPlan {
nombre?: string nombre?: string
} }
function AsignaturaLayout() { function AsignaturaLayout() {
const location = useLocation() const location = useLocation()
const { asignaturaId } = useParams({ const { asignaturaId, planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId) useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal const { data: todasLasAsignaturas } = usePlanAsignaturas(planId)
const [confirmState, setConfirmState] = useState<{
isOpen: boolean
resolve: (value: boolean) => void
mensaje: string
} | null>(null)
const validarConInterrupcion = async (
nuevoCiclo: number,
): Promise<boolean> => {
if (!todasLasAsignaturas || !asignaturaApi) return true
const materiasConflicto = todasLasAsignaturas.filter((a) => {
const esPrerrequisitoConflictivo =
asignaturaApi.prerrequisito_asignatura_id === a.id &&
(a.numero_ciclo ?? 0) >= nuevoCiclo
const esDependienteConflictiva =
a.prerrequisito_asignatura_id === asignaturaApi.id &&
(a.numero_ciclo ?? 0) <= nuevoCiclo
return esPrerrequisitoConflictivo || esDependienteConflictiva
})
if (materiasConflicto.length === 0) return true
const listaNombres = materiasConflicto.map((m) => m.nombre)
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
resolve,
mensaje: JSON.stringify({
main: `Mover "${asignaturaApi.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`,
materias: listaNombres,
}),
})
})
}
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({ const [headerData, setHeaderData] = useState({
codigo: '', codigo: '',
nombre: '', nombre: '',
@@ -85,7 +216,6 @@ function AsignaturaLayout() {
ciclo: 0, ciclo: 0,
}) })
// Sincronizar cuando llegue la API
useEffect(() => { useEffect(() => {
if (asignaturaApi) { if (asignaturaApi) {
setHeaderData({ setHeaderData({
@@ -97,28 +227,38 @@ function AsignaturaLayout() {
} }
}, [asignaturaApi]) }, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => { const handleUpdateHeader = async (key: string, value: string | number) => {
// 1. Si es ciclo, validamos antes de hacer nada
if (key === 'ciclo') {
const nuevoCiclo = Number(value)
const acepto = await validarConInterrupcion(nuevoCiclo)
setConfirmState(null) // Cerramos el modal tras la respuesta
if (!acepto) {
// Revertimos el estado local al valor de la API si cancela
setHeaderData((prev) => ({
...prev,
ciclo: asignaturaApi?.numero_ciclo ?? 0,
}))
return
}
}
// 2. Si no es ciclo o si aceptó el conflicto, procedemos
const newData = { ...headerData, [key]: value } const newData = { ...headerData, [key]: value }
setHeaderData(newData) setHeaderData(newData)
const patch: Record<string, any> = const patch: Record<string, any> =
key === 'ciclo' key === 'ciclo' ? { numero_ciclo: value } : { [key]: value }
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({ updateAsignatura.mutate({ asignaturaId, patch })
asignaturaId,
patch,
})
} }
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}) })
// Confetti al llegar desde creación IA
useEffect(() => { useEffect(() => {
if ((location.state as any)?.showConfetti) { if ((location.state as any)?.showConfetti) {
lateralConfetti() lateralConfetti()
@@ -128,106 +268,120 @@ function AsignaturaLayout() {
if (loadingAsig) { if (loadingAsig) {
return ( return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white"> <div className="bg-background text-foreground flex h-screen items-center justify-center">
Cargando asignatura... Cargando asignatura...
</div> </div>
) )
} }
// Si no hay datos y no está cargando, algo falló
if (!asignaturaApi) return null if (!asignaturaApi) return null
return ( return (
<div> <div className="bg-background min-h-screen">
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> {/* HEADER DE LA ASIGNATURA CON TU FONDO AZUL HARDCODEADO */}
<div className="mx-auto max-w-7xl px-6 py-10"> <section className="border-border border-b bg-[#0b1d3a] pt-6 pb-8">
<div className="mx-auto px-4 md:px-6 lg:px-8">
<Link <Link
to="/planes/$planId/asignaturas" to="/planes/$planId/asignaturas"
params={{ planId }} params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white" // Enlace blanco sutil
className="mb-4 flex w-fit items-center gap-2 text-sm text-white/70 transition-colors hover:text-white"
> >
<ArrowLeft className="h-4 w-4" /> Volver al plan <ArrowLeft className="h-4 w-4" /> Volver al plan
</Link> </Link>
<div className="flex items-start justify-between gap-6"> <div className="flex flex-col gap-4">
<div className="space-y-3"> {/* Título Editable (Texto blanco controlado dentro del componente) */}
{/* CÓDIGO EDITABLE */} <div className="-ml-2">
<Badge className="border border-blue-700 bg-blue-900/50"> <InlineEditTitle
<EditableHeaderField value={headerData.nombre}
value={headerData.codigo} onSave={(val) => handleUpdateHeader('nombre', val)}
onSave={(val) => handleUpdateHeader('codigo', val)} />
/>
</Badge>
{/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre ?? ''}
</span>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div> </div>
<div className="flex flex-col items-end gap-2 text-right"> {/* Fila de Metadatos Alineados */}
{/* CRÉDITOS EDITABLES */} <div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="gap-1"> {/* Badge Estático del Tipo (Estilo oscuro sutil) */}
<span className="inline-flex max-w-fit"> <Badge
<EditableHeaderField variant="outline"
value={headerData.creditos} className="flex h-8 cursor-default items-center gap-1.5 border-white/10 bg-white/5 px-3 text-white"
onSave={(val) => >
handleUpdateHeader('creditos', parseInt(val) || 0) <Tag size={12} className="text-white/70" />
} {asignaturaApi.tipo}
/>
</span>
<span>créditos</span>
</Badge> </Badge>
{/* SEMESTRE EDITABLE */} {/* Badges Editables (Texto blanco controlado dentro de los componentes) */}
<Badge variant="secondary" className="gap-1"> <InlineEditBadge
<EditableHeaderField icon={<Hash size={14} />}
value={headerData.ciclo} label="Código"
onSave={(val) => value={headerData.codigo}
handleUpdateHeader('ciclo', parseInt(val) || 0) onSave={(val) => handleUpdateHeader('codigo', val)}
} />
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturaApi.tipo}</Badge> <InlineEditBadge
icon={<BookOpen size={14} />}
label="Créditos"
type="number"
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
<InlineEditBadge
icon={<CalendarDays size={14} />}
label="Semestre"
type="number"
value={headerData.ciclo}
suffix="°"
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
</div>
{/* Subtítulo de contexto (Texto blanco sutil) */}
<div className="mt-2 flex items-center gap-2 text-sm text-white/70">
<GraduationCap className="h-4 w-4 shrink-0 text-white/60" />
<span>Pertenece al plan:</span>
<span className="font-medium text-white">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* TABS NAVEGACIÓN (Se mantiene semántico para el cuerpo de la página)
<nav className="border-border bg-background/80 sticky top-0 z-20 border-b backdrop-blur-md">
<div className="mx-auto px-4 py-1 md:px-6 lg:px-8">
<div className="scrollbar-hide flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
*/}
{confirmState && (
<AlertaConflicto
isOpen={confirmState.isOpen}
onOpenChange={(open) => {
if (!open) {
confirmState.resolve(false)
setConfirmState(null)
}
}}
onConfirm={() => confirmState.resolve(true)}
titulo="Conflicto de Seriación"
descripcion={confirmState.mensaje}
/>
)}
{/* TABS */} {/* TABS */}
<nav className="border-b bg-white"> <nav className="sticky top-0 z-20 border-b bg-white">
<div className="mx-auto max-w-7xl px-6"> <div className="mx-auto p-4 py-2 md:px-6 lg:px-8">
<div className="flex justify-center gap-8"> {/* CAMBIOS CLAVE:
1. overflow-x-auto: Permite scroll horizontal.
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
*/}
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
{[ {[
{ label: 'Datos', to: '' }, { label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' }, { label: 'Contenido', to: 'contenido' },
@@ -251,11 +405,12 @@ function AsignaturaLayout() {
} }
from="/planes/$planId/asignaturas/$asignaturaId" from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }} params={{ planId, asignaturaId }}
className={`border-b-2 py-3 text-sm font-medium ${ className={cn(
'shrink-0 border-b-2 py-3 text-sm font-medium transition-colors',
isActive isActive
? 'border-blue-600 text-blue-600' ? 'border-primary text-primary font-bold'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700' : 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
}`} )}
> >
{tab.label} {tab.label}
</Link> </Link>
@@ -265,7 +420,7 @@ function AsignaturaLayout() {
</div> </div>
</nav> </nav>
<div className="mx-auto max-w-7xl px-6 py-8"> <div className="mx-auto p-4 py-8 md:px-6 lg:px-8">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+22 -11
View File
@@ -8,6 +8,7 @@ import BarraBusqueda from '@/components/planes/BarraBusqueda'
import Filtro from '@/components/planes/Filtro' import Filtro from '@/components/planes/Filtro'
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard' import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
// Hooks y Utils (ajusta las rutas de importación) // Hooks y Utils (ajusta las rutas de importación)
import { Button } from '@/components/ui/button'
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans' import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
import { getIconByName } from '@/features/planes/utils/icon-utils' import { getIconByName } from '@/features/planes/utils/icon-utils'
@@ -101,13 +102,20 @@ function RouteComponent() {
setPage(0) // Resetear página al buscar setPage(0) // Resetear página al buscar
} }
// Deshabilitar el botón 'Limpiar' si no hay filtros distintos al valor por defecto
const isClearDisabled =
cleanSearchTerm === '' &&
facultadSel === 'todas' &&
carreraSel === 'todas' &&
estadoSel === 'todos'
// Renderizado condicional básico // Renderizado condicional básico
if (isError) if (isError)
return <div className="p-8 text-red-500">Error cargando planes.</div> return <div className="p-8 text-red-500">Error cargando planes.</div>
return ( return (
<main className="bg-background min-h-screen w-full"> <main className="bg-background min-h-screen w-full">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8"> <div className="mx-auto flex w-full flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:col-span-3"> <div className="flex flex-col gap-4 lg:col-span-3">
{/* Header y Botón Nuevo */} {/* Header y Botón Nuevo */}
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
@@ -124,16 +132,16 @@ function RouteComponent() {
</p> </p>
</div> </div>
</div> </div>
<button <Button
onClick={() => { onClick={() => {
console.log('planId') console.log('planId')
navigate({ to: '/planes/nuevo', resetScroll: false }) navigate({ to: '/planes/nuevo', resetScroll: false })
}} }}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors" className="shadow-md"
> >
<Icons.Plus /> Nuevo plan de estudios <Icons.Plus /> Nuevo plan de estudios
</button> </Button>
</div> </div>
{/* Barra de Filtros */} {/* Barra de Filtros */}
@@ -181,13 +189,15 @@ function RouteComponent() {
placeholder="Estado" placeholder="Estado"
/> />
</div> </div>
<button <Button
type="button" type="button"
variant="secondary"
onClick={resetFilters} onClick={resetFilters}
className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors" disabled={isClearDisabled}
className={`shadow-md`}
> >
<Icons.X className="h-4 w-4" /> Limpiar <Icons.X className="h-4 w-4" /> Limpiar
</button> </Button>
</div> </div>
</div> </div>
@@ -212,9 +222,9 @@ function RouteComponent() {
// NOTA: El color del estado no viene en BD por defecto, // NOTA: El color del estado no viene en BD por defecto,
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan' // puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
// Aquí uso un fallback simple. // Aquí uso un fallback simple.
const estadoColor = estado?.es_final const estadoColorHex = (estado as any)?.color as
? 'bg-emerald-600' | string
: 'bg-amber-600' | undefined
return ( return (
<PlanEstudiosCard <PlanEstudiosCard
@@ -225,7 +235,8 @@ function RouteComponent() {
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`} ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
facultad={facultad?.nombre ?? 'Sin Facultad'} facultad={facultad?.nombre ?? 'Sin Facultad'}
estado={estado?.etiqueta ?? 'Desconocido'} estado={estado?.etiqueta ?? 'Desconocido'}
claseColorEstado={estadoColor} colorEstadoHex={estadoColorHex}
claseColorEstado={!estadoColorHex ? 'bg-secondary' : ''}
colorFacultad={facultad?.color ?? '#000000'} colorFacultad={facultad?.color ?? '#000000'}
onClick={() => onClick={() =>
navigate({ navigate({
+162 -12
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;
@@ -208,3 +335,26 @@ body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* =====================================================================
Utilidades locales (sin romper el design system)
Intención:
- Evitar estilos inline en JSX para assets puntuales del proyecto
===================================================================== */
.login-bg {
background-image: url('/fondo_login.png');
}
@layer base {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role='button']:disabled {
cursor: not-allowed !important;
}
}
+2 -7
View File
@@ -31,12 +31,7 @@ export interface Carrera {
facultadId: string facultadId: string
} }
export interface LineaCurricular { export type LineaCurricular = Tables<'lineas_plan'>
id: string
nombre: string
orden: number
color?: string
}
export interface Asignatura { export interface Asignatura {
id: string id: string
@@ -50,7 +45,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 {
+53
View File
@@ -154,6 +154,8 @@ 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
search_vector: unknown
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 +181,8 @@ 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
search_vector?: unknown
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 +208,8 @@ 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
search_vector?: unknown
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 +263,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: {
@@ -813,6 +833,7 @@ export type Database = {
Row: { Row: {
actualizado_en: string actualizado_en: string
area: string | null area: string | null
color: string | null
creado_en: string creado_en: string
id: string id: string
nombre: string nombre: string
@@ -822,6 +843,7 @@ export type Database = {
Insert: { Insert: {
actualizado_en?: string actualizado_en?: string
area?: string | null area?: string | null
color?: string | null
creado_en?: string creado_en?: string
id?: string id?: string
nombre: string nombre: string
@@ -831,6 +853,7 @@ export type Database = {
Update: { Update: {
actualizado_en?: string actualizado_en?: string
area?: string | null area?: string | null
color?: string | null
creado_en?: string creado_en?: string
id?: string id?: string
nombre?: string nombre?: string
@@ -1376,6 +1399,36 @@ export type Database = {
Args: { p_append: Json; p_id: string } Args: { p_append: Json; p_id: string }
Returns: undefined Returns: undefined
} }
build_asignaturas_prefix_tsquery: {
Args: { p_search: string }
Returns: unknown
}
recalcular_vectores_asignaturas: { Args: never; Returns: undefined }
search_asignaturas: {
Args: {
p_carrera_id?: string
p_facultad_id?: string
p_limit?: number
p_offset?: number
p_plan_estudio_id?: string
p_search?: string
}
Returns: Array<{
codigo: string
contenido_tematico: Json
creditos: number
datos: Json
estado: Database['public']['Enums']['estado_asignatura']
id: string
nombre: string
numero_ciclo: number
plan_estudio_id: string
rank: number
tipo: Database['public']['Enums']['tipo_asignatura']
total_count: number
}>
}
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 }
} }
+36
View File
@@ -0,0 +1,36 @@
// Convierte HSL a Hexadecimal
function hslToHex(h: number, s: number, l: number): string {
l /= 100
const a = (s * Math.min(l, 1 - l)) / 100
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color)
.toString(16)
.padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`
}
/**
* Genera un color contrastante.
* @param prevHue El tono (0-360) del color anterior. Null si es el primero.
* @returns Objeto con el hex y el nuevo hue para guardar como referencia.
*/
export function generarColorContrastante(prevHue: number | null = null) {
let newHue: number
if (prevHue === null) {
// Primer color: completamente al azar
newHue = Math.floor(Math.random() * 360)
} else {
// Siguientes: Salto aleatorio entre 130° y 230° para forzar contraste
const salto = 130 + Math.floor(Math.random() * 100)
newHue = (prevHue + salto) % 360
}
// Mantenemos saturación al 70% y luminosidad al 50% para colores vivos pero no fosforescentes
const hex = hslToHex(newHue, 70, 50)
return { hex, hue: newHue }
}
+14
View File
@@ -0,0 +1,14 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}