25 Commits

Author SHA1 Message Date
Guillermo.Arrieta c280faef22 feat: add 'vaul' dependency and update database types for conversation management 2026-02-12 17:26:47 -06:00
Guillermo.Arrieta d6c567195a Generación existosa de múltiples asignaturas con IA
TODO: actualización automática de el estado de las asignaturas generadas
2026-02-12 16:17:48 -06:00
Guillermo.Arrieta 9c588cfd8f PasoResumen muestra resumen antes de crear múltiples asignaturas 2026-02-12 16:15:11 -06:00
Guillermo.Arrieta 46d8d6142e feat: integrate Radix UI Accordion component and enhance subject wizard
- Added Radix UI Accordion component for better UI organization in PasoDetallesPanel.
- Implemented structure selection and subject suggestions management in the wizard.
- Updated subject API to initialize new subjects with null values for structure and cycle.
- Modified state management in useNuevaAsignaturaWizard to include estructuraId.
- Adjusted types for suggested subjects to include line and cycle information.
2026-02-12 16:15:11 -06:00
Guillermo.Arrieta 07d08e1b57 Add AI progress loader and enhance suggestion generation logic
- Introduced AIProgressLoader component to display loading progress and messages during suggestion generation.
- Updated PasoSugerenciasForm to manage loading state and display tooltip for preserved suggestions.
- Adjusted suggestion limits and removed unused ciclo input from state.
2026-02-12 16:15:11 -06:00
Guillermo.Arrieta ded54c18dd Se mandan generar sugerencias de asignaturas junto con el id del plan, el enfoque que se le quiere dar, la cantidad de sugerencias, y las sugerencias conservadas 2026-02-12 16:15:10 -06:00
Guillermo.Arrieta 89f264bf5d Primera version funcional de sugerencias 2026-02-12 16:15:10 -06:00
Guillermo.Arrieta 675c76db74 wip 2026-02-12 16:15:10 -06:00
Guillermo.Arrieta d74807c84e wip 2026-02-12 16:15:10 -06:00
roberto.silva 4d0f5815eb Merge pull request 'Que haga la cuenta de cuántas referencias llevas #99' (#103) from issue/99-que-haga-la-cuenta-de-cuntas-referencias-llevas into main
Reviewed-on: #103
2026-02-12 21:58:41 +00:00
roberto.silva 2f9e779bce Se corrigen incidencias
fix #100
fix #101
2026-02-12 15:55:14 -06:00
roberto.silva 0c57bdfc38 Que haga la cuenta de cuántas referencias llevas
fix #99
2026-02-12 14:14:02 -06:00
roberto.silva 2250a1afd1 Merge pull request 'Se agrega paginacion a historial' (#95) from issue/82-paginacin-del-historial-para-evitar-que-crezca-al- into main
Reviewed-on: #95
2026-02-12 16:55:56 +00:00
roberto.silva 9102e756cb Se agrega paginacion a historial 2026-02-12 10:55:19 -06:00
roberto.silva e788eb788f Merge pull request 'Archivado de chats y editar por campos de ia' (#94) from issue/90-historial-de-chats-archivado into main
Reviewed-on: #94
2026-02-12 16:02:51 +00:00
roberto.silva 2ec222694d Se agrega editar por campos en ia y archivar chats 2026-02-12 10:01:27 -06:00
roberto.silva 58d4ee8b6e Merge pull request 'Se agrega drawer de referencias de ia y panel de historial de conversaciones' (#93) from issue/90-crearSeccionHistorialChat into main
Reviewed-on: #93
2026-02-11 16:24:18 +00:00
roberto.silva d9a6852f43 Se agrega drawer de referencias de ia y panel de historial de chats 2026-02-11 10:22:14 -06:00
roberto.silva ba188329dc Se agrega avance de historial de chat y referencias de la ia 2026-02-10 14:33:04 -06:00
roberto.silva 777be81d2a Merge pull request 'Corregir que se duplica el campo #90' (#92) from issue/90-corregir-que-se-duplica-el-campo into main
Reviewed-on: #92
2026-02-10 17:37:14 +00:00
roberto.silva 3afce0de77 Corregir que se duplica el campo
fix #90
2026-02-10 11:34:58 -06:00
roberto.silva 4b8ec2c5ab Merge pull request 'Se borra boton de guardar y se cierran incidencias' (#91) from issue/87-no-tiene-sentido-este-botn-de-guardar into main
Reviewed-on: #91
2026-02-10 17:23:47 +00:00
roberto.silva 0788002c9b No todo debe ser editable #47 2026-02-10 10:10:40 -06:00
roberto.silva c7c631a701 Botón de Exportar Mapa Curricular fix #27 2026-02-10 09:06:57 -06:00
roberto.silva 9ba94f2c2c No tiene sentido este botón de guardar
fix #87
2026-02-10 08:35:46 -06:00
12 changed files with 699 additions and 126 deletions
+3
View File
@@ -43,6 +43,7 @@
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
"vaul": "^1.1.2",
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",
@@ -1388,6 +1389,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
+2 -1
View File
@@ -55,7 +55,8 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0"
"use-debounce": "^10.1.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",
@@ -249,22 +249,16 @@ export default function AsignaturaDetailPage() {
<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" />
{/* Eliminamos el max-w y dejamos que el flex-wrap haga su trabajo */}
<EditableHeaderField
value={asignaturasApi?.planes_estudio?.datos?.nombre || ''}
onSave={(val) => handleUpdateHeader('plan_nombre', val)}
className="min-w-[10ch] text-blue-100" // min-w para que sea clickeable si está vacío
/>
<span className="text-blue-100">
{asignaturasApi?.planes_estudio?.datos?.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<EditableHeaderField
value={
asignaturasApi?.planes_estudio?.carreras?.facultades
?.nombre || ''
}
onSave={(val) => handleUpdateHeader('facultad_nombre', val)}
className="min-w-[10ch] text-blue-100"
/>
<span className="text-blue-100">
{asignaturasApi?.planes_estudio?.carreras?.facultades
?.nombre || ''}
</span>
</span>
</div>
@@ -1,12 +1,4 @@
import {
Plus,
Search,
BookOpen,
Trash2,
Library,
Edit3,
Save,
} from 'lucide-react'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
@@ -204,15 +196,6 @@ export function BibliographyItem({
/>
</DialogContent>
</Dialog>
<Button
onClick={() => onSave(entries)}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
+133
View File
@@ -0,0 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+17 -4
View File
@@ -165,7 +165,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,conversation_id,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',
)
.eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -176,18 +176,31 @@ export async function plan_asignaturas_list(
return data ?? []
}
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
export async function plans_history(
planId: UUID,
page: number = 0,
pageSize: number = 4,
): Promise<{ data: Array<CambioPlan>; count: number }> {
// Cambiamos el retorno
const supabase = supabaseBrowser()
const { data, error } = await supabase
const from = page * pageSize
const to = from + pageSize - 1
const { data, error, count } = await supabase
.from('cambios_plan')
.select(
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
{ count: 'exact' }, // <--- Pedimos el conteo exacto
)
.eq('plan_estudio_id', planId)
.order('cambiado_en', { ascending: false })
.range(from, to)
throwIfError(error)
return data ?? []
return {
data: data ?? [],
count: count ?? 0,
}
}
/** Wizard: crear plan manual (Edge Function) */
+1 -1
View File
@@ -41,7 +41,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
.from('asignaturas')
.select(
`
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
+9 -3
View File
@@ -80,11 +80,17 @@ export function usePlanAsignaturas(planId: UUID | null | undefined) {
})
}
export function usePlanHistorial(planId: UUID | null | undefined) {
export function usePlanHistorial(
planId: UUID | null | undefined,
page: number,
) {
return useQuery({
queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null],
queryFn: () => plans_history(planId as UUID),
queryKey: planId
? [...qk.planHistorial(planId), page]
: ['planes', 'historial', null, page],
queryFn: () => plans_history(planId as UUID, page),
enabled: Boolean(planId),
placeholderData: (previousData) => previousData,
})
}
@@ -12,10 +12,13 @@ import {
Eye,
History,
Calendar,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
@@ -57,15 +60,14 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() {
const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(planId)
const [page, setPage] = useState(0)
const pageSize = 4
const { data: response, isLoading } = usePlanHistorial(planId, page)
const rawData = response?.data ?? []
const totalRecords = response?.count ?? 0
const totalPages = Math.ceil(totalRecords / pageSize)
const [structure, setStructure] = useState<any>(null)
const { data } = usePlan(planId)
console.log('analizando estructura')
console.log(data?.estructuras_plan?.definicion?.properties)
// console.log(structure)
// ESTADOS PARA EL MODAL
const [selectedEvent, setSelectedEvent] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -229,6 +231,46 @@ function RouteComponent() {
</div>
))
)}
{historyEvents.length > 0 && (
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
<p className="text-xs text-slate-500">
Mostrando {rawData.length} de {totalRecords} cambios
</p>
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => {
setPage((p) => Math.max(0, p - 1))
window.scrollTo(0, 0) // Opcional: volver arriba
}}
disabled={page === 0 || isLoading}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Anterior
</Button>
<span className="text-sm font-medium text-slate-700">
Página {page + 1} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
setPage((p) => p + 1)
window.scrollTo(0, 0)
}}
// Ahora se deshabilita si llegamos a la última página real
disabled={page + 1 >= totalPages || isLoading}
>
Siguiente
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
+342 -72
View File
@@ -1,20 +1,26 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { createFileRoute, useRouterState } from '@tanstack/react-router'
import {
Sparkles,
Send,
Target,
UserCheck,
Lightbulb,
FileText,
GraduationCap,
BookOpen,
Check,
X,
MessageSquarePlus,
Archive,
RotateCcw,
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
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 { usePlan } from '@/data/hooks/usePlans'
@@ -53,22 +59,25 @@ interface SelectedField {
value: string
}
const formatLabel = (key: string) => {
const result = key.replace(/_/g, ' ')
return result.charAt(0).toUpperCase() + result.slice(1)
}
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent,
})
function RouteComponent() {
const { planId } = Route.useParams()
// Usamos el ID dinámico del plan o el hardcoded según tu necesidad
const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false)
// archivos
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
// ESTADOS PRINCIPALES
const [messages, setMessages] = useState<Array<any>>([
{
id: '1',
@@ -84,6 +93,62 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [activeChatId, setActiveChatId] = useState('1')
const [chatHistory, setChatHistory] = useState([
{ id: '1', title: 'Chat inicial' },
])
const [showArchived, setShowArchived] = useState(false)
const [archivedHistory, setArchivedHistory] = useState<Array<any>>([])
const [allMessages, setAllMessages] = useState<{ [key: string]: Array<any> }>(
{
'1': [
{
id: 'm1',
role: 'assistant',
content: '¡Hola! Soy tu asistente de IA en este chat inicial.',
},
],
},
)
const createNewChat = () => {
const newId = Date.now().toString()
const newChat = { id: newId, title: `Nuevo chat ${chatHistory.length + 1}` }
setChatHistory([newChat, ...chatHistory])
setAllMessages({
...allMessages,
[newId]: [
{
id: '1',
role: 'assistant',
content: '¡Nuevo chat creado! ¿En qué puedo ayudarte?',
},
],
})
setActiveChatId(newId)
}
const archiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
const chatToArchive = chatHistory.find((chat) => chat.id === id)
if (chatToArchive) {
setArchivedHistory([chatToArchive, ...archivedHistory])
const newHistory = chatHistory.filter((chat) => chat.id !== id)
setChatHistory(newHistory)
if (activeChatId === id && newHistory.length > 0) {
setActiveChatId(newHistory[0].id)
}
}
}
const unarchiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
const chatToRestore = archivedHistory.find((chat) => chat.id === id)
if (chatToRestore) {
setChatHistory([chatToRestore, ...chatHistory])
setArchivedHistory(archivedHistory.filter((chat) => chat.id !== id))
}
}
// 1. Transformar datos de la API para el menú de selección
const availableFields = useMemo(() => {
@@ -107,12 +172,11 @@ function RouteComponent() {
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
)
if (field && !selectedFields.some((sf) => sf.key === field.key)) {
setSelectedFields([field])
}
if (!field) return
setSelectedFields([field])
setInput((prev) =>
injectFieldsIntoInput(prev || 'Mejora este campo:', field ? [field] : []),
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
)
}, [availableFields])
@@ -131,21 +195,21 @@ function RouteComponent() {
input: string,
fields: Array<SelectedField>,
) => {
const baseText = input.replace(/\[[^\]]+]/g, '').trim()
// Quita cualquier bloque previo de campos
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
const tags = fields.map((f) => `${f.label}`).join(' ')
if (fields.length === 0) return cleaned
return `${baseText} ${tags}`.trim()
const fieldLabels = fields.map((f) => f.label).join(', ')
return `${cleaned}\n[Campos: ${fieldLabels}]`
}
const toggleField = (field: SelectedField) => {
setSelectedFields((prev) => {
let nextFields
if (prev.find((f) => f.key === field.key)) {
nextFields = prev.filter((f) => f.key !== field.key)
} else {
nextFields = [...prev, field]
}
const nextFields = prev.find((f) => f.key === field.key)
? prev.filter((f) => f.key !== field.key)
: [...prev, field]
setInput((prevInput) =>
injectFieldsIntoInput(prevInput || 'Mejora este campo:', nextFields),
@@ -156,6 +220,7 @@ function RouteComponent() {
setShowSuggestions(false)
}
const buildPrompt = (userInput: string) => {
if (selectedFields.length === 0) return userInput
@@ -164,11 +229,11 @@ function RouteComponent() {
.join('\n')
return `
${userInput || 'Mejora los siguientes campos:'}
${userInput || 'Mejora los siguientes campos:'}
Campos a analizar:
${fieldsText}
`.trim()
Campos a analizar:
${fieldsText}
`.trim()
}
const handleSend = async (promptOverride?: string) => {
@@ -183,39 +248,157 @@ ${fieldsText}
content: finalPrompt,
}
setMessages((prev) => [...prev, userMsg])
// setMessages((prev) => [...prev, userMsg])
setInput('')
setIsLoading(true)
// setSelectedFields([])
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
setTimeout(() => {
const mockText =
'Sugerencia generada basada en los campos seleccionados...'
const suggestions = selectedFields.map((field) => ({
key: field.key,
label: field.label,
newValue: field.value,
}))
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: 'assistant',
content: `He analizado ${selectedFields
.map((f) => f.label)
.join(', ')}. Aquí tienes una propuesta:\n\n${mockText}`,
type: 'improvement-card',
content:
'He analizado los campos seleccionados. Aquí tienes mis sugerencias de mejora:',
suggestions: suggestions,
},
])
setPendingSuggestion({ text: mockText })
setIsLoading(false)
}, 1200)
}
// ... debajo de tus otros hooks
const totalReferencias = useMemo(() => {
return (
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
)
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-1">
{/* Lógica de renderizado condicional */}
{!showArchived ? (
// LISTA DE CHATS ACTIVOS
chatHistory.map((chat) => (
<div
key={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 ${
activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<FileText size={16} className="shrink-0 opacity-40" />
<span className="truncate pr-8">{chat.title}</span>
<button
onClick={(e) => archiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600"
title="Archivar"
>
<Archive size={14} />
</button>
</div>
))
) : (
// LISTA DE CHATS ARCHIVADOS
<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>
{archivedHistory.map((chat) => (
<div
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"
>
<Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8">{chat.title}</span>
<button
onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
title="Desarchivar"
>
<RotateCcw size={14} />
</button>
</div>
))}
{archivedHistory.length === 0 && (
<div className="px-2 py-4 text-center">
<p className="text-xs text-slate-400 italic">
No hay archivados
</p>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
{/* PANEL DE CHAT PRINCIPAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase">
Mejorar con 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"
>
<Archive size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div>
</div>
@@ -225,32 +408,29 @@ ${fieldsText}
<div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
>
<Avatar
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
>
<AvatarFallback className="text-[10px]">
{msg.role === 'assistant' ? (
<Sparkles size={14} className="text-teal-600" />
) : (
<UserCheck size={14} />
)}
</AvatarFallback>
</Avatar>
<div
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: 'rounded-tl-none border bg-white text-slate-700'
}`}
>
<div
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: 'rounded-tl-none border bg-white text-slate-700'
}`}
>
{msg.content}
</div>
{msg.content}
{msg.type === 'improvement-card' && (
<ImprovementCard
suggestions={msg.suggestions}
onApply={(key, val) => {
setSelectedFields((prev) =>
prev.filter((f) => f.key !== key),
)
console.log(`Aplicando ${val} al campo ${key}`)
// Aquí llamarías a tu función de actualización de datos real
}}
/>
)}
</div>
</div>
))}
@@ -285,11 +465,10 @@ ${fieldsText}
)}
</div>
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE (Se mantiene igual) */}
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
{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="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
@@ -316,7 +495,7 @@ ${fieldsText}
{/* CONTENEDOR DEL INPUT TRANSFORMADO */}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{/* 1. Visualización de campos dentro del input (Tags) */}
{/* 1. Visualización de campos dentro del input ) */}
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => (
@@ -341,19 +520,13 @@ ${fieldsText}
<Textarea
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder={
selectedFields.length > 0
? 'Escribe instrucciones adicionales...'
: 'Escribe tu solicitud o ":" para campos...'
}
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent py-2 text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
@@ -369,8 +542,7 @@ ${fieldsText}
</div>
</div>
</div>
{/* PANEL LATERAL (PRESETS) - SE MANTIENE COMO LO TENÍAS */}
{/* PANEL LATERAL */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
@@ -392,6 +564,104 @@ ${fieldsText}
))}
</div>
</div>
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-0 h-screen w-screen max-w-none rounded-none">
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-sm font-semibold">Referencias para la IA</h2>
<button
onClick={() => setOpenIA(false)}
className="text-muted-foreground hover:text-foreground text-sm"
>
Cerrar
</button>
</div>
<div className="h-[calc(100vh-60px)] overflow-y-auto p-6">
<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>
)
}
const ImprovementCard = ({
suggestions,
onApply,
}: {
suggestions: Array<any>
onApply: (key: string, value: string) => void
}) => {
// Estado para rastrear qué campos han sido aplicados
const [appliedFields, setAppliedFields] = useState<Array<string>>([])
const handleApply = (key: string, value: string) => {
onApply(key, value)
setAppliedFields((prev) => [...prev, key])
}
return (
<div className="mt-2 flex w-full flex-col gap-4">
{suggestions.map((sug) => {
const isApplied = appliedFields.includes(sug.key)
return (
<div
key={sug.key}
className="rounded-2xl border border-slate-100 bg-white p-5 shadow-sm"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
<Button
size="sm"
onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied}
className={`h-8 rounded-full px-4 text-xs transition-all ${
isApplied
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
}`}
>
{isApplied ? (
<span className="flex items-center gap-1">
<Check size={12} /> Aplicado
</span>
) : (
'Aplicar mejora'
)}
</Button>
</div>
<div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied
? 'border-[#ccfbf1] bg-[#f0fdfa] text-slate-700'
: 'border-slate-200 bg-slate-50 text-slate-500'
}`}
>
{sug.newValue}
</div>
</div>
)
})}
</div>
)
}
@@ -504,6 +504,9 @@ function MapaCurricularPage() {
</p>
</div>
<div className="flex items-center gap-3">
<Button className="bg-teal-700 text-white hover:bg-teal-800">
<Plus size={16} className="mr-2" /> Exportar{' '}
</Button>
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
+131 -6
View File
@@ -88,7 +88,6 @@ export type Database = {
asignatura_hash: string | null
codigo: string | null
contenido_tematico: Json
conversation_id: string | null
creado_en: string
creado_por: string | null
creditos: number
@@ -113,7 +112,6 @@ export type Database = {
asignatura_hash?: string | null
codigo?: string | null
contenido_tematico?: Json
conversation_id?: string | null
creado_en?: string
creado_por?: string | null
creditos: number
@@ -138,7 +136,6 @@ export type Database = {
asignatura_hash?: string | null
codigo?: string | null
contenido_tematico?: Json
conversation_id?: string | null
creado_en?: string
creado_por?: string | null
creditos?: number
@@ -392,6 +389,135 @@ export type Database = {
},
]
}
conversaciones_asignatura: {
Row: {
archivado_en: string | null
archivado_por: string | null
asignatura_id: string
conversacion_json: Json
creado_en: string
creado_por: string | null
estado: Database['public']['Enums']['estado_conversacion']
id: string
intento_archivado: number
openai_conversation_id: string
}
Insert: {
archivado_en?: string | null
archivado_por?: string | null
asignatura_id: string
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
openai_conversation_id: string
}
Update: {
archivado_en?: string | null
archivado_por?: string | null
asignatura_id?: string
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
openai_conversation_id?: string
}
Relationships: [
{
foreignKeyName: 'conversaciones_asignatura_archivado_por_fkey'
columns: ['archivado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_asignatura_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'asignaturas'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_asignatura_creado_por_fkey'
columns: ['creado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
]
}
conversaciones_plan: {
Row: {
archivado_en: string | null
archivado_por: string | null
conversacion_json: Json
creado_en: string
creado_por: string | null
estado: Database['public']['Enums']['estado_conversacion']
id: string
intento_archivado: number
openai_conversation_id: string
plan_estudio_id: string
}
Insert: {
archivado_en?: string | null
archivado_por?: string | null
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
openai_conversation_id: string
plan_estudio_id: string
}
Update: {
archivado_en?: string | null
archivado_por?: string | null
conversacion_json?: Json
creado_en?: string
creado_por?: string | null
estado?: Database['public']['Enums']['estado_conversacion']
id?: string
intento_archivado?: number
openai_conversation_id?: string
plan_estudio_id?: string
}
Relationships: [
{
foreignKeyName: 'conversaciones_plan_archivado_por_fkey'
columns: ['archivado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_plan_creado_por_fkey'
columns: ['creado_por']
isOneToOne: false
referencedRelation: 'usuarios_app'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_plan_plan_estudio_id_fkey'
columns: ['plan_estudio_id']
isOneToOne: false
referencedRelation: 'planes_estudio'
referencedColumns: ['id']
},
{
foreignKeyName: 'conversaciones_plan_plan_estudio_id_fkey'
columns: ['plan_estudio_id']
isOneToOne: false
referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id']
},
]
}
estados_plan: {
Row: {
clave: string
@@ -675,7 +801,6 @@ export type Database = {
actualizado_en: string
actualizado_por: string | null
carrera_id: string
conversation_id: string | null
creado_en: string
creado_por: string | null
datos: Json
@@ -696,7 +821,6 @@ export type Database = {
actualizado_en?: string
actualizado_por?: string | null
carrera_id: string
conversation_id?: string | null
creado_en?: string
creado_por?: string | null
datos?: Json
@@ -717,7 +841,6 @@ export type Database = {
actualizado_en?: string
actualizado_por?: string | null
carrera_id?: string
conversation_id?: string | null
creado_en?: string
creado_por?: string | null
datos?: Json
@@ -1088,6 +1211,7 @@ export type Database = {
}
Enums: {
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
fuente_cambio: 'HUMANO' | 'IA'
nivel_plan_estudio:
@@ -1261,6 +1385,7 @@ export const Constants = {
public: {
Enums: {
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
fuente_cambio: ['HUMANO', 'IA'],
nivel_plan_estudio: [