1 Commits

Author SHA1 Message Date
1eaf5cc4e4 wip 2026-03-10 14:21:29 -06:00
36 changed files with 1115 additions and 3160 deletions

View File

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

View File

@@ -4,7 +4,6 @@
"": {
"name": "acad-ia-2",
"dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -139,18 +138,6 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -263,8 +250,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],

View File

@@ -17,7 +17,6 @@
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
},
"dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",

View File

@@ -1,5 +1,3 @@
import { DragDropProvider } from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import { useParams } from '@tanstack/react-router'
import {
Plus,
@@ -13,7 +11,7 @@ import {
import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
import type { FocusEvent, KeyboardEvent } from 'react'
import {
AlertDialog,
@@ -52,95 +50,6 @@ export interface UnidadTematica {
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> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -191,18 +100,20 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (Array.isArray(value.temas)) {
temas = value.temas
.map(mapTemaValue)
.filter((x): x is ContenidoTemaApi => x !== null)
.filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
}
return {
...value,
unidad,
titulo,
temas,
}
return { unidad, titulo, temas }
}
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') {
try {
return mapContenidoTematicoFromDb(JSON.parse(value))
@@ -281,16 +192,7 @@ export function ContenidoTematico() {
const [temaDraftHoras, setTemaDraftHoras] = useState('')
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>) => {
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
didInitExpandedUnitsRef.current = true
const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({
subjectId: asignaturaId,
@@ -344,17 +246,10 @@ 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 = () => {
if (!editingTema) return
const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u
@@ -408,110 +303,28 @@ export function ContenidoTematico() {
data ? data.contenido_tematico : undefined,
)
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
const currentPayload = JSON.stringify(
serializeUnidadesToApi(unidadesRef.current),
)
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
const incomingPayload = JSON.stringify(
contenido.map((u, idx) => ({
unidad: u.unidad || idx + 1,
titulo: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? 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,
}
})
: [],
}
})
const transformed = contenido.map((u, idx) => ({
id: `u-${u.unidad || idx + 1}`,
numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
horasEstimadas: t?.horasEstimadas || 0,
}))
: [],
}))
setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)),
)
// 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
if (filtered.size > 0) return filtered
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
})
}, [data])
@@ -540,7 +353,7 @@ export function ContenidoTematico() {
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce(
(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,
)
@@ -551,22 +364,16 @@ export function ContenidoTematico() {
setExpandedUnits(newExpanded)
}
const insertUnidadAt = (insertIndex: number) => {
const newId = createClientId('u')
const addUnidad = () => {
const newNumero = unidades.length + 1
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = {
id: newId,
nombre: 'Nueva Unidad',
numero: 0,
numero: newNumero,
temas: [],
}
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
const next = renumberUnidades([
...unidades.slice(0, clampedIndex),
newUnidad,
...unidades.slice(clampedIndex),
])
const next = [...unidades, newUnidad]
setUnidades(next)
setExpandedUnits((prev) => {
const n = new Set(prev)
@@ -575,40 +382,10 @@ export function ContenidoTematico() {
})
setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId)
setUnitDraftNombre(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 ---
@@ -674,182 +451,158 @@ export function ContenidoTematico() {
</div>
</div>
<DragDropProvider onDragEnd={handleReorderEnd}>
<div className="space-y-4">
{unidades.map((unidad, index) => (
<SortableUnidad
key={unidad.id}
id={unidad.id}
index={index}
registerContainer={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
>
{({ handleRef }) => (
<>
{index === 0 && (
<InsertUnidadOverlay
position="top"
onInsert={() => insertUnidadAt(index)}
/>
)}
<InsertUnidadOverlay
position="bottom"
onInsert={() => insertUnidadAt(index + 1)}
/>
<div className="space-y-4">
{unidades.map((unidad) => (
<div
key={unidad.id}
ref={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
>
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible
open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)}
>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-0">
{expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible
open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)}
>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3">
<span
ref={handleRef as any}
className="inline-flex cursor-grab touch-none items-center text-slate-300"
aria-label="Reordenar unidad"
>
<GripVertical className="h-4 w-4" />
</span>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto cursor-pointer p-0"
>
{expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
{editingUnit === unidad.id ? (
<Input
ref={unitTitleInputRef}
value={unitDraftNombre}
onChange={(e) => setUnitDraftNombre(e.target.value)}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
commitEditUnit()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white"
/>
) : (
<CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
{editingUnit === unidad.id ? (
<Input
ref={unitTitleInputRef}
value={unitDraftNombre}
onChange={(e) =>
setUnitDraftNombre(e.target.value)
}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
commitEditUnit()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white"
/>
) : (
<CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
<div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0),
0,
)}
h
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 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 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="ml-auto flex items-center gap-3">
<span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0),
0,
)}
h
</span>
<Button
variant="ghost"
size="icon"
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>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
</div>
<DeleteConfirmDialog
dialog={deleteDialog}
@@ -914,9 +667,6 @@ function TemaRow({
<Input
type="number"
value={draftHoras}
min={0}
max={200}
step={0.5}
onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white"
/>
@@ -925,7 +675,7 @@ function TemaRow({
<>
<button
type="button"
className="flex flex-1 cursor-pointer items-center gap-3 text-left"
className="flex flex-1 items-center gap-3 text-left"
onClick={(e) => {
e.stopPropagation()
onBeginEdit()
@@ -940,7 +690,7 @@ function TemaRow({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600"
className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={(e) => {
e.stopPropagation()
onBeginEdit()
@@ -951,7 +701,7 @@ function TemaRow({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500"
className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={(e) => {
e.stopPropagation()
onDelete()

View File

@@ -13,33 +13,21 @@ import {
X,
MessageSquarePlus,
Archive,
History,
Edit2, // Agregado
History, // Agregado
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useAISubjectChat,
useConversationBySubject,
useMessagesBySubjectChat,
useSubject,
useUpdateSubjectConversationName,
useUpdateSubjectConversationStatus,
} from '@/data'
import { cn } from '@/lib/utils'
@@ -87,24 +75,6 @@ export function IAAsignaturaTab({
const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
const hasInitialSelected = useRef(false)
const { mutate: updateName } = useUpdateSubjectConversationName()
const [editingId, setEditingId] = useState<string | null>(null)
const [tempName, setTempName] = useState('')
const [openIA, setOpenIA] = useState(false)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
// Cálculo del total para el Badge del botón
const totalReferencias =
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
const isAiThinking = useMemo(() => {
if (isSending) return true
@@ -136,92 +106,35 @@ export function IAAsignaturaTab({
}
}, [todasConversaciones])
const availableFields = useMemo(() => {
// 1. Obtenemos los campos dinámicos de la DB
const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
estructuraProps[key]?.title ||
key.replace(/_/g, ' ').toUpperCase(),
value: String(datosGenerales.datos[key] || ''),
}
})
: []
// 2. Definimos tus campos manuales (hardcoded)
const hardcodedFields = [
{
key: 'contenido_tematico',
label: 'Contenido temático',
value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera
},
{
key: 'criterios_de_evaluacion',
label: 'Criterios de evaluación',
value: '',
},
]
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
const combined = [...dynamicFields]
hardcodedFields.forEach((hf) => {
if (!combined.some((f) => f.key === hf.key)) {
combined.push(hf)
}
})
return combined
}, [datosGenerales])
// --- PROCESAMIENTO DE MENSAJES ---
// --- PROCESAMIENTO DE MENSAJES ---
const messages = useMemo(() => {
const msgs: Array<any> = []
// 1. Mensajes existentes de la DB
if (rawMessages) {
rawMessages.forEach((m) => {
// Mensaje del usuario
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
// Respuesta de la IA (si existe)
if (m.respuesta) {
const sugerencias =
m.propuesta?.recommendations?.map((rec: any, index: number) => ({
id: `${m.id}-sug-${index}`,
messageId: m.id,
campoKey: rec.campo_afectado,
campoNombre: rec.campo_afectado.replace(/_/g, ' '),
valorSugerido: rec.texto_mejora,
aceptada: rec.aplicada,
})) || []
msgs.push({
id: `${m.id}-ai`,
role: 'assistant',
content: m.respuesta,
sugerencias: sugerencias,
})
}
})
}
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
if (isSending && input.trim()) {
msgs.push({
id: 'optimistic-user-msg',
role: 'user',
content: input,
})
}
return msgs
}, [rawMessages, isSending, input])
if (!rawMessages) return []
return rawMessages.flatMap((m) => {
const msgs = []
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
if (m.respuesta) {
msgs.push({
id: `${m.id}-ai`,
role: 'assistant',
content: m.respuesta,
sugerencia: m.propuesta?.recommendations?.[0]
? {
id: m.id,
campoKey: m.propuesta.recommendations[0].campo_afectado,
campoNombre:
m.propuesta.recommendations[0].campo_afectado.replace(
/_/g,
' ',
),
valorSugerido: m.propuesta.recommendations[0].texto_mejora,
aceptada: m.propuesta.recommendations[0].aplicada,
}
: null,
})
}
return msgs
})
}, [rawMessages])
// Auto-selección inicial
useEffect(() => {
@@ -234,58 +147,6 @@ export function IAAsignaturaTab({
}
}, [activeChats, loadingConv])
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
return availableFields.filter(
(f) =>
f.label.toLowerCase().includes(query) ||
f.key.toLowerCase().includes(query),
)
}, [availableFields, input, showSuggestions])
// 2. Efecto para cerrar con ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowSuggestions(false)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field])
}
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
setInput(nuevoTexto)
}
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
}
const handleSaveName = (id: string) => {
if (tempName.trim()) {
updateName({ id, nombre: tempName })
}
setEditingId(null)
}
const handleSend = async (promptOverride?: string) => {
const text = promptOverride || input
if (!text.trim() && selectedFields.length === 0) return
@@ -305,7 +166,7 @@ export function IAAsignaturaTab({
}
setInput('')
// setSelectedFields([])
setSelectedFields([])
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
queryClient.invalidateQueries({
@@ -326,6 +187,18 @@ 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 = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
setInput('')
@@ -377,8 +250,11 @@ export function IAAsignaturaTab({
<Button
onClick={() => {
// 1. Limpiamos el ID
setActiveChatId(undefined)
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
hasInitialSelected.current = true
// 3. Limpiamos estados visuales
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
@@ -392,117 +268,46 @@ export function IAAsignaturaTab({
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
<div className="space-y-1 pr-3">
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsCreatingNewChat(false) // <--- Volvemos al modo normal
}}
className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
'group relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id
? 'bg-teal-50 text-teal-900'
? 'bg-teal-50 font-medium text-teal-900'
: 'text-slate-600 hover:bg-slate-100',
)}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
>
{editingId === chat.id ? (
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
}}
/>
</div>
) : (
<>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span
onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
>
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
const nuevoEstado =
chat.estado === 'ACTIVA'
? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={14} />
) : (
<History size={14} className="scale-x-[-1]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
)}
<FileText size={14} className="shrink-0 opacity-50" />
<span className="flex-1 truncate">
{chat.titulo || 'Conversación'}
</span>
<button
onClick={(e) => {
e.stopPropagation()
updateStatus(
{
id: chat.id,
estado: showArchived ? 'ACTIVA' : 'ARCHIVADA',
},
{
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['conversation-by-subject'],
}),
},
)
}}
className="rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-200"
>
<Archive size={12} />
</button>
</div>
))}
</div>
@@ -511,133 +316,112 @@ export function IAAsignaturaTab({
{/* PANEL CENTRAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
<div className="shrink-0 border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA
</span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
>
<FileText size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div>
<div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="mx-auto max-w-3xl space-y-6 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) => (
<div
key={msg.id}
className={cn(
'flex gap-4',
msg.role === 'user' ? 'flex-row-reverse' : 'flex-row',
)}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
>
<Avatar
className={cn(
'h-9 w-9 shrink-0 border shadow-sm',
msg.role === 'assistant'
? 'bg-teal-600 text-white'
: 'bg-slate-100',
)}
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
>
<AvatarFallback>
<AvatarFallback className="text-[10px]">
{msg.role === 'assistant' ? (
<Sparkles size={16} />
<Sparkles size={14} className="text-teal-600" />
) : (
<UserCheck size={16} />
<UserCheck size={14} />
)}
</AvatarFallback>
</Avatar>
<div
className={cn(
'flex max-w-[85%] flex-col gap-3',
msg.role === 'user' ? 'items-end' : 'items-start',
)}
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
>
<div
className={cn(
'relative overflow-hidden rounded-2xl border shadow-sm',
'rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm',
msg.role === 'user'
? 'rounded-tr-none border-teal-700 bg-teal-600 px-4 py-3 text-white'
: 'w-full rounded-tl-none border-slate-200 bg-white text-slate-800',
? 'rounded-tr-none bg-teal-600 text-white'
: 'rounded-tl-none border bg-white text-slate-700',
)}
>
{/* Texto del mensaje principal */}
<div
style={{ whiteSpace: 'pre-line' }}
className={cn(
'text-sm leading-relaxed',
msg.role === 'assistant' && 'p-4',
)}
>
{msg.content}
</div>
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
{msg.role === 'assistant' &&
msg.sugerencias &&
msg.sugerencias.length > 0 && (
<div className="space-y-3 border-t bg-slate-50/50 p-3">
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles:
</p>
{msg.sugerencias.map((sug: any) => (
<ImprovementCard
key={sug.id}
sug={sug}
asignaturaId={asignaturaId}
onApplied={(campoFinalizado) => {
// Filtramos el array para conservar todos MENOS el que se aplicó
console.log(campoFinalizado)
console.log('campos:', selectedFields)
setSelectedFields((prev) =>
prev.filter((fieldObj) => {
// Accedemos a .key porque fieldObj es { key: "...", label: "..." }
return fieldObj.key !== campoFinalizado
}),
)
}}
/>
))}
</div>
)}
{msg.content}
</div>
{msg.sugerencia && !msg.sugerencia.aceptada && (
<div className="animate-in fade-in slide-in-from-top-1 mt-3 w-full">
<div className="rounded-xl border border-teal-100 bg-white p-4 shadow-md">
<p className="mb-2 text-[10px] font-bold text-slate-400 uppercase">
Propuesta: {msg.sugerencia.campoNombre}
</p>
<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>
</div>
))}
{isAiThinking && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<div className="animate-in fade-in flex flex-row items-start gap-3 duration-300">
<Avatar className="h-8 w-8 shrink-0 border bg-teal-50">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
<Sparkles
size={14}
className="animate-pulse text-teal-600"
/>
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div>
<div className="rounded-2xl rounded-tl-none border bg-white p-4 shadow-sm">
<div className="flex gap-1.5">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}
{/* Espacio extra al final para que el scroll no tape el último mensaje */}
<div className="h-4" />
</div>
</ScrollArea>
</div>
@@ -646,53 +430,39 @@ export function IAAsignaturaTab({
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
<div 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="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
Campos de Asignatura
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button
key={field.key}
onClick={() => handleSelectField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
>
<div className="flex flex-col">
<span className="font-medium text-slate-700">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
<div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => (
<button
key={field.key}
onClick={() => toggleField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors hover:bg-teal-50"
>
<span className="text-slate-700">{field.label}</span>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))}
</div>
</div>
)}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
<div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
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"
>
<Target size={10} />
{field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
className="ml-1 rounded-full p-0.5 hover:bg-teal-200"
>
<X size={10} />
</button>
@@ -755,41 +525,6 @@ export function IAAsignaturaTab({
))}
</div>
</div>
{/* --- DRAWER DE REFERENCIAS --- */}
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 hover:text-slate-600"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds}
selectedRepositorioIds={selectedRepositorioIds}
uploadedFiles={uploadedFiles}
onToggleArchivo={(id, checked) => {
setSelectedArchivoIds((prev) =>
checked ? [...prev, id] : prev.filter((a) => a !== id),
)
}}
onToggleRepositorio={(id, checked) => {
setSelectedRepositorioIds((prev) =>
checked ? [...prev, id] : prev.filter((r) => r !== id),
)
}}
onFilesChange={(files) => setUploadedFiles(files)}
/>
</div>
</DrawerContent>
</Drawer>
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -359,19 +359,3 @@ export async function update_subject_conversation_status(
if (error) throw error
return data
}
export async function update_subject_conversation_name(
conversacionId: string,
nuevoNombre: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}

View File

@@ -207,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase
.from('asignaturas')
.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,prerrequisito_asignatura_id',
'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',
)
.eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false })

View File

@@ -232,7 +232,7 @@ export async function subjects_bibliografia_list(
const { data, error } = await supabase
.from('bibliografia_asignatura')
.select(
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
)
.eq('asignatura_id', subjectId)
.order('tipo', { ascending: true })

View File

@@ -19,7 +19,6 @@ import {
getConversationBySubject,
ai_subject_chat_v2,
create_subject_conversation,
update_subject_conversation_name,
} from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client'
@@ -321,17 +320,3 @@ 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'] })
},
})
}

View File

@@ -13,9 +13,8 @@ import {
X,
MessageSquarePlus,
Archive,
Loader2,
Sparkles,
RotateCcw,
Loader2,
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
@@ -23,17 +22,10 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useAIPlanChat,
useConversationByPlan,
@@ -129,7 +121,6 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null)
const isInitialLoad = useRef(true)
const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null)
@@ -206,20 +197,20 @@ function RouteComponent() {
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = (behavior = 'smooth') => {
const scrollToBottom = () => {
if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
)
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
behavior: 'smooth',
})
}
}
}
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
@@ -231,22 +222,22 @@ function RouteComponent() {
}, [lastConversation])
useEffect(() => {
if (chatMessages.length > 0) {
if (isInitialLoad.current) {
// Si es el primer render con mensajes, vamos al final al instante
scrollToBottom('instant')
isInitialLoad.current = false
} else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth')
}
}
}, [chatMessages])
console.log(mensajesDelChat)
scrollToBottom()
}, [chatMessages, isLoading])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
const camposActualizados = selectedFields.filter((field) =>
input.includes(field.label),
)
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
}
}, [input, selectedFields])
useEffect(() => {
if (isLoadingConv || isSending) return
@@ -306,7 +297,7 @@ function RouteComponent() {
},
])
setInput('')
// setSelectedFields([])
setSelectedFields([])
}
const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -414,7 +405,7 @@ function RouteComponent() {
setIsSending(true)
setOptimisticMessage(finalContent)
setInput('')
// setSelectedFields([])
setSelectedFields([])
try {
const payload = {
@@ -510,114 +501,82 @@ function RouteComponent() {
</div>
<ScrollArea className="flex-1">
<div className="space-y-1 pr-2">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
<div className="space-y-1">
{!showArchived ? (
activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
{/* LADO IZQUIERDO: Icono + Texto */}
<div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
// donde aparecen los botones
maskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
<FileText size={16} className="shrink-0 opacity-40" />
<span
ref={editingChatId === chat.id ? editableRef : null}
contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${
editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
: 'cursor-pointer'
}`}
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()
}}
>
{/* pr-12 reserva espacio para los botones absolutos */}
<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>
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
</span>
{/* LADO DERECHO: Acciones ABSOLUTAS */}
<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'
}`}
>
{/* ACCIONES */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50)
}}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
className="p-1 text-slate-400 hover:text-teal-600"
>
<Send size={12} className="rotate-45" />
</button>
<button
onClick={(e) => archiveChat(e, chat.id)}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
className="p-1 text-slate-400 hover:text-amber-600"
>
<Archive size={14} />
</button>
@@ -625,26 +584,24 @@ function RouteComponent() {
</div>
))
) : (
/* Sección de archivados (Simplificada para mantener consistencia) */
<div className="animate-in fade-in slide-in-from-left-2 px-1">
/* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados
</p>
{archivedChats.map((chat) => (
<div
key={chat.id}
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"
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"
>
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" />
<span className="block truncate">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
</div>
<Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
<button
onClick={(e) => unarchiveChat(e, chat.id)}
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"
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
>
<RotateCcw size={14} />
</button>
@@ -764,24 +721,33 @@ function RouteComponent() {
)
})}
{(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div>
{(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) && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<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-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
{isSyncing
? 'Actualizando historial...'
: 'Esperando respuesta...'}
</span>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}

View File

@@ -76,7 +76,7 @@ const mapAsignaturasToAsignaturas = (
// Mapeo directo de los nuevos campos de la API
hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
prerrequisitos: [],
}
})
}
@@ -336,7 +336,6 @@ function MapaCurricularPage() {
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
prerrequisito_asignatura_id?: string | null
}
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre,
@@ -346,7 +345,6 @@ function MapaCurricularPage() {
horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
}
@@ -492,7 +490,7 @@ function MapaCurricularPage() {
e: React.FocusEvent<HTMLSpanElement>,
id: string,
) => {
const nuevoNombre = e.currentTarget.textContent.trim() || ''
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
// Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id)
@@ -937,55 +935,65 @@ function MapaCurricularPage() {
{/* Fila 4: Seriación (Prerrequisitos) */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisito)
Seriación (Prerrequisitos)
</label>
<Select
// Cambiamos a manejo de valor único basado en el ID de la columna
value={editingData.prerrequisito_asignatura_id || undefined}
value={seriacionValue}
onValueChange={(val) => {
console.log(editingData)
setEditingData({
...editingData,
prerrequisito_asignatura_id: val === 'none' ? null : val,
})
if (val === 'none') {
setSeriacionValue('')
return
}
if (!editingData.prerrequisitos.includes(val)) {
setEditingData({
...editingData,
prerrequisitos: [...editingData.prerrequisitos, val],
})
}
setSeriacionValue('')
}}
>
<SelectTrigger className="w-full bg-white">
<SelectTrigger>
<SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">-- Sin Seriación --</SelectItem>
{asignaturas
.filter((asig) => {
// 1. No es la misma materia
const noEsMisma = asig.id !== editingData.id
// 2. El ciclo debe ser estrictamente MENOR
const esCicloMenor =
asig.ciclo !== null &&
editingData.ciclo !== null &&
asig.ciclo < editingData.ciclo
return noEsMisma && esCicloMenor
})
.sort(
(a, b) =>
(a.ciclo || 0) - (b.ciclo || 0) ||
a.nombre.localeCompare(b.nombre),
)
.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
<span className="font-bold text-teal-600">
[C{asig.ciclo}]
</span>{' '}
{asig.nombre}
.filter((m) => m.id !== editingData.id)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.nombre} ({m.clave})
</SelectItem>
))}
</SelectContent>
</Select>
{/* Visualización del Prerrequisito con el Nombre */}
{/* Visualización de los prerrequisitos seleccionados */}
<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>
{/* Fila 5: Tipo */}

