Compare commits
50 Commits
614ef3ffaf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c730fa0ab | |||
| 2abe296b9e | |||
| 1bce226d15 | |||
| b986ec343e | |||
| 379e2d3826 | |||
| cb5422f57c | |||
|
|
67724181fd | ||
| d9a5cec3c5 | |||
| 96848e1793 | |||
| cbaf96c6b5 | |||
| 0fb831fb58 | |||
| 0d1aa61022 | |||
| 84281a88f2 | |||
| d91018c612 | |||
| 658b2e245c | |||
| 30562fead0 | |||
| 2b91004129 | |||
| 96a045dc67 | |||
| a8229f12d5 | |||
| dd4ac5374a | |||
| 670e0b1d14 | |||
| 93fe247a19 | |||
| 32ebfde9ed | |||
| 32f0c4c4d4 | |||
| 6a520ef6b1 | |||
| 25d451839e | |||
| fe8f1d4753 | |||
| 518b1124d8 | |||
| 8bdaf935ca | |||
| 0d636cbf3b | |||
| 82d047e1c2 | |||
| 674c8a6bee | |||
| 3acea813b6 | |||
| e68954e03c | |||
| 296fbfee79 | |||
| a55910c226 | |||
| 88c6dc6b4d | |||
| 03caa791ad | |||
| 577daaff03 | |||
| f75680e8dd | |||
| 0b7f45c150 | |||
| 56ac8c0155 | |||
| 8ecb0f205a | |||
| ea842ee46c | |||
| 11369ce792 | |||
| 78471c19d9 | |||
| 3e8b8cd011 | |||
| 9eb7aae7d0 | |||
| e5afaa0c7c | |||
| 06bae3ba3e |
37
.gitea/workflows/deploy.yaml
Normal file
37
.gitea/workflows/deploy.yaml
Normal 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"
|
||||||
15
bun.lock
15
bun.lock
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "acad-ia-2",
|
"name": "acad-ia-2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/react": "^0.3.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -138,6 +139,18 @@
|
|||||||
|
|
||||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
|
||||||
|
|
||||||
|
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
|
||||||
|
|
||||||
|
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
|
||||||
|
|
||||||
|
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
|
||||||
|
|
||||||
|
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
@@ -250,6 +263,8 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
|
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
|
||||||
|
|
||||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/react": "^0.3.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|||||||
BIN
public/fonts/indivisa/IndivisaTextSans-Black.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Black.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-BlackItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-BlackItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-Bold.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-BoldItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-BoldItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-Light.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Light.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-LightItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-LightItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-Regular.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-RegularItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-RegularItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Black.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Black.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Bold.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Light.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Light.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-LightItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-LightItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Regular.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf
Normal file
Binary file not shown.
@@ -15,6 +15,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||||
|
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
||||||
|
|
||||||
export interface BibliografiaEntry {
|
export interface BibliografiaEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,6 +39,10 @@ export interface AsignaturaResponse {
|
|||||||
datos: AsignaturaDatos
|
datos: AsignaturaDatos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
type CriterioEvaluacionRow = {
|
type CriterioEvaluacionRow = {
|
||||||
criterio: string
|
criterio: string
|
||||||
porcentaje: number
|
porcentaje: number
|
||||||
@@ -791,80 +796,3 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseContenidoTematicoToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const blocks: Array<string> = []
|
|
||||||
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
|
|
||||||
const unidad =
|
|
||||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
|
||||||
? item.unidad
|
|
||||||
: undefined
|
|
||||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
|
||||||
|
|
||||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
|
||||||
if (!header) continue
|
|
||||||
|
|
||||||
const lines: Array<string> = [header]
|
|
||||||
|
|
||||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
|
||||||
temas.forEach((tema, idx) => {
|
|
||||||
const temaNombre =
|
|
||||||
typeof tema === 'string'
|
|
||||||
? tema
|
|
||||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
|
||||||
? tema.nombre
|
|
||||||
: ''
|
|
||||||
if (!temaNombre) return
|
|
||||||
|
|
||||||
if (unidad != null) {
|
|
||||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
|
||||||
} else {
|
|
||||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks.push(lines.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.join('\n\n').trimEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const lines: Array<string> = []
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
|
||||||
const valueNum =
|
|
||||||
typeof item.porcentaje === 'number'
|
|
||||||
? item.porcentaje
|
|
||||||
: typeof item.porcentaje === 'string'
|
|
||||||
? Number(item.porcentaje)
|
|
||||||
: NaN
|
|
||||||
|
|
||||||
if (!label) continue
|
|
||||||
if (!Number.isFinite(valueNum)) continue
|
|
||||||
|
|
||||||
const v = Math.trunc(valueNum)
|
|
||||||
if (v < 1 || v > 100) continue
|
|
||||||
|
|
||||||
lines.push(`${label}: ${v}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
|
||||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
|
||||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DragDropProvider } from '@dnd-kit/react'
|
||||||
|
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
|
||||||
import { useParams } from '@tanstack/react-router'
|
import { useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -11,7 +13,7 @@ import {
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
||||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -50,6 +52,95 @@ export interface UnidadTematica {
|
|||||||
temas: Array<Tema>
|
temas: Array<Tema>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createClientId(prefix: string) {
|
||||||
|
try {
|
||||||
|
const c = (globalThis as any).crypto
|
||||||
|
if (c && typeof c.randomUUID === 'function')
|
||||||
|
return `${prefix}-${c.randomUUID()}`
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayMove<T>(array: Array<T>, fromIndex: number, toIndex: number) {
|
||||||
|
const next = array.slice()
|
||||||
|
const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex
|
||||||
|
if (startIndex < 0 || startIndex >= next.length) return next
|
||||||
|
const endIndex = toIndex < 0 ? next.length + toIndex : toIndex
|
||||||
|
const [item] = next.splice(startIndex, 1)
|
||||||
|
next.splice(endIndex, 0, item)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumberUnidades(unidades: Array<UnidadTematica>) {
|
||||||
|
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsertUnidadOverlay({
|
||||||
|
onInsert,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
onInsert: () => void
|
||||||
|
position: 'top' | 'bottom'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
|
||||||
|
// Match the `space-y-4` gap so the hover target is *between* units.
|
||||||
|
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-background/95 border-border/60 hover:bg-background cursor-pointer opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onInsert()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableUnidad({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
registerContainer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
index: number
|
||||||
|
registerContainer: (el: HTMLDivElement | null) => void
|
||||||
|
children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode
|
||||||
|
}) {
|
||||||
|
const { ref, handleRef, isDragSource, isDropTarget } = useSortable({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
ref(el)
|
||||||
|
registerContainer(el)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group relative',
|
||||||
|
isDragSource && 'opacity-80',
|
||||||
|
isDropTarget && 'ring-primary/20 ring-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children({ handleRef })}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
@@ -100,20 +191,18 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
|
|||||||
if (Array.isArray(value.temas)) {
|
if (Array.isArray(value.temas)) {
|
||||||
temas = value.temas
|
temas = value.temas
|
||||||
.map(mapTemaValue)
|
.map(mapTemaValue)
|
||||||
.filter((t): t is ContenidoTemaApi => t !== null)
|
.filter((x): x is ContenidoTemaApi => x !== null)
|
||||||
} else if (typeof value.temas === 'string' && value.temas.trim()) {
|
|
||||||
temas = value.temas
|
|
||||||
.split(/\r?\n|,/)
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { unidad, titulo, temas }
|
return {
|
||||||
|
...value,
|
||||||
|
unidad,
|
||||||
|
titulo,
|
||||||
|
temas,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
||||||
if (value == null) return []
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
try {
|
try {
|
||||||
return mapContenidoTematicoFromDb(JSON.parse(value))
|
return mapContenidoTematicoFromDb(JSON.parse(value))
|
||||||
@@ -192,7 +281,16 @@ export function ContenidoTematico() {
|
|||||||
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
||||||
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
||||||
|
|
||||||
|
const didInitExpandedUnitsRef = useRef(false)
|
||||||
|
|
||||||
|
const unidadesRef = useRef<Array<UnidadTematica>>([])
|
||||||
|
useEffect(() => {
|
||||||
|
unidadesRef.current = unidades
|
||||||
|
}, [unidades])
|
||||||
|
|
||||||
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
||||||
|
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
|
||||||
|
didInitExpandedUnitsRef.current = true
|
||||||
const payload = serializeUnidadesToApi(nextUnidades)
|
const payload = serializeUnidadesToApi(nextUnidades)
|
||||||
await updateContenido.mutateAsync({
|
await updateContenido.mutateAsync({
|
||||||
subjectId: asignaturaId,
|
subjectId: asignaturaId,
|
||||||
@@ -246,10 +344,17 @@ export function ContenidoTematico() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseHorasEstimadas = (raw: string): number => {
|
||||||
|
const normalized = raw.trim().replace(',', '.')
|
||||||
|
const parsed = Number.parseFloat(normalized)
|
||||||
|
if (!Number.isFinite(parsed)) return 0
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
const commitEditTema = () => {
|
const commitEditTema = () => {
|
||||||
if (!editingTema) return
|
if (!editingTema) return
|
||||||
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
|
const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
|
||||||
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
|
|
||||||
|
|
||||||
const next = unidades.map((u) => {
|
const next = unidades.map((u) => {
|
||||||
if (u.id !== editingTema.unitId) return u
|
if (u.id !== editingTema.unitId) return u
|
||||||
@@ -303,28 +408,110 @@ export function ContenidoTematico() {
|
|||||||
data ? data.contenido_tematico : undefined,
|
data ? data.contenido_tematico : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const transformed = contenido.map((u, idx) => ({
|
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
|
||||||
id: `u-${u.unidad || idx + 1}`,
|
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
|
||||||
numero: u.unidad || idx + 1,
|
const currentPayload = JSON.stringify(
|
||||||
nombre: u.titulo || 'Sin título',
|
serializeUnidadesToApi(unidadesRef.current),
|
||||||
temas: Array.isArray(u.temas)
|
)
|
||||||
? u.temas.map((t: any, tidx: number) => ({
|
|
||||||
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
|
||||||
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
|
const incomingPayload = JSON.stringify(
|
||||||
horasEstimadas: t?.horasEstimadas || 0,
|
contenido.map((u, idx) => ({
|
||||||
}))
|
unidad: u.unidad || idx + 1,
|
||||||
: [],
|
titulo: u.titulo || 'Sin título',
|
||||||
}))
|
temas: Array.isArray(u.temas)
|
||||||
|
? u.temas.map((t) => {
|
||||||
|
if (typeof t === 'string') {
|
||||||
|
return {
|
||||||
|
nombre: t,
|
||||||
|
horasEstimadas: 0,
|
||||||
|
descripcion: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nombre: t.nombre || 'Tema',
|
||||||
|
horasEstimadas: t.horasEstimadas ?? 0,
|
||||||
|
descripcion: t.descripcion,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Si los datos son idénticos, abortamos el useEffect.
|
||||||
|
// ¡Nuestros IDs locales se salvan y no hay parpadeos!
|
||||||
|
if (currentPayload === incomingPayload && unidadesRef.current.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado.
|
||||||
|
// Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE.
|
||||||
|
const prevUnidades = [...unidadesRef.current]
|
||||||
|
|
||||||
|
const transformed = contenido.map((u, idx) => {
|
||||||
|
const dbTitulo = u.titulo || 'Sin título'
|
||||||
|
|
||||||
|
// Buscamos si ya existe una unidad con este mismo título
|
||||||
|
const existingUnitIndex = prevUnidades.findIndex(
|
||||||
|
(prev) => prev.nombre === dbTitulo,
|
||||||
|
)
|
||||||
|
let unidadId
|
||||||
|
let existingUnit = null
|
||||||
|
|
||||||
|
if (existingUnitIndex !== -1) {
|
||||||
|
existingUnit = prevUnidades[existingUnitIndex]
|
||||||
|
unidadId = existingUnit.id
|
||||||
|
prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo
|
||||||
|
} else {
|
||||||
|
unidadId = createClientId(`u-${u.unidad || idx + 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: unidadId,
|
||||||
|
numero: u.unidad || idx + 1,
|
||||||
|
nombre: dbTitulo,
|
||||||
|
temas: Array.isArray(u.temas)
|
||||||
|
? u.temas.map((t: any, tidx: number) => {
|
||||||
|
const dbTemaNombre =
|
||||||
|
typeof t === 'string' ? t : t?.nombre || 'Tema'
|
||||||
|
|
||||||
|
// Reciclamos subtemas por nombre también
|
||||||
|
const existingTema = existingUnit?.temas.find(
|
||||||
|
(prevT) => prevT.nombre === dbTemaNombre,
|
||||||
|
)
|
||||||
|
const temaId = existingTema
|
||||||
|
? existingTema.id
|
||||||
|
: createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: temaId,
|
||||||
|
nombre: dbTemaNombre,
|
||||||
|
horasEstimadas:
|
||||||
|
coerceNumber(
|
||||||
|
typeof t === 'string' ? undefined : t?.horasEstimadas,
|
||||||
|
) ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
setUnidades(transformed)
|
setUnidades(transformed)
|
||||||
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
|
||||||
setExpandedUnits((prev) => {
|
setExpandedUnits((prev) => {
|
||||||
const validIds = new Set(transformed.map((u) => u.id))
|
const validIds = new Set(transformed.map((u) => u.id))
|
||||||
const filtered = new Set(
|
const filtered = new Set(
|
||||||
Array.from(prev).filter((id) => validIds.has(id)),
|
Array.from(prev).filter((id) => validIds.has(id)),
|
||||||
)
|
)
|
||||||
if (filtered.size > 0) return filtered
|
|
||||||
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
// Expandir la primera unidad solo una vez al llegar a la ruta.
|
||||||
|
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
|
||||||
|
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
|
||||||
|
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
})
|
})
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
@@ -353,7 +540,7 @@ export function ContenidoTematico() {
|
|||||||
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
||||||
const totalHoras = unidades.reduce(
|
const totalHoras = unidades.reduce(
|
||||||
(acc, u) =>
|
(acc, u) =>
|
||||||
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
|
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -364,16 +551,22 @@ export function ContenidoTematico() {
|
|||||||
setExpandedUnits(newExpanded)
|
setExpandedUnits(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addUnidad = () => {
|
const insertUnidadAt = (insertIndex: number) => {
|
||||||
const newNumero = unidades.length + 1
|
const newId = createClientId('u')
|
||||||
const newId = `u-${newNumero}`
|
|
||||||
const newUnidad: UnidadTematica = {
|
const newUnidad: UnidadTematica = {
|
||||||
id: newId,
|
id: newId,
|
||||||
nombre: 'Nueva Unidad',
|
nombre: 'Nueva Unidad',
|
||||||
numero: newNumero,
|
numero: 0,
|
||||||
temas: [],
|
temas: [],
|
||||||
}
|
}
|
||||||
const next = [...unidades, newUnidad]
|
|
||||||
|
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
|
||||||
|
const next = renumberUnidades([
|
||||||
|
...unidades.slice(0, clampedIndex),
|
||||||
|
newUnidad,
|
||||||
|
...unidades.slice(clampedIndex),
|
||||||
|
])
|
||||||
|
|
||||||
setUnidades(next)
|
setUnidades(next)
|
||||||
setExpandedUnits((prev) => {
|
setExpandedUnits((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
@@ -382,10 +575,40 @@ export function ContenidoTematico() {
|
|||||||
})
|
})
|
||||||
setPendingScrollUnitId(newId)
|
setPendingScrollUnitId(newId)
|
||||||
|
|
||||||
// Abrir edición del título inmediatamente
|
|
||||||
setEditingUnit(newId)
|
setEditingUnit(newId)
|
||||||
setUnitDraftNombre(newUnidad.nombre)
|
setUnitDraftNombre(newUnidad.nombre)
|
||||||
setUnitOriginalNombre(newUnidad.nombre)
|
setUnitOriginalNombre(newUnidad.nombre)
|
||||||
|
|
||||||
|
void persistUnidades(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReorderEnd = (event: any) => {
|
||||||
|
if (event?.canceled) return
|
||||||
|
|
||||||
|
const source = event?.operation?.source
|
||||||
|
if (!source) return
|
||||||
|
|
||||||
|
// Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden
|
||||||
|
if (!isSortable(source)) return
|
||||||
|
|
||||||
|
// Extraemos las posiciones exactas calculadas por dnd-kit
|
||||||
|
const { initialIndex, index } = source.sortable
|
||||||
|
|
||||||
|
// Si lo soltó en la misma posición de la que salió, cancelamos
|
||||||
|
if (initialIndex === index) return
|
||||||
|
|
||||||
|
setUnidades((prev) => {
|
||||||
|
// Hacemos el movimiento usando los índices directos
|
||||||
|
const moved = arrayMove(prev, initialIndex, index)
|
||||||
|
const next = renumberUnidades(moved)
|
||||||
|
|
||||||
|
// Disparamos la persistencia hacia Supabase
|
||||||
|
void persistUnidades(next).catch((err) => {
|
||||||
|
console.error('No se pudo guardar el orden de unidades', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lógica de Temas ---
|
// --- Lógica de Temas ---
|
||||||
@@ -451,158 +674,182 @@ export function ContenidoTematico() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<DragDropProvider onDragEnd={handleReorderEnd}>
|
||||||
{unidades.map((unidad) => (
|
<div className="space-y-4">
|
||||||
<div
|
{unidades.map((unidad, index) => (
|
||||||
key={unidad.id}
|
<SortableUnidad
|
||||||
ref={(el) => {
|
key={unidad.id}
|
||||||
if (el) unitContainerRefs.current.set(unidad.id, el)
|
id={unidad.id}
|
||||||
else unitContainerRefs.current.delete(unidad.id)
|
index={index}
|
||||||
}}
|
registerContainer={(el) => {
|
||||||
>
|
if (el) unitContainerRefs.current.set(unidad.id, el)
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
else unitContainerRefs.current.delete(unidad.id)
|
||||||
<Collapsible
|
}}
|
||||||
open={expandedUnits.has(unidad.id)}
|
>
|
||||||
onOpenChange={() => toggleUnit(unidad.id)}
|
{({ handleRef }) => (
|
||||||
>
|
<>
|
||||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
{index === 0 && (
|
||||||
<div className="flex items-center gap-3">
|
<InsertUnidadOverlay
|
||||||
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
position="top"
|
||||||
<CollapsibleTrigger asChild>
|
onInsert={() => insertUnidadAt(index)}
|
||||||
<Button variant="ghost" size="sm" className="h-auto p-0">
|
/>
|
||||||
{expandedUnits.has(unidad.id) ? (
|
)}
|
||||||
<ChevronDown className="h-4 w-4" />
|
<InsertUnidadOverlay
|
||||||
) : (
|
position="bottom"
|
||||||
<ChevronRight className="h-4 w-4" />
|
onInsert={() => insertUnidadAt(index + 1)}
|
||||||
)}
|
/>
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<Badge className="bg-blue-600 font-mono">
|
|
||||||
Unidad {unidad.numero}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{editingUnit === unidad.id ? (
|
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
<Input
|
<Collapsible
|
||||||
ref={unitTitleInputRef}
|
open={expandedUnits.has(unidad.id)}
|
||||||
value={unitDraftNombre}
|
onOpenChange={() => toggleUnit(unidad.id)}
|
||||||
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
>
|
||||||
onBlur={() => {
|
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
||||||
if (cancelNextBlurRef.current) {
|
<div className="flex items-center gap-3">
|
||||||
cancelNextBlurRef.current = false
|
<span
|
||||||
return
|
ref={handleRef as any}
|
||||||
}
|
className="inline-flex cursor-grab touch-none items-center text-slate-300"
|
||||||
commitEditUnit()
|
aria-label="Reordenar unidad"
|
||||||
}}
|
>
|
||||||
onKeyDown={(e) => {
|
<GripVertical className="h-4 w-4" />
|
||||||
if (e.key === 'Enter') {
|
</span>
|
||||||
e.preventDefault()
|
<CollapsibleTrigger asChild>
|
||||||
e.currentTarget.blur()
|
<Button
|
||||||
return
|
variant="ghost"
|
||||||
}
|
size="sm"
|
||||||
if (e.key === 'Escape') {
|
className="h-auto cursor-pointer p-0"
|
||||||
e.preventDefault()
|
>
|
||||||
cancelNextBlurRef.current = true
|
{expandedUnits.has(unidad.id) ? (
|
||||||
cancelEditUnit()
|
<ChevronDown className="h-4 w-4" />
|
||||||
e.currentTarget.blur()
|
) : (
|
||||||
}
|
<ChevronRight className="h-4 w-4" />
|
||||||
}}
|
)}
|
||||||
className="h-8 max-w-md bg-white"
|
</Button>
|
||||||
/>
|
</CollapsibleTrigger>
|
||||||
) : (
|
<Badge className="bg-blue-600 font-mono">
|
||||||
<CardTitle
|
Unidad {unidad.numero}
|
||||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
</Badge>
|
||||||
onClick={() => beginEditUnit(unidad.id)}
|
|
||||||
>
|
|
||||||
{unidad.nombre}
|
|
||||||
</CardTitle>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
{editingUnit === unidad.id ? (
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
|
<Input
|
||||||
<Clock className="h-3 w-3" />{' '}
|
ref={unitTitleInputRef}
|
||||||
{unidad.temas.reduce(
|
value={unitDraftNombre}
|
||||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
onChange={(e) =>
|
||||||
0,
|
setUnitDraftNombre(e.target.value)
|
||||||
)}
|
}
|
||||||
h
|
onBlur={() => {
|
||||||
</span>
|
if (cancelNextBlurRef.current) {
|
||||||
<Button
|
cancelNextBlurRef.current = false
|
||||||
variant="ghost"
|
return
|
||||||
size="icon"
|
}
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
commitEditUnit()
|
||||||
onClick={() =>
|
}}
|
||||||
setDeleteDialog({ type: 'unidad', id: unidad.id })
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === 'Enter') {
|
||||||
>
|
e.preventDefault()
|
||||||
<Trash2 className="h-4 w-4" />
|
e.currentTarget.blur()
|
||||||
</Button>
|
return
|
||||||
</div>
|
}
|
||||||
</div>
|
if (e.key === 'Escape') {
|
||||||
</CardHeader>
|
e.preventDefault()
|
||||||
<CollapsibleContent>
|
cancelNextBlurRef.current = true
|
||||||
<CardContent className="bg-white pt-4">
|
cancelEditUnit()
|
||||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
e.currentTarget.blur()
|
||||||
{unidad.temas.map((tema, idx) => (
|
}
|
||||||
<TemaRow
|
}}
|
||||||
key={tema.id}
|
className="h-8 max-w-md bg-white"
|
||||||
tema={tema}
|
/>
|
||||||
index={idx + 1}
|
) : (
|
||||||
isEditing={
|
<CardTitle
|
||||||
!!editingTema &&
|
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
||||||
editingTema.unitId === unidad.id &&
|
onClick={() => beginEditUnit(unidad.id)}
|
||||||
editingTema.temaId === tema.id
|
>
|
||||||
}
|
{unidad.nombre}
|
||||||
draftNombre={temaDraftNombre}
|
</CardTitle>
|
||||||
draftHoras={temaDraftHoras}
|
)}
|
||||||
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
|
||||||
onDraftNombreChange={setTemaDraftNombre}
|
|
||||||
onDraftHorasChange={setTemaDraftHoras}
|
|
||||||
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
|
||||||
onEditorKeyDownCapture={
|
|
||||||
handleTemaEditorKeyDownCapture
|
|
||||||
}
|
|
||||||
onNombreInputRef={(el) => {
|
|
||||||
temaNombreInputElRef.current = el
|
|
||||||
}}
|
|
||||||
onDelete={() =>
|
|
||||||
setDeleteDialog({
|
|
||||||
type: 'tema',
|
|
||||||
id: tema.id,
|
|
||||||
parentId: unidad.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
|
||||||
onClick={() => addTema(unidad.id)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<Button
|
<span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
|
||||||
variant="outline"
|
<Clock className="h-3 w-3" />{' '}
|
||||||
className="gap-2"
|
{unidad.temas.reduce(
|
||||||
onClick={(e) => {
|
(sum, t) => sum + (t.horasEstimadas || 0),
|
||||||
// Evita que Enter vuelva a disparar el click sobre el botón.
|
0,
|
||||||
e.currentTarget.blur()
|
)}
|
||||||
addUnidad()
|
h
|
||||||
}}
|
</span>
|
||||||
>
|
<Button
|
||||||
<Plus className="h-4 w-4" /> Nueva unidad
|
variant="ghost"
|
||||||
</Button>
|
size="icon"
|
||||||
</div>
|
className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteDialog({
|
||||||
|
type: 'unidad',
|
||||||
|
id: unidad.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="bg-white pt-4">
|
||||||
|
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
||||||
|
{unidad.temas.map((tema, idx) => (
|
||||||
|
<TemaRow
|
||||||
|
key={tema.id}
|
||||||
|
tema={tema}
|
||||||
|
index={idx + 1}
|
||||||
|
isEditing={
|
||||||
|
!!editingTema &&
|
||||||
|
editingTema.unitId === unidad.id &&
|
||||||
|
editingTema.temaId === tema.id
|
||||||
|
}
|
||||||
|
draftNombre={temaDraftNombre}
|
||||||
|
draftHoras={temaDraftHoras}
|
||||||
|
onBeginEdit={() =>
|
||||||
|
beginEditTema(unidad.id, tema.id)
|
||||||
|
}
|
||||||
|
onDraftNombreChange={setTemaDraftNombre}
|
||||||
|
onDraftHorasChange={setTemaDraftHoras}
|
||||||
|
onEditorBlurCapture={
|
||||||
|
handleTemaEditorBlurCapture
|
||||||
|
}
|
||||||
|
onEditorKeyDownCapture={
|
||||||
|
handleTemaEditorKeyDownCapture
|
||||||
|
}
|
||||||
|
onNombreInputRef={(el) => {
|
||||||
|
temaNombreInputElRef.current = el
|
||||||
|
}}
|
||||||
|
onDelete={() =>
|
||||||
|
setDeleteDialog({
|
||||||
|
type: 'tema',
|
||||||
|
id: tema.id,
|
||||||
|
parentId: unidad.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
onClick={() => addTema(unidad.id)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SortableUnidad>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropProvider>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
dialog={deleteDialog}
|
dialog={deleteDialog}
|
||||||
@@ -667,6 +914,9 @@ function TemaRow({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={draftHoras}
|
value={draftHoras}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={0.5}
|
||||||
onChange={(e) => onDraftHorasChange(e.target.value)}
|
onChange={(e) => onDraftHorasChange(e.target.value)}
|
||||||
className="h-8 w-16 bg-white"
|
className="h-8 w-16 bg-white"
|
||||||
/>
|
/>
|
||||||
@@ -675,7 +925,7 @@ function TemaRow({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex flex-1 items-center gap-3 text-left"
|
className="flex flex-1 cursor-pointer items-center gap-3 text-left"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onBeginEdit()
|
onBeginEdit()
|
||||||
@@ -690,7 +940,7 @@ function TemaRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-blue-600"
|
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onBeginEdit()
|
onBeginEdit()
|
||||||
@@ -701,7 +951,7 @@ function TemaRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-red-500"
|
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDelete()
|
onDelete()
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
|
|||||||
interface DocumentoSEPTabProps {
|
interface DocumentoSEPTabProps {
|
||||||
pdfUrl: string | null
|
pdfUrl: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
onDownload: () => void
|
onDownloadPdf: () => void
|
||||||
|
onDownloadWord: () => void
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
|
|||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
onDownload,
|
onDownloadPdf,
|
||||||
|
onDownloadWord,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
isRegenerating,
|
isRegenerating,
|
||||||
}: DocumentoSEPTabProps) {
|
}: DocumentoSEPTabProps) {
|
||||||
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{pdfUrl && !isLoading && (
|
|
||||||
<Button variant="outline" onClick={onDownload}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Descargar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={showConfirmDialog}
|
open={showConfirmDialog}
|
||||||
onOpenChange={setShowConfirmDialog}
|
onOpenChange={setShowConfirmDialog}
|
||||||
>
|
>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button disabled={isRegenerating}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isRegenerating}
|
||||||
|
>
|
||||||
{isRegenerating ? (
|
{isRegenerating ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{pdfUrl && !isLoading && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
|
onClick={onDownloadWord}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Descargar Word
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={onDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Descargar PDF
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF Preview */}
|
{/* PDF Preview */}
|
||||||
<Card className="h-[800px] overflow-hidden">
|
<Card className="h-200 overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-10 w-10 animate-spin" />
|
<Loader2 className="h-10 w-10 animate-spin" />
|
||||||
|
|||||||
@@ -13,21 +13,33 @@ import {
|
|||||||
X,
|
X,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Archive,
|
Archive,
|
||||||
History, // Agregado
|
History,
|
||||||
|
Edit2, // Agregado
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
|
||||||
|
|
||||||
import type { IASugerencia } from '@/types/asignatura'
|
import type { IASugerencia } from '@/types/asignatura'
|
||||||
|
|
||||||
|
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
useAISubjectChat,
|
useAISubjectChat,
|
||||||
useConversationBySubject,
|
useConversationBySubject,
|
||||||
useMessagesBySubjectChat,
|
useMessagesBySubjectChat,
|
||||||
useSubject,
|
useSubject,
|
||||||
|
useUpdateSubjectConversationName,
|
||||||
useUpdateSubjectConversationStatus,
|
useUpdateSubjectConversationStatus,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -75,6 +87,24 @@ export function IAAsignaturaTab({
|
|||||||
const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
|
const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
|
||||||
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
|
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
|
||||||
const hasInitialSelected = useRef(false)
|
const hasInitialSelected = useRef(false)
|
||||||
|
const { mutate: updateName } = useUpdateSubjectConversationName()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [tempName, setTempName] = useState('')
|
||||||
|
const [openIA, setOpenIA] = useState(false)
|
||||||
|
|
||||||
|
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
|
||||||
|
Array<string>
|
||||||
|
>([])
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
|
||||||
|
|
||||||
|
// Cálculo del total para el Badge del botón
|
||||||
|
const totalReferencias =
|
||||||
|
selectedArchivoIds.length +
|
||||||
|
selectedRepositorioIds.length +
|
||||||
|
uploadedFiles.length
|
||||||
|
|
||||||
const isAiThinking = useMemo(() => {
|
const isAiThinking = useMemo(() => {
|
||||||
if (isSending) return true
|
if (isSending) return true
|
||||||
@@ -106,35 +136,92 @@ export function IAAsignaturaTab({
|
|||||||
}
|
}
|
||||||
}, [todasConversaciones])
|
}, [todasConversaciones])
|
||||||
|
|
||||||
|
const availableFields = useMemo(() => {
|
||||||
|
// 1. Obtenemos los campos dinámicos de la DB
|
||||||
|
const dynamicFields = datosGenerales?.datos
|
||||||
|
? Object.keys(datosGenerales.datos).map((key) => {
|
||||||
|
const estructuraProps =
|
||||||
|
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label:
|
||||||
|
estructuraProps[key]?.title ||
|
||||||
|
key.replace(/_/g, ' ').toUpperCase(),
|
||||||
|
value: String(datosGenerales.datos[key] || ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
// 2. Definimos tus campos manuales (hardcoded)
|
||||||
|
const hardcodedFields = [
|
||||||
|
{
|
||||||
|
key: 'contenido_tematico',
|
||||||
|
label: 'Contenido temático',
|
||||||
|
value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'criterios_de_evaluacion',
|
||||||
|
label: 'Criterios de evaluación',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
|
||||||
|
const combined = [...dynamicFields]
|
||||||
|
|
||||||
|
hardcodedFields.forEach((hf) => {
|
||||||
|
if (!combined.some((f) => f.key === hf.key)) {
|
||||||
|
combined.push(hf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}, [datosGenerales])
|
||||||
|
|
||||||
|
// --- PROCESAMIENTO DE MENSAJES ---
|
||||||
// --- PROCESAMIENTO DE MENSAJES ---
|
// --- PROCESAMIENTO DE MENSAJES ---
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!rawMessages) return []
|
const msgs: Array<any> = []
|
||||||
return rawMessages.flatMap((m) => {
|
|
||||||
const msgs = []
|
// 1. Mensajes existentes de la DB
|
||||||
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
|
if (rawMessages) {
|
||||||
if (m.respuesta) {
|
rawMessages.forEach((m) => {
|
||||||
msgs.push({
|
// Mensaje del usuario
|
||||||
id: `${m.id}-ai`,
|
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
|
||||||
role: 'assistant',
|
|
||||||
content: m.respuesta,
|
// Respuesta de la IA (si existe)
|
||||||
sugerencia: m.propuesta?.recommendations?.[0]
|
if (m.respuesta) {
|
||||||
? {
|
const sugerencias =
|
||||||
id: m.id,
|
m.propuesta?.recommendations?.map((rec: any, index: number) => ({
|
||||||
campoKey: m.propuesta.recommendations[0].campo_afectado,
|
id: `${m.id}-sug-${index}`,
|
||||||
campoNombre:
|
messageId: m.id,
|
||||||
m.propuesta.recommendations[0].campo_afectado.replace(
|
campoKey: rec.campo_afectado,
|
||||||
/_/g,
|
campoNombre: rec.campo_afectado.replace(/_/g, ' '),
|
||||||
' ',
|
valorSugerido: rec.texto_mejora,
|
||||||
),
|
aceptada: rec.aplicada,
|
||||||
valorSugerido: m.propuesta.recommendations[0].texto_mejora,
|
})) || []
|
||||||
aceptada: m.propuesta.recommendations[0].aplicada,
|
|
||||||
}
|
msgs.push({
|
||||||
: null,
|
id: `${m.id}-ai`,
|
||||||
})
|
role: 'assistant',
|
||||||
}
|
content: m.respuesta,
|
||||||
return msgs
|
sugerencias: sugerencias,
|
||||||
})
|
})
|
||||||
}, [rawMessages])
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
|
||||||
|
if (isSending && input.trim()) {
|
||||||
|
msgs.push({
|
||||||
|
id: 'optimistic-user-msg',
|
||||||
|
role: 'user',
|
||||||
|
content: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs
|
||||||
|
}, [rawMessages, isSending, input])
|
||||||
|
|
||||||
// Auto-selección inicial
|
// Auto-selección inicial
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,6 +234,58 @@ export function IAAsignaturaTab({
|
|||||||
}
|
}
|
||||||
}, [activeChats, loadingConv])
|
}, [activeChats, loadingConv])
|
||||||
|
|
||||||
|
const filteredFields = useMemo(() => {
|
||||||
|
if (!showSuggestions) return availableFields
|
||||||
|
|
||||||
|
// Extraemos lo que hay después del último ':' para filtrar
|
||||||
|
const lastColonIndex = input.lastIndexOf(':')
|
||||||
|
const query = input.slice(lastColonIndex + 1).toLowerCase()
|
||||||
|
|
||||||
|
return availableFields.filter(
|
||||||
|
(f) =>
|
||||||
|
f.label.toLowerCase().includes(query) ||
|
||||||
|
f.key.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [availableFields, input, showSuggestions])
|
||||||
|
|
||||||
|
// 2. Efecto para cerrar con ESC
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setShowSuggestions(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 3. Función para insertar el campo y limpiar el prompt
|
||||||
|
const handleSelectField = (field: SelectedField) => {
|
||||||
|
// 1. Agregamos al array de objetos (para tu lógica de API)
|
||||||
|
if (!selectedFields.find((f) => f.key === field.key)) {
|
||||||
|
setSelectedFields((prev) => [...prev, field])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lógica de autocompletado en el texto
|
||||||
|
const lastColonIndex = input.lastIndexOf(':')
|
||||||
|
if (lastColonIndex !== -1) {
|
||||||
|
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
|
||||||
|
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
|
||||||
|
setInput(nuevoTexto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cerramos el buscador y devolvemos el foco al textarea
|
||||||
|
setShowSuggestions(false)
|
||||||
|
|
||||||
|
// Opcional: Si tienes una ref del textarea, puedes hacer:
|
||||||
|
// textareaRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveName = (id: string) => {
|
||||||
|
if (tempName.trim()) {
|
||||||
|
updateName({ id, nombre: tempName })
|
||||||
|
}
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = async (promptOverride?: string) => {
|
const handleSend = async (promptOverride?: string) => {
|
||||||
const text = promptOverride || input
|
const text = promptOverride || input
|
||||||
if (!text.trim() && selectedFields.length === 0) return
|
if (!text.trim() && selectedFields.length === 0) return
|
||||||
@@ -166,7 +305,7 @@ export function IAAsignaturaTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInput('')
|
setInput('')
|
||||||
setSelectedFields([])
|
// setSelectedFields([])
|
||||||
|
|
||||||
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
|
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -187,18 +326,6 @@ export function IAAsignaturaTab({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableFields = useMemo(() => {
|
|
||||||
if (!datosGenerales?.datos) return []
|
|
||||||
const estructuraProps =
|
|
||||||
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
|
||||||
return Object.keys(datosGenerales.datos).map((key) => ({
|
|
||||||
key,
|
|
||||||
label:
|
|
||||||
estructuraProps[key]?.title || key.replace(/_/g, ' ').toUpperCase(),
|
|
||||||
value: String(datosGenerales.datos[key] || ''),
|
|
||||||
}))
|
|
||||||
}, [datosGenerales])
|
|
||||||
|
|
||||||
const createNewChat = () => {
|
const createNewChat = () => {
|
||||||
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
|
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
|
||||||
setInput('')
|
setInput('')
|
||||||
@@ -250,11 +377,8 @@ export function IAAsignaturaTab({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 1. Limpiamos el ID
|
|
||||||
setActiveChatId(undefined)
|
setActiveChatId(undefined)
|
||||||
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
|
|
||||||
hasInitialSelected.current = true
|
hasInitialSelected.current = true
|
||||||
// 3. Limpiamos estados visuales
|
|
||||||
setIsCreatingNewChat(true)
|
setIsCreatingNewChat(true)
|
||||||
setInput('')
|
setInput('')
|
||||||
setSelectedFields([])
|
setSelectedFields([])
|
||||||
@@ -268,46 +392,117 @@ export function IAAsignaturaTab({
|
|||||||
<MessageSquarePlus size={18} /> Nuevo Chat
|
<MessageSquarePlus size={18} /> Nuevo Chat
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 pr-3">
|
<div className="flex flex-col gap-1 pr-3">
|
||||||
|
{' '}
|
||||||
|
{/* Eliminado space-y-1 para mejor control con gap */}
|
||||||
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => {
|
|
||||||
setActiveChatId(chat.id)
|
|
||||||
setIsCreatingNewChat(false) // <--- Volvemos al modo normal
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all',
|
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
|
||||||
|
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
|
||||||
activeChatId === chat.id
|
activeChatId === chat.id
|
||||||
? 'bg-teal-50 font-medium text-teal-900'
|
? 'bg-teal-50 text-teal-900'
|
||||||
: 'text-slate-600 hover:bg-slate-100',
|
: 'text-slate-600 hover:bg-slate-100',
|
||||||
)}
|
)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setEditingId(chat.id)
|
||||||
|
setTempName(chat.nombre || chat.titulo || 'Conversacion')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={14} className="shrink-0 opacity-50" />
|
{editingId === chat.id ? (
|
||||||
<span className="flex-1 truncate">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
{chat.titulo || 'Conversación'}
|
<input
|
||||||
</span>
|
autoFocus
|
||||||
<button
|
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
|
||||||
onClick={(e) => {
|
value={tempName}
|
||||||
e.stopPropagation()
|
onChange={(e) => setTempName(e.target.value)}
|
||||||
updateStatus(
|
onBlur={() => handleSaveName(chat.id)}
|
||||||
{
|
onKeyDown={(e) => {
|
||||||
id: chat.id,
|
if (e.key === 'Enter') handleSaveName(chat.id)
|
||||||
estado: showArchived ? 'ACTIVA' : 'ARCHIVADA',
|
if (e.key === 'Escape') setEditingId(null)
|
||||||
},
|
}}
|
||||||
{
|
/>
|
||||||
onSuccess: () =>
|
</div>
|
||||||
queryClient.invalidateQueries({
|
) : (
|
||||||
queryKey: ['conversation-by-subject'],
|
<>
|
||||||
}),
|
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
|
||||||
},
|
<span
|
||||||
)
|
onClick={() => setActiveChatId(chat.id)}
|
||||||
}}
|
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
|
||||||
className="rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-200"
|
title={chat.nombre || chat.titulo}
|
||||||
>
|
>
|
||||||
<Archive size={12} />
|
{chat.nombre || chat.titulo || 'Conversación'}
|
||||||
</button>
|
</span>
|
||||||
|
|
||||||
|
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
|
||||||
|
activeChatId === chat.id
|
||||||
|
? 'bg-teal-50'
|
||||||
|
: 'bg-slate-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingId(chat.id)
|
||||||
|
setTempName(chat.nombre || chat.titulo || '')
|
||||||
|
}}
|
||||||
|
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-[10px]">
|
||||||
|
Editar nombre
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const nuevoEstado =
|
||||||
|
chat.estado === 'ACTIVA'
|
||||||
|
? 'ARCHIVADA'
|
||||||
|
: 'ACTIVA'
|
||||||
|
updateStatus({
|
||||||
|
id: chat.id,
|
||||||
|
estado: nuevoEstado,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1 transition-colors hover:bg-slate-200',
|
||||||
|
chat.estado === 'ACTIVA'
|
||||||
|
? 'hover:text-red-500'
|
||||||
|
: 'hover:text-teal-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{chat.estado === 'ACTIVA' ? (
|
||||||
|
<Archive size={14} />
|
||||||
|
) : (
|
||||||
|
<History size={14} className="scale-x-[-1]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-[10px]">
|
||||||
|
{chat.estado === 'ACTIVA'
|
||||||
|
? 'Archivar'
|
||||||
|
: 'Desarchivar'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,112 +511,133 @@ export function IAAsignaturaTab({
|
|||||||
|
|
||||||
{/* PANEL CENTRAL */}
|
{/* PANEL CENTRAL */}
|
||||||
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
||||||
<div className="shrink-0 border-b bg-white p-3">
|
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||||
Asistente IA
|
Asistente IA
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenIA(true)}
|
||||||
|
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
<FileText size={14} className="text-slate-500" />
|
||||||
|
Referencias
|
||||||
|
{totalReferencias > 0 && (
|
||||||
|
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
|
||||||
|
{totalReferencias}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||||||
{messages.length === 0 && !isSending && (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 text-center opacity-60">
|
|
||||||
<div className="rounded-full bg-teal-100 p-4">
|
|
||||||
<Sparkles size={32} className="text-teal-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-slate-700">
|
|
||||||
Nueva Consultoría IA
|
|
||||||
</h3>
|
|
||||||
<p className="max-w-[250px] text-xs text-slate-500">
|
|
||||||
Selecciona campos con ":" o usa una acción rápida para
|
|
||||||
comenzar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
className={cn(
|
||||||
|
'flex gap-4',
|
||||||
|
msg.role === 'user' ? 'flex-row-reverse' : 'flex-row',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
|
className={cn(
|
||||||
|
'h-9 w-9 shrink-0 border shadow-sm',
|
||||||
|
msg.role === 'assistant'
|
||||||
|
? 'bg-teal-600 text-white'
|
||||||
|
: 'bg-slate-100',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<AvatarFallback className="text-[10px]">
|
<AvatarFallback>
|
||||||
{msg.role === 'assistant' ? (
|
{msg.role === 'assistant' ? (
|
||||||
<Sparkles size={14} className="text-teal-600" />
|
<Sparkles size={16} />
|
||||||
) : (
|
) : (
|
||||||
<UserCheck size={14} />
|
<UserCheck size={16} />
|
||||||
)}
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
|
className={cn(
|
||||||
|
'flex max-w-[85%] flex-col gap-3',
|
||||||
|
msg.role === 'user' ? 'items-end' : 'items-start',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm',
|
'relative overflow-hidden rounded-2xl border shadow-sm',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'rounded-tr-none bg-teal-600 text-white'
|
? 'rounded-tr-none border-teal-700 bg-teal-600 px-4 py-3 text-white'
|
||||||
: 'rounded-tl-none border bg-white text-slate-700',
|
: 'w-full rounded-tl-none border-slate-200 bg-white text-slate-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{msg.content}
|
{/* Texto del mensaje principal */}
|
||||||
</div>
|
<div
|
||||||
|
style={{ whiteSpace: 'pre-line' }}
|
||||||
{msg.sugerencia && !msg.sugerencia.aceptada && (
|
className={cn(
|
||||||
<div className="animate-in fade-in slide-in-from-top-1 mt-3 w-full">
|
'text-sm leading-relaxed',
|
||||||
<div className="rounded-xl border border-teal-100 bg-white p-4 shadow-md">
|
msg.role === 'assistant' && 'p-4',
|
||||||
<p className="mb-2 text-[10px] font-bold text-slate-400 uppercase">
|
)}
|
||||||
Propuesta: {msg.sugerencia.campoNombre}
|
>
|
||||||
</p>
|
{msg.content}
|
||||||
<div className="mb-4 max-h-40 overflow-y-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-600 italic">
|
|
||||||
{msg.sugerencia.valorSugerido}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAcceptSuggestion(msg.sugerencia)}
|
|
||||||
className="h-8 bg-teal-600 hover:bg-teal-700"
|
|
||||||
>
|
|
||||||
<Check size={14} className="mr-1" /> Aplicar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onRejectSuggestion(msg.id)}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<X size={14} className="mr-1" /> Descartar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
|
||||||
|
{msg.role === 'assistant' &&
|
||||||
|
msg.sugerencias &&
|
||||||
|
msg.sugerencias.length > 0 && (
|
||||||
|
<div className="space-y-3 border-t bg-slate-50/50 p-3">
|
||||||
|
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
Mejoras disponibles:
|
||||||
|
</p>
|
||||||
|
{msg.sugerencias.map((sug: any) => (
|
||||||
|
<ImprovementCard
|
||||||
|
key={sug.id}
|
||||||
|
sug={sug}
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
|
onApplied={(campoFinalizado) => {
|
||||||
|
// Filtramos el array para conservar todos MENOS el que se aplicó
|
||||||
|
console.log(campoFinalizado)
|
||||||
|
console.log('campos:', selectedFields)
|
||||||
|
|
||||||
|
setSelectedFields((prev) =>
|
||||||
|
prev.filter((fieldObj) => {
|
||||||
|
// Accedemos a .key porque fieldObj es { key: "...", label: "..." }
|
||||||
|
return fieldObj.key !== campoFinalizado
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isAiThinking && (
|
{isAiThinking && (
|
||||||
<div className="animate-in fade-in flex flex-row items-start gap-3 duration-300">
|
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
||||||
<Avatar className="h-8 w-8 shrink-0 border bg-teal-50">
|
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<Sparkles
|
<Sparkles size={16} className="animate-pulse" />
|
||||||
size={14}
|
|
||||||
className="animate-pulse text-teal-600"
|
|
||||||
/>
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="rounded-2xl rounded-tl-none border bg-white p-4 shadow-sm">
|
<div className="flex flex-col items-start gap-2">
|
||||||
<div className="flex gap-1.5">
|
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:-0.3s]" />
|
<div className="flex gap-1">
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:-0.15s]" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
|
||||||
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[10px] font-medium text-slate-400 italic">
|
||||||
|
La IA está analizando tu solicitud...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Espacio extra al final para que el scroll no tape el último mensaje */}
|
||||||
|
<div className="h-4" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,39 +646,53 @@ export function IAAsignaturaTab({
|
|||||||
<div className="shrink-0 border-t bg-white p-4">
|
<div className="shrink-0 border-t bg-white p-4">
|
||||||
<div className="relative mx-auto max-w-4xl">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
||||||
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
Campos de Asignatura
|
<span>Filtrando campos...</span>
|
||||||
|
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
|
||||||
|
ESC para cerrar
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto p-1">
|
<div className="max-h-60 overflow-y-auto p-1">
|
||||||
{availableFields.map((field) => (
|
{filteredFields.length > 0 ? (
|
||||||
<button
|
filteredFields.map((field) => (
|
||||||
key={field.key}
|
<button
|
||||||
onClick={() => toggleField(field)}
|
key={field.key}
|
||||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors hover:bg-teal-50"
|
onClick={() => handleSelectField(field)}
|
||||||
>
|
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
||||||
<span className="text-slate-700">{field.label}</span>
|
>
|
||||||
{selectedFields.find((f) => f.key === field.key) && (
|
<div className="flex flex-col">
|
||||||
<Check size={14} className="text-teal-600" />
|
<span className="font-medium text-slate-700">
|
||||||
)}
|
{field.label}
|
||||||
</button>
|
</span>
|
||||||
))}
|
</div>
|
||||||
|
{selectedFields.find((f) => f.key === field.key) && (
|
||||||
|
<Check size={14} className="text-teal-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-xs text-slate-400 italic">
|
||||||
|
No se encontraron coincidencias
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
|
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
|
||||||
{selectedFields.length > 0 && (
|
{selectedFields.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 px-2 pt-1">
|
<div className="flex flex-wrap gap-1.5 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 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
|
||||||
>
|
>
|
||||||
|
<Target size={10} />
|
||||||
{field.label}
|
{field.label}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleField(field)}
|
onClick={() => toggleField(field)}
|
||||||
className="ml-1 rounded-full p-0.5 hover:bg-teal-200"
|
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
@@ -525,6 +755,41 @@ export function IAAsignaturaTab({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* --- DRAWER DE REFERENCIAS --- */}
|
||||||
|
<Drawer open={openIA} onOpenChange={setOpenIA}>
|
||||||
|
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
|
||||||
|
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
|
||||||
|
Referencias para la IA
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenIA(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<ReferenciasParaIA
|
||||||
|
selectedArchivoIds={selectedArchivoIds}
|
||||||
|
selectedRepositorioIds={selectedRepositorioIds}
|
||||||
|
uploadedFiles={uploadedFiles}
|
||||||
|
onToggleArchivo={(id, checked) => {
|
||||||
|
setSelectedArchivoIds((prev) =>
|
||||||
|
checked ? [...prev, id] : prev.filter((a) => a !== id),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onToggleRepositorio={(id, checked) => {
|
||||||
|
setSelectedRepositorioIds((prev) =>
|
||||||
|
checked ? [...prev, id] : prev.filter((r) => r !== id),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onFilesChange={(files) => setUploadedFiles(files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { IASugerencia } from '@/types/asignatura'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
useUpdateAsignatura,
|
||||||
|
useSubject,
|
||||||
|
useUpdateSubjectRecommendation,
|
||||||
|
} from '@/data'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ImprovementCardProps {
|
||||||
|
sug: IASugerencia
|
||||||
|
asignaturaId: string
|
||||||
|
onApplied: (campoKey: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImprovementCard({
|
||||||
|
sug,
|
||||||
|
asignaturaId,
|
||||||
|
onApplied,
|
||||||
|
}: ImprovementCardProps) {
|
||||||
|
const { data: asignatura } = useSubject(asignaturaId)
|
||||||
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
const updateRecommendation = useUpdateSubjectRecommendation()
|
||||||
|
|
||||||
|
const [isApplying, setIsApplying] = useState(false)
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!asignatura) return
|
||||||
|
|
||||||
|
setIsApplying(true)
|
||||||
|
try {
|
||||||
|
// 1. Identificar a qué columna debe ir el guardado
|
||||||
|
let patchData = {}
|
||||||
|
|
||||||
|
if (sug.campoKey === 'contenido_tematico') {
|
||||||
|
// Se guarda directamente en la columna contenido_tematico
|
||||||
|
patchData = { contenido_tematico: sug.valorSugerido }
|
||||||
|
} else if (sug.campoKey === 'criterios_de_evaluacion') {
|
||||||
|
// Se guarda directamente en la columna criterios_de_evaluacion
|
||||||
|
patchData = { criterios_de_evaluacion: sug.valorSugerido }
|
||||||
|
} else {
|
||||||
|
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
|
||||||
|
patchData = {
|
||||||
|
datos: {
|
||||||
|
...asignatura.datos,
|
||||||
|
[sug.campoKey]: sug.valorSugerido,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ejecutar la actualización con la estructura correcta
|
||||||
|
await updateAsignatura.mutateAsync({
|
||||||
|
asignaturaId: asignaturaId as any,
|
||||||
|
patch: patchData as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Marcar la recomendación como aplicada
|
||||||
|
await updateRecommendation.mutateAsync({
|
||||||
|
mensajeId: sug.messageId,
|
||||||
|
campoAfectado: sug.campoKey,
|
||||||
|
})
|
||||||
|
console.log(sug.campoKey)
|
||||||
|
|
||||||
|
onApplied(sug.campoKey)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al aplicar mejora:', error)
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
|
||||||
|
const renderContenido = (valor: any) => {
|
||||||
|
// Si no es un array, es texto simple
|
||||||
|
if (!Array.isArray(valor)) {
|
||||||
|
return <p className="italic">"{String(valor)}"</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
|
||||||
|
if (valor[0]?.hasOwnProperty('unidad')) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{valor.map((u: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
|
||||||
|
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{u.temas?.map((t: any, tidx: number) => (
|
||||||
|
<li
|
||||||
|
key={tidx}
|
||||||
|
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
|
||||||
|
>
|
||||||
|
<span className="leading-tight">• {t.nombre}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
|
||||||
|
<Clock size={10} /> {t.horasEstimadas}h
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
|
||||||
|
if (valor[0]?.hasOwnProperty('criterio')) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
<ListChecks size={12} /> Desglose de evaluación
|
||||||
|
</div>
|
||||||
|
{valor.map((c: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] leading-tight text-slate-700">
|
||||||
|
{c.criterio}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
||||||
|
{c.porcentaje}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Opcional: Suma total para verificar que de 100% */}
|
||||||
|
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
|
||||||
|
Total:{' '}
|
||||||
|
{valor.reduce(
|
||||||
|
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso por defecto (Array genérico)
|
||||||
|
return (
|
||||||
|
<pre className="text-[10px]">
|
||||||
|
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ESTADO APLICADO ---
|
||||||
|
if (sug.aceptada) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm font-bold text-slate-800">
|
||||||
|
{sug.campoNombre}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
|
||||||
|
<Check size={14} />
|
||||||
|
Aplicado
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
|
||||||
|
{renderContenido(sug.valorSugerido)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ESTADO PENDIENTE ---
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
|
||||||
|
{sug.campoNombre}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isApplying || !asignatura}
|
||||||
|
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
{isApplying ? (
|
||||||
|
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} className="mr-1.5" />
|
||||||
|
)}
|
||||||
|
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
|
||||||
|
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderContenido(sug.valorSugerido)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/components/ui/radio-group.tsx
Normal file
43
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -5,16 +5,24 @@ export function WizardResponsiveHeader({
|
|||||||
wizard,
|
wizard,
|
||||||
methods,
|
methods,
|
||||||
titleOverrides,
|
titleOverrides,
|
||||||
|
hiddenStepIds,
|
||||||
}: {
|
}: {
|
||||||
wizard: any
|
wizard: any
|
||||||
methods: any
|
methods: any
|
||||||
titleOverrides?: Record<string, string>
|
titleOverrides?: Record<string, string>
|
||||||
|
hiddenStepIds?: Array<string>
|
||||||
}) {
|
}) {
|
||||||
const idx = wizard.utils.getIndex(methods.current.id)
|
const hidden = new Set(hiddenStepIds ?? [])
|
||||||
const totalSteps = wizard.steps.length
|
const visibleSteps = (wizard.steps as Array<any>).filter(
|
||||||
const currentIndex = idx + 1
|
(s) => s && !hidden.has(s.id),
|
||||||
const hasNextStep = idx < totalSteps - 1
|
)
|
||||||
const nextStep = wizard.steps[currentIndex]
|
|
||||||
|
const idx = visibleSteps.findIndex((s) => s.id === methods.current.id)
|
||||||
|
const safeIdx = idx >= 0 ? idx : 0
|
||||||
|
const totalSteps = visibleSteps.length
|
||||||
|
const currentIndex = Math.min(safeIdx + 1, totalSteps)
|
||||||
|
const hasNextStep = safeIdx < totalSteps - 1
|
||||||
|
const nextStep = visibleSteps[safeIdx + 1]
|
||||||
|
|
||||||
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
|
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
|
||||||
|
|
||||||
@@ -45,10 +53,11 @@ export function WizardResponsiveHeader({
|
|||||||
|
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
||||||
{wizard.steps.map((step: any) => (
|
{visibleSteps.map((step: any, visibleIdx: number) => (
|
||||||
<wizard.Stepper.Step
|
<wizard.Stepper.Step
|
||||||
key={step.id}
|
key={step.id}
|
||||||
of={step.id}
|
of={step.id}
|
||||||
|
icon={visibleIdx + 1}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<wizard.Stepper.Title>
|
<wizard.Stepper.Title>
|
||||||
|
|||||||
@@ -359,3 +359,19 @@ export async function update_subject_conversation_status(
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function update_subject_conversation_name(
|
||||||
|
conversacionId: string,
|
||||||
|
nuevoNombre: string,
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversaciones_asignatura')
|
||||||
|
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function plan_asignaturas_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
|
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id',
|
||||||
)
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export async function subjects_bibliografia_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('bibliografia_asignatura')
|
.from('bibliografia_asignatura')
|
||||||
.select(
|
.select(
|
||||||
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
|
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
|
||||||
)
|
)
|
||||||
.eq('asignatura_id', subjectId)
|
.eq('asignatura_id', subjectId)
|
||||||
.order('tipo', { ascending: true })
|
.order('tipo', { ascending: true })
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getConversationBySubject,
|
getConversationBySubject,
|
||||||
ai_subject_chat_v2,
|
ai_subject_chat_v2,
|
||||||
create_subject_conversation,
|
create_subject_conversation,
|
||||||
|
update_subject_conversation_name,
|
||||||
} from '../api/ai.api'
|
} from '../api/ai.api'
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
|
|
||||||
@@ -320,3 +321,17 @@ export function useUpdateSubjectConversationStatus() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateSubjectConversationName() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: { id: string; nombre: string }) =>
|
||||||
|
update_subject_conversation_name(payload.id, payload.nombre),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
|
||||||
|
// También invalidamos los mensajes si el título se muestra en la cabecera
|
||||||
|
qc.invalidateQueries({ queryKey: ['subject-messages'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
|||||||
export type EdgeInvokeOptions = {
|
export type EdgeInvokeOptions = {
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EdgeFunctionError extends Error {
|
export class EdgeFunctionError extends Error {
|
||||||
@@ -26,6 +27,55 @@ export class EdgeFunctionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soporta base64 puro o data:...;base64,...
|
||||||
|
function decodeBase64ToUint8Array(input: string): Uint8Array {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
const base64 = trimmed.startsWith('data:')
|
||||||
|
? trimmed.slice(trimmed.indexOf(',') + 1)
|
||||||
|
: trimmed
|
||||||
|
|
||||||
|
const bin = atob(base64)
|
||||||
|
const bytes = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDataUrlPrefix(input: string): string {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed.startsWith('data:')) return trimmed
|
||||||
|
const commaIdx = trimmed.indexOf(',')
|
||||||
|
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeBase64(s: string): boolean {
|
||||||
|
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
|
||||||
|
|
||||||
|
// base64 típico: solo chars permitidos y longitud razonable
|
||||||
|
if (t.length < 64) return false
|
||||||
|
return /^[A-Za-z0-9+/]+$/.test(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithZip(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithPdf(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.length >= 5 &&
|
||||||
|
bytes[0] === 0x25 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x44 &&
|
||||||
|
bytes[3] === 0x46 &&
|
||||||
|
bytes[4] === 0x2d
|
||||||
|
) // "%PDF-"
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryStringToUint8Array(input: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(input.length)
|
||||||
|
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
export async function invokeEdge<TOut>(
|
export async function invokeEdge<TOut>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
body?:
|
body?:
|
||||||
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
|
|||||||
): Promise<TOut> {
|
): Promise<TOut> {
|
||||||
const supabase = client ?? supabaseBrowser()
|
const supabase = client ?? supabaseBrowser()
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
|
||||||
|
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
|
||||||
|
const invoke: any = (supabase.functions as any).invoke.bind(
|
||||||
|
supabase.functions,
|
||||||
|
)
|
||||||
|
const { data, error } = await invoke(functionName, {
|
||||||
body,
|
body,
|
||||||
method: opts.method ?? 'POST',
|
method: opts.method ?? 'POST',
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
|
responseType: opts.responseType,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
|
|||||||
throw new EdgeFunctionError(message, functionName, status, details)
|
throw new EdgeFunctionError(message, functionName, status, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.responseType === 'blob') {
|
||||||
|
const anyData: unknown = data
|
||||||
|
|
||||||
|
if (anyData instanceof Blob) {
|
||||||
|
return anyData as TOut
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new EdgeFunctionError(
|
||||||
|
'La Edge Function no devolvió un binario (Blob) válido.',
|
||||||
|
functionName,
|
||||||
|
undefined,
|
||||||
|
{ receivedType: typeof anyData, received: anyData },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return data as TOut
|
return data as TOut
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
78
src/lib/asignaturaColumnParsers.ts
Normal file
78
src/lib/asignaturaColumnParsers.ts
Normal 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,
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileJson,
|
FileJson,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { usePlan } from '@/data'
|
||||||
import { fetchPlanPdf } from '@/data/api/document.api'
|
import { fetchPlanPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
||||||
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
||||||
|
const { data: plan } = usePlan(planId)
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
|
const pdfUrlRef = useRef<string | null>(null)
|
||||||
|
const isMountedRef = useRef<boolean>(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
if (isMountedRef.current) setIsLoading(true)
|
||||||
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
|
const pdfBlob = await fetchPlanPdf({
|
||||||
|
plan_estudio_id: planId,
|
||||||
|
convertTo: 'pdf',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
// Limpiar URL anterior si existe para evitar fugas de memoria
|
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
pdfUrlRef.current = url
|
||||||
|
|
||||||
setPdfUrl(url)
|
setPdfUrl(url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando preview:', error)
|
console.error('Error cargando preview:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
if (isMountedRef.current) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [planId])
|
}, [planId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
return () => {
|
return () => {
|
||||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
isMountedRef.current = false
|
||||||
|
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
@@ -51,12 +63,13 @@ function RouteComponent() {
|
|||||||
try {
|
try {
|
||||||
const pdfBlob = await fetchPlanPdf({
|
const pdfBlob = await fetchPlanPdf({
|
||||||
plan_estudio_id: planId,
|
plan_estudio_id: planId,
|
||||||
|
convertTo: 'pdf',
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = 'plan_estudios.pdf'
|
link.download = `${planFileBaseName}.pdf`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
|
||||||
@@ -67,6 +80,27 @@ function RouteComponent() {
|
|||||||
alert('No se pudo generar el PDF')
|
alert('No se pudo generar el PDF')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadWord = async () => {
|
||||||
|
try {
|
||||||
|
const docBlob = await fetchPlanPdf({
|
||||||
|
plan_estudio_id: planId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(docBlob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${planFileBaseName}.docx`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
link.remove()
|
||||||
|
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
alert('No se pudo generar el Word')
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
@@ -88,12 +122,17 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<RefreshCcw size={16} /> Regenerar
|
<RefreshCcw size={16} /> Regenerar
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
|
||||||
<Download size={16} /> Descargar Word
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
|
onClick={handleDownloadWord}
|
||||||
|
>
|
||||||
|
<Download size={16} /> Descargar Word
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
onClick={handleDownloadPdf}
|
onClick={handleDownloadPdf}
|
||||||
>
|
>
|
||||||
<Download size={16} /> Descargar PDF
|
<Download size={16} /> Descargar PDF
|
||||||
@@ -139,7 +178,7 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
|
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||||
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
||||||
@@ -149,7 +188,7 @@ function RouteComponent() {
|
|||||||
/* 3. VISOR DE PDF REAL */
|
/* 3. VISOR DE PDF REAL */
|
||||||
<iframe
|
<iframe
|
||||||
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
||||||
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
|
className="h-250 w-full max-w-250 border-none shadow-2xl"
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -163,6 +202,24 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFileBaseName(input: string): string {
|
||||||
|
const text = String(input)
|
||||||
|
const withoutControlChars = Array.from(text)
|
||||||
|
.filter((ch) => {
|
||||||
|
const code = ch.charCodeAt(0)
|
||||||
|
return code >= 32 && code !== 127
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const cleaned = withoutControlChars
|
||||||
|
.replace(/[<>:"/\\|?*]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
|
||||||
|
return (cleaned || 'documento').slice(0, 150)
|
||||||
|
}
|
||||||
|
|
||||||
// Componente pequeño para las tarjetas de estado superior
|
// Componente pequeño para las tarjetas de estado superior
|
||||||
function StatusCard({
|
function StatusCard({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
X,
|
X,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Archive,
|
Archive,
|
||||||
RotateCcw,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
@@ -22,10 +23,17 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
|
|||||||
|
|
||||||
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
|
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
|
||||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
useAIPlanChat,
|
useAIPlanChat,
|
||||||
useConversationByPlan,
|
useConversationByPlan,
|
||||||
@@ -121,6 +129,7 @@ function RouteComponent() {
|
|||||||
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isInitialLoad = useRef(true)
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||||
const editableRef = useRef<HTMLSpanElement>(null)
|
const editableRef = useRef<HTMLSpanElement>(null)
|
||||||
@@ -197,20 +206,20 @@ function RouteComponent() {
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
}, [mensajesDelChat, activeChatId, availableFields])
|
}, [mensajesDelChat, activeChatId, availableFields])
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = (behavior = 'smooth') => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
// Buscamos el viewport interno del ScrollArea de Radix
|
|
||||||
const scrollContainer = scrollRef.current.querySelector(
|
const scrollContainer = scrollRef.current.querySelector(
|
||||||
'[data-radix-scroll-area-viewport]',
|
'[data-radix-scroll-area-viewport]',
|
||||||
)
|
)
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollContainer.scrollHeight,
|
top: scrollContainer.scrollHeight,
|
||||||
behavior: 'smooth',
|
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeChats, archivedChats } = useMemo(() => {
|
const { activeChats, archivedChats } = useMemo(() => {
|
||||||
const allChats = lastConversation || []
|
const allChats = lastConversation || []
|
||||||
return {
|
return {
|
||||||
@@ -222,22 +231,22 @@ function RouteComponent() {
|
|||||||
}, [lastConversation])
|
}, [lastConversation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(mensajesDelChat)
|
if (chatMessages.length > 0) {
|
||||||
|
if (isInitialLoad.current) {
|
||||||
scrollToBottom()
|
// Si es el primer render con mensajes, vamos al final al instante
|
||||||
}, [chatMessages, isLoading])
|
scrollToBottom('instant')
|
||||||
|
isInitialLoad.current = false
|
||||||
useEffect(() => {
|
} else {
|
||||||
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
|
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
|
||||||
const camposActualizados = selectedFields.filter((field) =>
|
scrollToBottom('smooth')
|
||||||
input.includes(field.label),
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
|
|
||||||
if (camposActualizados.length !== selectedFields.length) {
|
|
||||||
setSelectedFields(camposActualizados)
|
|
||||||
}
|
}
|
||||||
}, [input, selectedFields])
|
}, [chatMessages])
|
||||||
|
|
||||||
|
// 2. Resetear el flag cuando cambies de chat activo
|
||||||
|
useEffect(() => {
|
||||||
|
isInitialLoad.current = true
|
||||||
|
}, [activeChatId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoadingConv || isSending) return
|
if (isLoadingConv || isSending) return
|
||||||
@@ -297,7 +306,7 @@ function RouteComponent() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
setInput('')
|
setInput('')
|
||||||
setSelectedFields([])
|
// setSelectedFields([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveChat = (e: React.MouseEvent, id: string) => {
|
const archiveChat = (e: React.MouseEvent, id: string) => {
|
||||||
@@ -405,7 +414,7 @@ function RouteComponent() {
|
|||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
setOptimisticMessage(finalContent)
|
setOptimisticMessage(finalContent)
|
||||||
setInput('')
|
setInput('')
|
||||||
setSelectedFields([])
|
// setSelectedFields([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -501,82 +510,114 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 pr-2">
|
||||||
|
{' '}
|
||||||
|
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
|
||||||
{!showArchived ? (
|
{!showArchived ? (
|
||||||
activeChats.map((chat) => (
|
activeChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => setActiveChatId(chat.id)}
|
onClick={() => setActiveChatId(chat.id)}
|
||||||
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${
|
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
|
||||||
activeChatId === chat.id
|
activeChatId === chat.id
|
||||||
? 'bg-slate-100 font-medium text-slate-900'
|
? 'bg-slate-100 font-medium text-slate-900'
|
||||||
: 'text-slate-600 hover:bg-slate-50'
|
: 'text-slate-600 hover:bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText size={16} className="shrink-0 opacity-40" />
|
{/* LADO IZQUIERDO: Icono + Texto */}
|
||||||
|
<div
|
||||||
<span
|
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
|
||||||
ref={editingChatId === chat.id ? editableRef : null}
|
style={{
|
||||||
contentEditable={editingChatId === chat.id}
|
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
|
||||||
suppressContentEditableWarning={true}
|
// donde aparecen los botones
|
||||||
className={`truncate pr-14 transition-all outline-none ${
|
maskImage:
|
||||||
editingChatId === chat.id
|
'linear-gradient(to right, black 70%, transparent 95%)',
|
||||||
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
|
WebkitMaskImage:
|
||||||
: 'cursor-pointer'
|
'linear-gradient(to right, black 70%, transparent 95%)',
|
||||||
}`}
|
|
||||||
onDoubleClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setEditingChatId(chat.id)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
const newTitle = e.currentTarget.textContent || ''
|
|
||||||
updateTitleMutation(
|
|
||||||
{ id: chat.id, nombre: newTitle },
|
|
||||||
{
|
|
||||||
onSuccess: () => setEditingChatId(null),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setEditingChatId(null)
|
|
||||||
|
|
||||||
e.currentTarget.textContent = chat.nombre || ''
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (editingChatId === chat.id) {
|
|
||||||
const newTitle = e.currentTarget.textContent || ''
|
|
||||||
if (newTitle !== chat.nombre) {
|
|
||||||
updateTitleMutation({ id: chat.id, nombre: newTitle })
|
|
||||||
}
|
|
||||||
setEditingChatId(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (editingChatId === chat.id) e.stopPropagation()
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
|
{/* pr-12 reserva espacio para los botones absolutos */}
|
||||||
</span>
|
<FileText size={16} className="shrink-0 opacity-40" />
|
||||||
|
<TooltipProvider delayDuration={400}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild className="min-w-0 flex-1">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
ref={
|
||||||
|
editingChatId === chat.id ? editableRef : null
|
||||||
|
}
|
||||||
|
contentEditable={editingChatId === chat.id}
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
className={`block truncate outline-none ${
|
||||||
|
editingChatId === chat.id
|
||||||
|
? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
}`}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingChatId(chat.id)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingChatId(null)
|
||||||
|
e.currentTarget.textContent =
|
||||||
|
chat.nombre || ''
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (editingChatId === chat.id) {
|
||||||
|
const newTitle =
|
||||||
|
e.currentTarget.textContent?.trim() || ''
|
||||||
|
if (newTitle && newTitle !== chat.nombre) {
|
||||||
|
updateTitleMutation({
|
||||||
|
id: chat.id,
|
||||||
|
nombre: newTitle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setEditingChatId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chat.nombre ||
|
||||||
|
`Chat ${chat.creado_en.split('T')[0]}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{editingChatId !== chat.id && (
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
className="max-w-[280px] break-all"
|
||||||
|
>
|
||||||
|
{chat.nombre || 'Conversación'}
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ACCIONES */}
|
{/* LADO DERECHO: Acciones ABSOLUTAS */}
|
||||||
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
<div
|
||||||
|
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
|
||||||
|
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditingChatId(chat.id)
|
setEditingChatId(chat.id)
|
||||||
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
|
|
||||||
setTimeout(() => editableRef.current?.focus(), 50)
|
setTimeout(() => editableRef.current?.focus(), 50)
|
||||||
}}
|
}}
|
||||||
className="p-1 text-slate-400 hover:text-teal-600"
|
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
|
||||||
>
|
>
|
||||||
<Send size={12} className="rotate-45" />
|
<Send size={12} className="rotate-45" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => archiveChat(e, chat.id)}
|
onClick={(e) => archiveChat(e, chat.id)}
|
||||||
className="p-1 text-slate-400 hover:text-amber-600"
|
className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
|
||||||
>
|
>
|
||||||
<Archive size={14} />
|
<Archive size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -584,24 +625,26 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
/* ... Resto del código de archivados (sin cambios) ... */
|
/* Sección de archivados (Simplificada para mantener consistencia) */
|
||||||
<div className="animate-in fade-in slide-in-from-left-2">
|
<div className="animate-in fade-in slide-in-from-left-2 px-1">
|
||||||
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
Archivados
|
Archivados
|
||||||
</p>
|
</p>
|
||||||
{archivedChats.map((chat) => (
|
{archivedChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
|
className="group relative mb-1 flex w-full items-center overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
|
||||||
>
|
>
|
||||||
<Archive size={14} className="shrink-0 opacity-30" />
|
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
|
||||||
<span className="truncate pr-8">
|
<Archive size={14} className="shrink-0 opacity-30" />
|
||||||
{chat.nombre ||
|
<span className="block truncate">
|
||||||
`Archivado ${chat.creado_en.split('T')[0]}`}
|
{chat.nombre ||
|
||||||
</span>
|
`Archivado ${chat.creado_en.split('T')[0]}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => unarchiveChat(e, chat.id)}
|
onClick={(e) => unarchiveChat(e, chat.id)}
|
||||||
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
|
className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -721,33 +764,24 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{(isSending || isSyncing) &&
|
|
||||||
optimisticMessage &&
|
|
||||||
!chatMessages.some(
|
|
||||||
(m) => m.content === optimisticMessage,
|
|
||||||
) && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
|
|
||||||
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
|
|
||||||
{optimisticMessage}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isSending || isSyncing) && (
|
{(isSending || isSyncing) && (
|
||||||
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
|
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
||||||
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<AvatarFallback>
|
||||||
|
<Sparkles size={16} className="animate-pulse" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
|
|
||||||
{isSyncing
|
|
||||||
? 'Actualizando historial...'
|
|
||||||
: 'Esperando respuesta...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[10px] font-medium text-slate-400 italic">
|
||||||
|
La IA está analizando tu solicitud...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
GripVertical,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -46,16 +45,33 @@ import {
|
|||||||
useUpdateAsignatura,
|
useUpdateAsignatura,
|
||||||
useUpdateLinea,
|
useUpdateLinea,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
||||||
|
const palette = [
|
||||||
|
'#4F46E5', // índigo
|
||||||
|
'#7C3AED', // violeta
|
||||||
|
'#EA580C', // naranja
|
||||||
|
'#059669', // esmeralda
|
||||||
|
'#DC2626', // rojo
|
||||||
|
'#0891B2', // cyan
|
||||||
|
'#CA8A04', // ámbar
|
||||||
|
'#C026D3', // fucsia
|
||||||
|
]
|
||||||
|
|
||||||
const mapLineasToLineaCurricular = (
|
const mapLineasToLineaCurricular = (
|
||||||
lineasApi: Array<any> = [],
|
lineasApi: Array<any> = [],
|
||||||
): Array<LineaCurricular> => {
|
): Array<LineaCurricular> => {
|
||||||
return lineasApi.map((linea) => ({
|
return lineasApi.map((linea, index) => ({
|
||||||
id: linea.id,
|
id: linea.id,
|
||||||
nombre: linea.nombre,
|
nombre: linea.nombre,
|
||||||
orden: linea.orden ?? 0,
|
orden: linea.orden ?? 0,
|
||||||
color: '#1976d2',
|
color: palette[index % palette.length],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +92,7 @@ const mapAsignaturasToAsignaturas = (
|
|||||||
// Mapeo directo de los nuevos campos de la API
|
// Mapeo directo de los nuevos campos de la API
|
||||||
hd: asig.horas_academicas ?? 0,
|
hd: asig.horas_academicas ?? 0,
|
||||||
hi: asig.horas_independientes ?? 0,
|
hi: asig.horas_independientes ?? 0,
|
||||||
prerrequisitos: [],
|
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -121,52 +137,216 @@ function StatItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
const estadoConfig: Record<
|
||||||
|
Asignatura['estado'],
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
dot: string
|
||||||
|
soft: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
borrador: {
|
||||||
|
label: 'Borrador',
|
||||||
|
dot: 'bg-slate-500',
|
||||||
|
soft: 'bg-slate-100 text-slate-700',
|
||||||
|
icon: Icons.FileText,
|
||||||
|
},
|
||||||
|
revisada: {
|
||||||
|
label: 'Revisada',
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
soft: 'bg-amber-100 text-amber-700',
|
||||||
|
icon: Icons.ScanSearch,
|
||||||
|
},
|
||||||
|
aprobada: {
|
||||||
|
label: 'Aprobada',
|
||||||
|
dot: 'bg-emerald-500',
|
||||||
|
soft: 'bg-emerald-100 text-emerald-700',
|
||||||
|
icon: Icons.BadgeCheck,
|
||||||
|
},
|
||||||
|
generando: {
|
||||||
|
label: 'Generando',
|
||||||
|
dot: 'bg-sky-500',
|
||||||
|
soft: 'bg-sky-100 text-sky-700',
|
||||||
|
icon: Icons.LoaderCircle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number) {
|
||||||
|
const clean = hex.replace('#', '')
|
||||||
|
const bigint = parseInt(clean, 16)
|
||||||
|
const r = (bigint >> 16) & 255
|
||||||
|
const g = (bigint >> 8) & 255
|
||||||
|
const b = bigint & 255
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
function AsignaturaCardItem({
|
function AsignaturaCardItem({
|
||||||
asignatura,
|
asignatura,
|
||||||
|
lineaColor,
|
||||||
|
lineaNombre,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
isDragging,
|
isDragging,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
asignatura: Asignatura
|
asignatura: Asignatura
|
||||||
|
lineaColor: string
|
||||||
|
lineaNombre?: string
|
||||||
onDragStart: (e: React.DragEvent, id: string) => void
|
onDragStart: (e: React.DragEvent, id: string) => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
|
||||||
|
const EstadoIcon = estado.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<TooltipProvider delayDuration={150}>
|
||||||
draggable
|
<Tooltip>
|
||||||
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
<TooltipTrigger asChild>
|
||||||
onClick={onClick}
|
<button
|
||||||
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
|
draggable
|
||||||
isDragging
|
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
||||||
? 'scale-95 opacity-40'
|
onClick={onClick}
|
||||||
: 'hover:border-teal-400 hover:shadow-md'
|
className={[
|
||||||
}`}
|
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
|
||||||
>
|
'transition-all duration-300 ease-out',
|
||||||
<div className="mb-1 flex items-start justify-between">
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||||
<span className="font-mono text-[10px] font-bold text-slate-400">
|
'active:cursor-grabbing cursor-grab',
|
||||||
{asignatura.clave}
|
isDragging
|
||||||
</span>
|
? 'scale-[0.985] opacity-45 shadow-none'
|
||||||
<Badge
|
: 'hover:-translate-y-1 hover:shadow-lg',
|
||||||
variant="outline"
|
].join(' ')}
|
||||||
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
|
style={{
|
||||||
>
|
borderColor: hexToRgba(lineaColor, 0.18),
|
||||||
{asignatura.estado}
|
background: `
|
||||||
</Badge>
|
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
|
||||||
</div>
|
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
|
||||||
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
|
`,
|
||||||
{asignatura.nombre}
|
}}
|
||||||
</p>
|
title={asignatura.nombre}
|
||||||
<div className="mt-2 flex items-center justify-between">
|
>
|
||||||
<span className="text-[10px] text-slate-500">
|
{/* franja */}
|
||||||
{asignatura.creditos} CR • HD:{asignatura.hd} • HI:{asignatura.hi}
|
<div
|
||||||
</span>
|
className="absolute inset-x-0 top-0 h-2"
|
||||||
<GripVertical
|
style={{ backgroundColor: lineaColor }}
|
||||||
size={12}
|
/>
|
||||||
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
|
|
||||||
/>
|
{/* glow decorativo */}
|
||||||
</div>
|
<div
|
||||||
</button>
|
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl transition-transform duration-500 group-hover:scale-110"
|
||||||
|
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex h-full flex-col p-4">
|
||||||
|
{/* top */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div
|
||||||
|
className="inline-flex h-8 max-w-[200px] items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
|
||||||
|
style={{
|
||||||
|
borderColor: hexToRgba(lineaColor, 0.2),
|
||||||
|
backgroundColor: hexToRgba(lineaColor, 0.1),
|
||||||
|
color: lineaColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
|
||||||
|
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
|
||||||
|
<EstadoIcon
|
||||||
|
className={[
|
||||||
|
'h-3.5 w-3.5 text-foreground/65',
|
||||||
|
asignatura.estado === 'generando' ? 'animate-spin' : '',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'absolute right-2 flex translate-x-6 items-center gap-1.5 opacity-0 transition-all duration-300',
|
||||||
|
'group-hover:translate-x-0 group-hover:opacity-100'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||||
|
{estado.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* titulo */}
|
||||||
|
<div className="mt-4 min-h-[72px]">
|
||||||
|
<h3
|
||||||
|
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{asignatura.nombre}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* bottom */}
|
||||||
|
<div className="mt-auto grid grid-cols-3 gap-2">
|
||||||
|
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icons.Award className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
CR
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-foreground">
|
||||||
|
{asignatura.creditos}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icons.Clock3 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
HD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-foreground">
|
||||||
|
{asignatura.hd}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icons.BookOpenText className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
HI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-foreground">
|
||||||
|
{asignatura.hi}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* drag affordance */}
|
||||||
|
<div className="pointer-events-none absolute right-3 bottom-3 rounded-full bg-background/70 p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
|
||||||
|
<Icons.GripVertical className="h-4 w-4 text-muted-foreground/55" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<div className="text-xs">
|
||||||
|
{lineaNombre ? `${lineaNombre} · ` : ''}
|
||||||
|
{asignatura.nombre}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +516,7 @@ function MapaCurricularPage() {
|
|||||||
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
||||||
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
||||||
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
||||||
|
prerrequisito_asignatura_id?: string | null
|
||||||
}
|
}
|
||||||
const patch: Partial<AsignaturaPatch> = {
|
const patch: Partial<AsignaturaPatch> = {
|
||||||
nombre: editingData.nombre,
|
nombre: editingData.nombre,
|
||||||
@@ -345,6 +526,7 @@ function MapaCurricularPage() {
|
|||||||
horas_independientes: editingData.hi,
|
horas_independientes: editingData.hi,
|
||||||
numero_ciclo: editingData.ciclo,
|
numero_ciclo: editingData.ciclo,
|
||||||
linea_plan_id: editingData.lineaCurricularId,
|
linea_plan_id: editingData.lineaCurricularId,
|
||||||
|
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
|
||||||
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +672,7 @@ function MapaCurricularPage() {
|
|||||||
e: React.FocusEvent<HTMLSpanElement>,
|
e: React.FocusEvent<HTMLSpanElement>,
|
||||||
id: string,
|
id: string,
|
||||||
) => {
|
) => {
|
||||||
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
|
const nuevoNombre = e.currentTarget.textContent.trim() || ''
|
||||||
|
|
||||||
// Buscamos la línea original para comparar
|
// Buscamos la línea original para comparar
|
||||||
const lineaOriginal = lineas.find((l) => l.id === id)
|
const lineaOriginal = lineas.find((l) => l.id === id)
|
||||||
@@ -522,15 +704,15 @@ function MapaCurricularPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
|
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
|
||||||
0 && (
|
0 && (
|
||||||
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
|
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
|
||||||
<AlertTriangle size={14} className="mr-1" />{' '}
|
<AlertTriangle size={14} className="mr-1" />{' '}
|
||||||
{
|
{
|
||||||
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
|
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
|
||||||
.length
|
.length
|
||||||
}{' '}
|
}{' '}
|
||||||
sin asignar
|
sin asignar
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
||||||
@@ -616,9 +798,8 @@ function MapaCurricularPage() {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={linea.id}>
|
<Fragment key={linea.id}>
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
|
||||||
lineColors[idx % lineColors.length]
|
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
||||||
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<span
|
<span
|
||||||
@@ -633,11 +814,10 @@ function MapaCurricularPage() {
|
|||||||
setTempNombreLinea(linea.nombre)
|
setTempNombreLinea(linea.nombre)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full text-xs font-bold break-words outline-none ${
|
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
|
||||||
editingLineaId === linea.id
|
? 'cursor-text border-b border-teal-500/50 pb-1'
|
||||||
? 'cursor-text border-b border-teal-500/50 pb-1'
|
: 'cursor-pointer'
|
||||||
: 'cursor-pointer'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{linea.nombre}
|
{linea.nombre}
|
||||||
</span>
|
</span>
|
||||||
@@ -675,6 +855,8 @@ function MapaCurricularPage() {
|
|||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
|
lineaColor={linea.color || '#1976d2'}
|
||||||
|
lineaNombre={linea.nombre}
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -725,45 +907,81 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Asignaturas Sin Asignar */}
|
{/* Asignaturas Sin Asignar */}
|
||||||
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2 text-slate-600">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-bold tracking-wider uppercase">
|
<div className="flex items-center gap-2">
|
||||||
Bandeja de Entrada / Asignaturas sin asignar
|
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
</h3>
|
<Icons.Inbox className="h-4.5 w-4.5" />
|
||||||
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-bold tracking-wide text-foreground uppercase">
|
||||||
|
Bandeja de entrada
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-[11px] font-semibold text-muted-foreground">
|
||||||
|
{unassignedAsignaturas.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
|
Asignaturas sin ciclo o línea curricular
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-dashed border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
<Icons.MoveDown className="h-3.5 w-3.5" />
|
||||||
|
<span>Arrastra aquí para desasignar</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
Arrastra una asignatura aquí para quitarla del mapa
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
|
|
||||||
draggedAsignatura
|
|
||||||
? 'border-teal-300 bg-teal-50/50'
|
|
||||||
: 'border-slate-200 bg-white/50'
|
|
||||||
}`}
|
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
|
onDrop={(e) => handleDrop(e, null, null)}
|
||||||
|
className={[
|
||||||
|
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
|
||||||
|
'min-h-[220px]',
|
||||||
|
draggedAsignatura
|
||||||
|
? 'border-primary/35 bg-primary/6 shadow-inner'
|
||||||
|
: 'border-border bg-muted/20',
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{unassignedAsignaturas.map((m) => (
|
{unassignedAsignaturas.length > 0 ? (
|
||||||
<div key={m.id} className="w-[200px]">
|
<div className="flex flex-wrap gap-4">
|
||||||
<AsignaturaCardItem
|
{unassignedAsignaturas.map((m) => (
|
||||||
asignatura={m}
|
<div key={m.id} className="w-[272px] shrink-0">
|
||||||
isDragging={draggedAsignatura === m.id}
|
<AsignaturaCardItem
|
||||||
onDragStart={handleDragStart}
|
asignatura={m}
|
||||||
onClick={() => {
|
lineaColor="#94A3B8"
|
||||||
setEditingData(m) // Cargamos los datos en el estado de edición
|
lineaNombre="Sin asignar"
|
||||||
setIsEditModalOpen(true)
|
isDragging={draggedAsignatura === m.id}
|
||||||
}}
|
onDragStart={handleDragStart}
|
||||||
/>
|
onClick={() => {
|
||||||
|
setEditingData(m)
|
||||||
|
setIsEditModalOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
{unassignedAsignaturas.length === 0 && (
|
<div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
|
||||||
<div className="flex w-full items-center justify-center text-sm text-slate-400">
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
No hay asignaturas pendientes. Arrastra una asignatura aquí para
|
<Icons.CheckCheck className="h-5 w-5" />
|
||||||
desasignarla.
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
No hay asignaturas pendientes
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||||
|
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
|
||||||
|
ciclo y línea curricular.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -935,65 +1153,55 @@ function MapaCurricularPage() {
|
|||||||
{/* Fila 4: Seriación (Prerrequisitos) */}
|
{/* Fila 4: Seriación (Prerrequisitos) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||||
Seriación (Prerrequisitos)
|
Seriación (Prerrequisito)
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={seriacionValue}
|
// Cambiamos a manejo de valor único basado en el ID de la columna
|
||||||
|
value={editingData.prerrequisito_asignatura_id || undefined}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
if (val === 'none') {
|
console.log(editingData)
|
||||||
setSeriacionValue('')
|
|
||||||
return
|
setEditingData({
|
||||||
}
|
...editingData,
|
||||||
if (!editingData.prerrequisitos.includes(val)) {
|
prerrequisito_asignatura_id: val === 'none' ? null : val,
|
||||||
setEditingData({
|
})
|
||||||
...editingData,
|
|
||||||
prerrequisitos: [...editingData.prerrequisitos, val],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setSeriacionValue('')
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full bg-white">
|
||||||
<SelectValue placeholder="Seleccionar asignatura..." />
|
<SelectValue placeholder="Seleccionar asignatura..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
||||||
|
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter((m) => m.id !== editingData.id)
|
.filter((asig) => {
|
||||||
.map((m) => (
|
// 1. No es la misma materia
|
||||||
<SelectItem key={m.id} value={m.id}>
|
const noEsMisma = asig.id !== editingData.id
|
||||||
{m.nombre} ({m.clave})
|
// 2. El ciclo debe ser estrictamente MENOR
|
||||||
|
const esCicloMenor =
|
||||||
|
asig.ciclo !== null &&
|
||||||
|
editingData.ciclo !== null &&
|
||||||
|
asig.ciclo < editingData.ciclo
|
||||||
|
|
||||||
|
return noEsMisma && esCicloMenor
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.ciclo || 0) - (b.ciclo || 0) ||
|
||||||
|
a.nombre.localeCompare(b.nombre),
|
||||||
|
)
|
||||||
|
.map((asig) => (
|
||||||
|
<SelectItem key={asig.id} value={asig.id}>
|
||||||
|
<span className="font-bold text-teal-600">
|
||||||
|
[C{asig.ciclo}]
|
||||||
|
</span>{' '}
|
||||||
|
{asig.nombre}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Visualización de los prerrequisitos seleccionados */}
|
{/* Visualización del Prerrequisito con el Nombre */}
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{editingData.prerrequisitos.map((pre) => (
|
|
||||||
<Badge
|
|
||||||
key={pre}
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-slate-100 text-slate-600"
|
|
||||||
>
|
|
||||||
{pre}
|
|
||||||
<button
|
|
||||||
className="ml-1 hover:text-red-500"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingData({
|
|
||||||
...editingData,
|
|
||||||
prerrequisitos: editingData.prerrequisitos.filter(
|
|
||||||
(p) => p !== pre,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fila 5: Tipo */}
|
{/* Fila 5: Tipo */}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,30 +166,20 @@ function AsignaturaLayout() {
|
|||||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||||
/>
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
|
{
|
||||||
|
// console.log(headerData),
|
||||||
|
|
||||||
|
console.log(asignaturaApi.planes_estudio?.nombre)
|
||||||
|
}
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<GraduationCap className="h-4 w-4 shrink-0" />
|
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||||
|
Pertenece al plan:{' '}
|
||||||
<span className="text-blue-100">
|
<span className="text-blue-100">
|
||||||
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
|
||||||
.nombre || ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="text-blue-100">
|
|
||||||
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
|
||||||
.nombre ?? ''}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-blue-300">
|
|
||||||
Pertenece al plan:{' '}
|
|
||||||
<span className="cursor-pointer underline">
|
|
||||||
{asignaturaApi.planes_estudio?.nombre}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-2 text-right">
|
<div className="flex flex-col items-end gap-2 text-right">
|
||||||
|
|||||||
151
src/styles.css
151
src/styles.css
@@ -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;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface Asignatura {
|
|||||||
orden?: number
|
orden?: number
|
||||||
hd: number // <--- Añadir
|
hd: number // <--- Añadir
|
||||||
hi: number // <--- Añadir
|
hi: number // <--- Añadir
|
||||||
prerrequisitos: Array<string>
|
prerrequisito_asignatura_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
14
staticwebapp.config.json
Normal file
14
staticwebapp.config.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"navigationFallback": {
|
||||||
|
"rewrite": "/index.html",
|
||||||
|
"exclude": [
|
||||||
|
"/assets/*",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js",
|
||||||
|
"/*.ico",
|
||||||
|
"/*.png",
|
||||||
|
"/*.jpg",
|
||||||
|
"/*.svg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user