View File

@@ -166,20 +166,30 @@ function AsignaturaLayout() {
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
Pertenece al plan:{' '}
<span className="text-blue-100">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre ?? ''}
</span>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">

View File

@@ -4,145 +4,18 @@
@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 {
@apply m-0;
font-family: var(--font-sans);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: var(--font-mono);
}
strong,
b,
.font-bold {
font-family: 'Indivisa Sans', serif;
font-weight: 900;
/* Inter letter space */
letter-spacing: -0.025em;
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
:root {
@@ -178,9 +51,9 @@ b,
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0 0 0);
--font-sans: 'Indivisa Sans', sans-serif;
--font-serif: 'Indivisa Serif', serif;
--font-mono: 'Indivisa Sans', monospace;
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--radius: 1.4rem;
--shadow-x: 0px;
--shadow-y: 2px;
@@ -228,7 +101,7 @@ b,
--chart-1: oklch(0.6686 0.1794 251.7436);
--chart-2: oklch(0.6342 0.2516 22.4415);
--chart-3: oklch(0.8718 0.1716 90.9505);
--chart-4: oklch(11.492% 0.00001 271.152);
--chart-4: oklch(0.4503 0.229 263.0881);
--chart-5: oklch(0.8322 0.146 185.9404);
--sidebar: oklch(0.1564 0.0688 261.2771);
--sidebar-foreground: oklch(0.9551 0 0);
@@ -238,9 +111,9 @@ b,
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
--sidebar-border: oklch(0.3289 0.0092 268.3843);
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
--font-sans: 'Indivisa Sans', sans-serif;
--font-serif: 'Indivisa Serif', serif;
--font-mono: 'Indivisa Sans', monospace;
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--radius: 1.4rem;
--shadow-x: 0px;
--shadow-y: 2px;

View File

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

File diff suppressed because it is too large Load Diff

View File

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