1 Commits

Author SHA1 Message Date
Guillermo.Arrieta cd6cb72d4c mejoras de diseño parte 2
- color picker añadido por linea
- bote de basura para borrar linea alineado abajo a la derecha
- se quitó degradado y linea de color superior de las tarjetas de asignaturas
2026-03-26 20:11:58 -06:00
36 changed files with 995 additions and 1422 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

@@ -128,6 +128,8 @@ export default function AsignaturaDetailPage() {
} }
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados]) }, [asignaturaApi, requisitosFormateados])
@@ -180,6 +182,7 @@ function DatosGenerales({
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => { const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
const raw = (data as any)?.criterios_de_evaluacion const raw = (data as any)?.criterios_de_evaluacion
console.log(raw)
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
@@ -229,7 +232,7 @@ function DatosGenerales({
if (isLoading) return <p>Cargando información...</p> if (isLoading) return <p>Cargando información...</p>
return ( return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500"> <div className="animate-in fade-in space-y-8 pb-8 duration-500">
{/* Encabezado de la Sección */} {/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div> <div>
@@ -411,7 +414,8 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(initialContent) setTempText(initialContent)
console.log(data)
console.log(initialContent)
if (type === 'evaluation') { if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : [] const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw const rows: Array<CriterioEvaluacionRowDraft> = raw
@@ -453,6 +457,10 @@ function InfoCard({
}, [highlightToken]) }, [highlightToken])
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') { if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = [] const cleaned: Array<CriterioEvaluacionRow> = []
for (const r of evalRows) { for (const r of evalRows) {
@@ -483,6 +491,8 @@ function InfoCard({
return return
} }
if (type === 'requirements') { if (type === 'requirements') {
console.log('entre aqui ')
// Si tempText es un array y tiene elementos, tomamos el ID del primero // Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación) // Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId = const prerequisiteId =
@@ -509,6 +519,8 @@ function InfoCard({
} }
const handleIARequest = (campoClave: string) => { const handleIARequest = (campoClave: string) => {
console.log(campoClave)
let targetClave = campoClave let targetClave = campoClave
if (type === 'evaluation' && !targetClave) { if (type === 'evaluation' && !targetClave) {
targetClave = 'criterios_de_evaluacion' targetClave = 'criterios_de_evaluacion'
@@ -517,6 +529,8 @@ function InfoCard({
if (targetClave === 'contenido') { if (targetClave === 'contenido') {
targetClave = 'contenido_tematico' targetClave = 'contenido_tematico'
} }
console.log(targetClave)
console.log(asignaturaId)
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
@@ -638,12 +652,18 @@ function InfoCard({
: 'none' : 'none'
} }
onValueChange={(val) => { onValueChange={(val) => {
console.log(availableSubjects)
const selected = availableSubjects?.find( const selected = availableSubjects?.find(
(s) => s.id === val, (s) => s.id === val,
) )
if (val === 'none' || !selected) { if (val === 'none' || !selected) {
console.log('guardando')
setTempText([]) setTempText([])
} else { } else {
console.log('hola')
setTempText([ setTempText([
{ {
id: selected.id, id: selected.id,
@@ -657,29 +677,20 @@ function InfoCard({
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<div className="flex-1 truncate text-left"> <div className="flex-1 truncate text-left">
<SelectValue placeholder="Selecciona una materia"> <SelectValue placeholder="Selecciona una materia" />
{Array.isArray(tempText) && tempText.length > 0
? `${tempText[0].code} - ${tempText[0].name}`
: undefined}
</SelectValue>
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none"> <SelectItem value="none">
Ninguna (Sin seriación) Ninguna (Sin seriación)
</SelectItem> </SelectItem>
{availableSubjects?.map((asig) => ( {availableSubjects?.map((asig) => (
<SelectItem <SelectItem
key={asig.id} key={asig.id}
value={asig.id} value={asig.id}
className="max-w-[300px] sm:max-w-[500px]" className="max-w-[300px] sm:max-w-[500px]"
> >
<span className="text-primary font-bold"> <span className="block truncate">
[C{asig.numero_ciclo}]
</span>{' '}
<span className="inline-block truncate">
{asig.codigo} - {asig.nombre} {asig.codigo} - {asig.nombre}
</span> </span>
</SelectItem> </SelectItem>
@@ -169,7 +169,7 @@ export function BibliographyItem() {
resetScroll: false, resetScroll: false,
}) })
} }
className="shadow-md" className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
> >
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía <Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
</Button> </Button>
@@ -94,7 +94,11 @@ export function DocumentoSEPTab({
{pdfUrl && !isLoading && ( {pdfUrl && !isLoading && (
<> <>
<Button size="sm" className="gap-2" onClick={onDownloadWord}> <Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Download className="h-4 w-4" /> Descargar Word <Download className="h-4 w-4" /> Descargar Word
</Button> </Button>
<Button <Button
@@ -11,6 +11,7 @@ import {
Filter, Filter,
Calendar, Calendar,
Loader2, Loader2,
Eye,
} from 'lucide-react' } from 'lucide-react'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
@@ -248,16 +249,7 @@ export function HistorialTab() {
`bg-current ${config.color}`, `bg-current ${config.color}`,
)} )}
/> />
<Card <Card className="card-interactive">
className="border-border card-interactive hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
onClick={() => openCompareModal(cambio)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
openCompareModal(cambio)
}}
>
<CardContent className="py-4"> <CardContent className="py-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div
@@ -273,7 +265,16 @@ export function HistorialTab() {
<p className="font-medium"> <p className="font-medium">
{cambio.descripcion} {cambio.descripcion}
</p> </p>
{/* BOTÓN PARA VER CAMBIOS */}
<Button
variant="ghost"
size="sm"
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => openCompareModal(cambio)}
>
<Eye className="h-4 w-4" />
Ver cambios
</Button>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{format(cambio.fecha, 'HH:mm')} {format(cambio.fecha, 'HH:mm')}
</span> </span>
@@ -15,7 +15,7 @@ import {
Archive, Archive,
History, History,
Edit2, Edit2,
Loader2, Loader2, // Agregado
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -62,7 +62,6 @@ export function IAAsignaturaTab() {
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const location = useLocation() const location = useLocation()
// --- ESTADOS --- // --- ESTADOS ---
const [activeChatId, setActiveChatId] = useState<string | undefined>( const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined, undefined,
@@ -110,6 +109,7 @@ export function IAAsignaturaTab() {
setIsSending(true) setIsSending(true)
try { try {
// 1. Consolidar el Patch
const patchData: any = { const patchData: any = {
datos: { ...datosGenerales.datos }, datos: { ...datosGenerales.datos },
} }
@@ -124,11 +124,13 @@ export function IAAsignaturaTab() {
} }
} }
// 2. Actualización única de la asignatura
await updateAsignatura.mutateAsync({ await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any, asignaturaId: asignaturaId as any,
patch: patchData, patch: patchData,
}) })
// 3. Marcar recomendaciones una por una (Secuencialmente para evitar colisiones)
for (const sug of sugerencias) { for (const sug of sugerencias) {
await updateRecommendation.mutateAsync({ await updateRecommendation.mutateAsync({
mensajeId: sug.messageId, mensajeId: sug.messageId,
@@ -136,12 +138,14 @@ export function IAAsignaturaTab() {
}) })
} }
// 4. Limpieza de estados
const appliedKeys = sugerencias.map((s) => s.campoKey) const appliedKeys = sugerencias.map((s) => s.campoKey)
setSelectedFields((prev) => setSelectedFields((prev) =>
prev.filter((f) => !appliedKeys.includes(f.key)), prev.filter((f) => !appliedKeys.includes(f.key)),
) )
setSelectedImprovements([]) setSelectedImprovements([])
// Forzar actualización visual
queryClient.invalidateQueries({ queryKey: ['subject', asignaturaId] }) queryClient.invalidateQueries({ queryKey: ['subject', asignaturaId] })
} catch (error) { } catch (error) {
console.error('Error en aplicación masiva:', error) console.error('Error en aplicación masiva:', error)
@@ -149,7 +153,6 @@ export function IAAsignaturaTab() {
setIsSending(false) setIsSending(false)
} }
} }
const toggleImprovementSelection = (sugId: string) => { const toggleImprovementSelection = (sugId: string) => {
setSelectedImprovements((prev) => setSelectedImprovements((prev) =>
prev.includes(sugId) prev.includes(sugId)
@@ -173,6 +176,7 @@ export function IAAsignaturaTab() {
} }
} }
// Cálculo del total para el Badge del botón
const totalReferencias = const totalReferencias =
selectedArchivoIds.length + selectedArchivoIds.length +
selectedRepositorioIds.length + selectedRepositorioIds.length +
@@ -182,12 +186,14 @@ export function IAAsignaturaTab() {
if (isSending) return true if (isSending) return true
if (!rawMessages || rawMessages.length === 0) return false if (!rawMessages || rawMessages.length === 0) return false
// Verificamos si el último mensaje está en estado de procesamiento
const lastMessage = rawMessages[rawMessages.length - 1] const lastMessage = rawMessages[rawMessages.length - 1]
return ( return (
lastMessage.estado === 'PROCESANDO' || lastMessage.estado === 'PENDIENTE' lastMessage.estado === 'PROCESANDO' || lastMessage.estado === 'PENDIENTE'
) )
}, [isSending, rawMessages]) }, [isSending, rawMessages])
// --- AUTO-SCROLL ---
useEffect(() => { useEffect(() => {
const viewport = scrollRef.current?.querySelector( const viewport = scrollRef.current?.querySelector(
'[data-radix-scroll-area-viewport]', '[data-radix-scroll-area-viewport]',
@@ -197,6 +203,7 @@ export function IAAsignaturaTab() {
} }
}, [rawMessages, isSending]) }, [rawMessages, isSending])
// --- FILTRADO DE CHATS ---
const { activeChats, archivedChats } = useMemo(() => { const { activeChats, archivedChats } = useMemo(() => {
const chats = todasConversaciones || [] const chats = todasConversaciones || []
return { return {
@@ -206,6 +213,7 @@ export function IAAsignaturaTab() {
}, [todasConversaciones]) }, [todasConversaciones])
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
// 1. Obtenemos los campos dinámicos de la DB
const dynamicFields = datosGenerales?.datos const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => { ? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps = const estructuraProps =
@@ -220,8 +228,13 @@ export function IAAsignaturaTab() {
}) })
: [] : []
// 2. Definimos tus campos manuales (hardcoded)
const hardcodedFields = [ const hardcodedFields = [
{ key: 'contenido_tematico', label: 'Contenido temático', value: '' }, {
key: 'contenido_tematico',
label: 'Contenido temático',
value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera
},
{ {
key: 'criterios_de_evaluacion', key: 'criterios_de_evaluacion',
label: 'Criterios de evaluación', label: 'Criterios de evaluación',
@@ -229,6 +242,7 @@ export function IAAsignaturaTab() {
}, },
] ]
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
const combined = [...dynamicFields] const combined = [...dynamicFields]
hardcodedFields.forEach((hf) => { hardcodedFields.forEach((hf) => {
@@ -240,13 +254,18 @@ export function IAAsignaturaTab() {
return combined return combined
}, [datosGenerales]) }, [datosGenerales])
// --- PROCESAMIENTO DE MENSAJES ---
// --- PROCESAMIENTO DE MENSAJES ---
const messages = useMemo(() => { const messages = useMemo(() => {
const msgs: Array<any> = [] const msgs: Array<any> = []
// 1. Mensajes existentes de la DB
if (rawMessages) { if (rawMessages) {
rawMessages.forEach((m) => { rawMessages.forEach((m) => {
// Mensaje del usuario
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje }) msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
// Respuesta de la IA (si existe)
if (m.respuesta) { if (m.respuesta) {
const sugerencias = const sugerencias =
m.propuesta?.recommendations?.map((rec: any, index: number) => ({ m.propuesta?.recommendations?.map((rec: any, index: number) => ({
@@ -268,14 +287,21 @@ export function IAAsignaturaTab() {
}) })
} }
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
if (isSending && input.trim()) { if (isSending && input.trim()) {
msgs.push({ id: 'optimistic-user-msg', role: 'user', content: input }) msgs.push({
id: 'optimistic-user-msg',
role: 'user',
content: input,
})
} }
return msgs return msgs
}, [rawMessages, isSending, input]) }, [rawMessages, isSending, input])
// Auto-selección inicial
useEffect(() => { useEffect(() => {
// Si ya hay un chat, o si el usuario ya interactuó (hasInitialSelected), abortamos.
if (activeChatId || hasInitialSelected.current) return if (activeChatId || hasInitialSelected.current) return
if (activeChats.length > 0 && !loadingConv) { if (activeChats.length > 0 && !loadingConv) {
@@ -286,14 +312,16 @@ export function IAAsignaturaTab() {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value const value = e.target.value
const selectionStart = e.target.selectionStart const selectionStart = e.target.selectionStart // Posición del cursor
setInput(value) setInput(value)
// Buscamos si el carácter anterior al cursor es ':'
const lastChar = value.slice(selectionStart - 1, selectionStart) const lastChar = value.slice(selectionStart - 1, selectionStart)
if (lastChar === ':') { if (lastChar === ':') {
setShowSuggestions(true) setShowSuggestions(true)
} else if (!value.includes(':')) { } else if (!value.includes(':')) {
// Si borran todos los dos puntos, cerramos
setShowSuggestions(false) setShowSuggestions(false)
} }
} }
@@ -301,11 +329,17 @@ export function IAAsignaturaTab() {
const filteredFields = useMemo(() => { const filteredFields = useMemo(() => {
if (!showSuggestions || !input) return availableFields if (!showSuggestions || !input) return availableFields
// 1. Encontrar el ":" más cercano a la IZQUIERDA del cursor
// Usamos una posición de referencia (si no tienes ref, usaremos el final del string,
// pero para mayor precisión lo ideal es usar e.target.selectionStart en el onChange)
const lastColonIndex = input.lastIndexOf(':') const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex === -1) return availableFields if (lastColonIndex === -1) return availableFields
// 2. Extraer solo el fragmento de "búsqueda"
// Cortamos desde el ":" hasta el final, y luego tomamos solo la primera palabra
const textAfterColon = input.slice(lastColonIndex + 1) const textAfterColon = input.slice(lastColonIndex + 1)
const query = textAfterColon.split(/\s/)[0].toLowerCase() const query = textAfterColon.split(/\s/)[0].toLowerCase() // Se detiene al encontrar un espacio
if (!query) return availableFields if (!query) return availableFields
@@ -319,21 +353,28 @@ export function IAAsignaturaTab() {
useEffect(() => { useEffect(() => {
const state = location.state as any const state = location.state as any
// Verificamos si existe el timestamp (_ts) para saber que es una acción nueva
if (state?.activeTab === 'ia' && state?._ts) { if (state?.activeTab === 'ia' && state?._ts) {
// Si el campo no está ya seleccionado, lo agregamos
if (state.prefillCampo) { if (state.prefillCampo) {
const fieldToSelect = availableFields.find( const fieldToSelect = availableFields.find(
(f) => f.key === state.prefillCampo, (f) => f.key === state.prefillCampo,
) )
if (fieldToSelect) { if (fieldToSelect) {
setSelectedFields([fieldToSelect]) setSelectedFields([fieldToSelect]) // Reemplaza o añade según prefieras
// Generamos un prompt inicial automático
const autoPrompt = `Mejora el contenido de: ${fieldToSelect.label}` const autoPrompt = `Mejora el contenido de: ${fieldToSelect.label}`
setInput(autoPrompt) setInput(autoPrompt)
// Opcional: Si quieres que dispare la IA inmediatamente al llegar:
// handleSend(autoPrompt)
} }
} }
} }
}, [location.state, availableFields]) }, [location.state, availableFields])
// 2. Efecto para cerrar con ESC
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowSuggestions(false) if (e.key === 'Escape') setShowSuggestions(false)
@@ -342,6 +383,7 @@ export function IAAsignaturaTab() {
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, []) }, [])
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => { const handleSelectField = (field: SelectedField) => {
if (!selectedFields.find((f) => f.key === field.key)) { if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field]) setSelectedFields((prev) => [...prev, field])
@@ -350,11 +392,16 @@ export function IAAsignaturaTab() {
const lastColonIndex = input.lastIndexOf(':') const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) { if (lastColonIndex !== -1) {
const parteAntesDelColon = input.slice(0, lastColonIndex) const parteAntesDelColon = input.slice(0, lastColonIndex)
// Buscamos si hay texto después de la palabra que estamos escribiendo
const textoDespuesDelColon = input.slice(lastColonIndex + 1) const textoDespuesDelColon = input.slice(lastColonIndex + 1)
const espacioIndex = textoDespuesDelColon.indexOf(' ') const espacioIndex = textoDespuesDelColon.indexOf(' ')
// Si hay un espacio, guardamos lo que sigue. Si no, es el final del texto.
const parteRestante = const parteRestante =
espacioIndex !== -1 ? textoDespuesDelColon.slice(espacioIndex) : '' espacioIndex !== -1 ? textoDespuesDelColon.slice(espacioIndex) : ''
// Reconstruimos: [Antes] + [Label] + [Lo que ya estaba después]
const nuevoTexto = `${parteAntesDelColon}${field.label}${parteRestante}` const nuevoTexto = `${parteAntesDelColon}${field.label}${parteRestante}`
setInput(nuevoTexto) setInput(nuevoTexto)
} }
@@ -376,17 +423,21 @@ export function IAAsignaturaTab() {
setIsSending(true) setIsSending(true)
try { try {
const response = await sendMessage({ const response = await sendMessage({
subjectId: asignaturaId as any, subjectId: asignaturaId as any, // Importante: se usa para crear la conv si activeChatId es undefined
content: text, content: text,
campos: selectedFields.map((f) => f.key), campos: selectedFields.map((f) => f.key),
conversacionId: activeChatId, conversacionId: activeChatId, // Si es undefined, la mutación crea el chat automáticamente
}) })
// IMPORTANTE: Después de la respuesta, actualizamos el ID activo con el que creó el backend
if (response.conversacionId) { if (response.conversacionId) {
setActiveChatId(response.conversacionId) setActiveChatId(response.conversacionId)
} }
setInput('') setInput('')
// setSelectedFields([])
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-subject', asignaturaId], queryKey: ['conversation-by-subject', asignaturaId],
}) })
@@ -406,9 +457,10 @@ export function IAAsignaturaTab() {
} }
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
// Opcional: podrías forzar el foco al textarea aquí con una ref
} }
const PRESETS = [ const PRESETS = [
@@ -433,19 +485,19 @@ export function IAAsignaturaTab() {
] ]
return ( return (
<div className="bg-background flex h-full w-full overflow-hidden"> <div className="flex h-full w-full overflow-hidden bg-white">
<div className="bg-background/80 fixed top-0 z-40 flex w-full items-center justify-between border-b p-2 backdrop-blur-md"> <div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground" className="text-slate-600"
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
> >
<History size={18} className="mr-2" /> Historial <History size={18} className="mr-2" /> Historial
</Button> </Button>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-primary text-[9px] font-bold tracking-wider uppercase"> <span className="text-[9px] font-bold tracking-wider text-teal-600 uppercase">
Asistente Asistente
</span> </span>
</div> </div>
@@ -453,17 +505,16 @@ export function IAAsignaturaTab() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground" className="text-slate-600"
onClick={() => setOpenIA(true)} onClick={() => setOpenIA(true)} // O el drawer de acciones/referencias
> >
<FileText size={18} className="text-primary mr-2" /> Referencias <FileText size={18} className="mr-2 text-teal-600" /> Referencias
</Button> </Button>
</div> </div>
{/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */} {/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */}
<aside className="bg-background hidden h-full w-64 shrink-0 flex-col border-r pr-4 md:flex"> <aside className="hidden h-full w-64 shrink-0 flex-col border-r bg-white pr-4 md:flex">
<div className="mb-4 flex items-center justify-between px-2 pt-4"> <div className="mb-4 flex items-center justify-between px-2 pt-4">
<h2 className="text-muted-foreground flex items-center gap-2 text-xs font-bold uppercase"> <h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
<History size={14} /> Historial <History size={14} /> Historial
</h2> </h2>
<Button <Button
@@ -471,7 +522,7 @@ export function IAAsignaturaTab() {
size="icon" size="icon"
className={cn( className={cn(
'h-8 w-8', 'h-8 w-8',
showArchived && 'bg-accent text-accent-foreground', showArchived && 'bg-teal-50 text-teal-600',
)} )}
onClick={() => setShowArchived(!showArchived)} onClick={() => setShowArchived(!showArchived)}
> >
@@ -488,7 +539,7 @@ export function IAAsignaturaTab() {
queryClient.setQueryData(['subject-messages', undefined], []) queryClient.setQueryData(['subject-messages', undefined], [])
}} }}
variant="outline" variant="outline"
className="border-border text-muted-foreground hover:border-primary hover:bg-primary/10 mb-4 w-full justify-start gap-2 border-dashed" className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 text-slate-600 hover:border-teal-500 hover:bg-teal-50/50"
> >
<MessageSquarePlus size={18} /> Nuevo Chat <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
@@ -499,10 +550,11 @@ export function IAAsignaturaTab() {
<div <div
key={chat.id} key={chat.id}
className={cn( 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 w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id activeChatId === chat.id
? 'bg-accent text-foreground font-medium' ? 'bg-teal-50 text-teal-900'
: 'text-muted-foreground hover:bg-muted', : 'text-slate-600 hover:bg-slate-100',
)} )}
onDoubleClick={() => { onDoubleClick={() => {
setEditingId(chat.id) setEditingId(chat.id)
@@ -513,7 +565,7 @@ export function IAAsignaturaTab() {
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 flex-1 items-center">
<input <input
autoFocus autoFocus
className="bg-background ring-primary w-full rounded border-none px-1 text-xs ring-1 outline-none" className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName} value={tempName}
onChange={(e) => setTempName(e.target.value)} onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)} onBlur={() => handleSaveName(chat.id)}
@@ -525,6 +577,7 @@ export function IAAsignaturaTab() {
</div> </div>
) : ( ) : (
<> <>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span <span
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1" className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
@@ -533,12 +586,13 @@ export function IAAsignaturaTab() {
{chat.nombre || chat.titulo || 'Conversación'} {chat.nombre || chat.titulo || 'Conversación'}
</span> </span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div <div
className={cn( className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100', 'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id activeChatId === chat.id
? 'bg-accent' ? 'bg-teal-50'
: 'bg-transparent', : 'bg-slate-100',
)} )}
> >
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
@@ -550,7 +604,7 @@ export function IAAsignaturaTab() {
setEditingId(chat.id) setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '') setTempName(chat.nombre || chat.titulo || '')
}} }}
className="hover:bg-muted hover:text-primary rounded-md p-1 transition-colors" className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
> >
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
@@ -575,10 +629,10 @@ export function IAAsignaturaTab() {
}) })
}} }}
className={cn( className={cn(
'hover:bg-muted rounded-md p-1 transition-colors', 'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA' chat.estado === 'ACTIVA'
? 'hover:text-destructive' ? 'hover:text-red-500'
: 'hover:text-primary', : 'hover:text-teal-600',
)} )}
> >
{chat.estado === 'ACTIVA' ? ( {chat.estado === 'ACTIVA' ? (
@@ -604,16 +658,16 @@ export function IAAsignaturaTab() {
</ScrollArea> </ScrollArea>
</aside> </aside>
{/* 2. PANEL CENTRAL (CHAT) */} {/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
<main <main
className={cn( className={cn(
'bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden shadow-sm', 'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50/50 shadow-sm',
'mt-[50px] h-[calc(100svh-50px)]', 'mt-[50px] h-[calc(100svh-50px)]', // 50px es la altura aproximada de tu header fixed
'md:border-border md:m-2 md:mt-0 md:h-[calc(100vh-160px)] md:rounded-xl md:border', 'md:m-2 md:mt-0 md:h-[calc(100vh-160px)] md:rounded-xl md:border md:border-slate-200',
)} )}
> >
{/* Header Interno */} {/* Header Interno del Recuadro */}
<div className="bg-background flex shrink-0 items-center justify-between border-b p-3"> <div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
@@ -621,13 +675,13 @@ export function IAAsignaturaTab() {
className="md:hidden" className="md:hidden"
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
> >
<History size={20} className="text-muted-foreground" /> <History size={20} className="text-slate-500" />
</Button> </Button>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-primary text-[10px] font-bold tracking-wider uppercase"> <span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
Asistente Académico Asistente Académico
</span> </span>
<span className="text-muted-foreground text-[11px]"> <span className="text-[11px] text-slate-400">
Personalizado para tu asignatura Personalizado para tu asignatura
</span> </span>
</div> </div>
@@ -636,12 +690,12 @@ export function IAAsignaturaTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setOpenIA(true)} onClick={() => setOpenIA(true)}
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors" className="flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
> >
<FileText size={14} /> <FileText size={14} />
<span className="xs:inline hidden">Referencias</span> <span className="xs:inline hidden">Referencias</span>
{totalReferencias > 0 && ( {totalReferencias > 0 && (
<span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]"> <span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias} {totalReferencias}
</span> </span>
)} )}
@@ -665,8 +719,8 @@ export function IAAsignaturaTab() {
className={cn( className={cn(
'h-9 w-9 shrink-0 border shadow-sm', 'h-9 w-9 shrink-0 border shadow-sm',
msg.role === 'assistant' msg.role === 'assistant'
? 'bg-primary text-primary-foreground' ? 'bg-teal-600 text-white'
: 'bg-muted text-muted-foreground', : 'bg-slate-100',
)} )}
> >
<AvatarFallback> <AvatarFallback>
@@ -688,10 +742,11 @@ export function IAAsignaturaTab() {
className={cn( className={cn(
'relative overflow-hidden rounded-2xl border shadow-sm', 'relative overflow-hidden rounded-2xl border shadow-sm',
msg.role === 'user' msg.role === 'user'
? 'border-primary bg-primary text-primary-foreground rounded-tr-none px-4 py-3' ? 'rounded-tr-none border-teal-700 bg-teal-600 px-4 py-3 text-white'
: 'bg-card text-card-foreground border-border w-full rounded-tl-none', : 'w-full rounded-tl-none border-slate-200 bg-white text-slate-800',
)} )}
> >
{/* Texto del mensaje principal */}
<div <div
style={{ whiteSpace: 'pre-line' }} style={{ whiteSpace: 'pre-line' }}
className={cn( className={cn(
@@ -702,27 +757,27 @@ export function IAAsignaturaTab() {
{msg.content} {msg.content}
</div> </div>
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
{msg.role === 'assistant' && {msg.role === 'assistant' &&
msg.sugerencias?.length > 0 && ( msg.sugerencias?.length > 0 && (
<div className="border-border bg-muted/50 space-y-3 border-t p-3"> <div className="space-y-3 border-t bg-slate-50/50 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground text-[10px] font-bold uppercase"> <p className="text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles Mejoras disponibles
</p> </p>
{msg.sugerencias.some( {/* Solo mostramos "Seleccionar todo" si hay sugerencias pendientes en ESTE mensaje */}
(s: any) => !s.aceptada, {msg.sugerencias.some((s) => !s.aceptada) && (
) && (
<Button <Button
variant="ghost" variant="ghost"
className="text-primary hover:bg-primary/10 h-6 text-[10px]" className="h-6 text-[10px] text-teal-600 hover:bg-teal-100"
onClick={() => onClick={() =>
toggleAllFromMessage(msg.sugerencias) toggleAllFromMessage(msg.sugerencias)
} }
> >
{msg.sugerencias {msg.sugerencias
.filter((s: any) => !s.aceptada) .filter((s) => !s.aceptada)
.every((s: any) => .every((s) =>
selectedImprovements.includes(s.id), selectedImprovements.includes(s.id),
) )
? 'Desmarcar todas' ? 'Desmarcar todas'
@@ -736,7 +791,7 @@ export function IAAsignaturaTab() {
{!sug.aceptada && ( {!sug.aceptada && (
<input <input
type="checkbox" type="checkbox"
className="border-input accent-primary mt-4 h-4 w-4 rounded" className="mt-4 h-4 w-4 rounded border-slate-300 accent-teal-600"
checked={selectedImprovements.includes( checked={selectedImprovements.includes(
sug.id, sug.id,
)} )}
@@ -759,16 +814,17 @@ export function IAAsignaturaTab() {
</div> </div>
))} ))}
{msg.sugerencias.some((s: any) => {/* EL BOTÓN DE APLICAR: Lo movemos para que solo aparezca si hay algo seleccionado de ESTE mensaje */}
{msg.sugerencias.some((s) =>
selectedImprovements.includes(s.id), selectedImprovements.includes(s.id),
) && ( ) && (
<Button <Button
size="sm" size="sm"
disabled={isSending} disabled={isSending}
className="w-full text-xs font-bold shadow-md" className="w-full bg-teal-600 text-xs font-bold shadow-md hover:bg-teal-700"
onClick={() => { onClick={() => {
const seleccionadasDeEsteMensaje = const seleccionadasDeEsteMensaje =
msg.sugerencias.filter((s: any) => msg.sugerencias.filter((s) =>
selectedImprovements.includes(s.id), selectedImprovements.includes(s.id),
) )
handleApplyMultiple( handleApplyMultiple(
@@ -795,38 +851,40 @@ export function IAAsignaturaTab() {
))} ))}
{isAiThinking && ( {isAiThinking && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4"> <div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm"> <Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback> <AvatarFallback>
<Sparkles size={16} className="animate-pulse" /> <Sparkles size={16} className="animate-pulse" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<div className="bg-card border-border rounded-2xl rounded-tl-none border p-4 shadow-sm"> <div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div> </div>
</div> </div>
<span className="text-muted-foreground text-[10px] font-medium italic"> <span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud... La IA está analizando tu solicitud...
</span> </span>
</div> </div>
</div> </div>
)} )}
{/* Espacio extra al final para que el scroll no tape el último mensaje */}
<div className="h-4" /> <div className="h-4" />
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
{/* Input de Chat */} {/* Input de Chat */}
<footer className="bg-background border-t p-3 md:p-4"> <footer className="shrink-0 border-t bg-white p-3 md:p-4">
<div className="shrink-0 border-t bg-white p-2 md:p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{showSuggestions && ( {showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border shadow-2xl"> <div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="bg-muted text-muted-foreground flex justify-between border-b px-3 py-2 text-[10px] font-bold uppercase"> <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>Filtrando campos...</span>
<span className="bg-muted-foreground/20 text-muted-foreground rounded px-1 text-[9px]"> <span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar ESC para cerrar
</span> </span>
</div> </div>
@@ -836,20 +894,20 @@ export function IAAsignaturaTab() {
<button <button
key={field.key} key={field.key}
onClick={() => handleSelectField(field)} onClick={() => handleSelectField(field)}
className="hover:bg-accent flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors" className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-foreground font-medium"> <span className="font-medium text-slate-700">
{field.label} {field.label}
</span> </span>
</div> </div>
{selectedFields.find((f) => f.key === field.key) && ( {selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-primary" /> <Check size={14} className="text-teal-600" />
)} )}
</button> </button>
)) ))
) : ( ) : (
<div className="text-muted-foreground p-4 text-center text-xs italic"> <div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias No se encontraron coincidencias
</div> </div>
)} )}
@@ -857,19 +915,19 @@ export function IAAsignaturaTab() {
</div> </div>
)} )}
<div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1"> <div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{selectedFields.length > 0 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1"> <div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
<div <div
key={field.key} key={field.key}
className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-bold shadow-sm" className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
> >
<Target size={10} /> <Target size={10} />
{field.label} {field.label}
<button <button
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors" className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
> >
<X size={10} /> <X size={10} />
</button> </button>
@@ -894,6 +952,8 @@ export function IAAsignaturaTab() {
lastColonIndex + 1, lastColonIndex + 1,
) )
// Si hay un espacio después del ":", cerramos sugerencias (ya no es un comando)
// Si no hay espacio, activamos
if (!textSinceColon.includes(' ')) { if (!textSinceColon.includes(' ')) {
setShowSuggestions(true) setShowSuggestions(true)
} else { } else {
@@ -915,39 +975,37 @@ export function IAAsignaturaTab() {
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={
(!input.trim() && selectedFields.length === 0) || isSending (!input.trim() && selectedFields.length === 0) ||
isSending
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0" className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
> >
{isSending ? ( <Send size={16} className="text-white" />
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div>
</footer> </footer>
</main> </main>
{/* 3. PANEL DERECHO (ATAJOS) */} {/* 3. PANEL DERECHO (ATAJOS) */}
<aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto p-4 lg:flex"> <aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto p-4 lg:flex">
<h4 className="text-foreground flex items-center gap-2 text-sm font-bold"> <h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-primary" /> Atajos Rápidos <Lightbulb size={18} className="text-orange-500" /> Atajos Rápidos
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
onClick={() => handleSend(preset.prompt)} onClick={() => handleSend(preset.prompt)}
className="bg-card border-border hover:border-primary group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm transition-all hover:shadow-sm" className="group flex w-full items-center gap-3 rounded-xl border border-slate-100 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:shadow-sm"
> >
<div className="bg-muted group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors"> <div className="rounded-lg bg-slate-50 p-2 group-hover:bg-teal-50 group-hover:text-teal-600">
<preset.icon size={16} /> <preset.icon size={16} />
</div> </div>
<span className="text-muted-foreground group-hover:text-foreground font-medium"> <span className="font-medium text-slate-600 group-hover:text-slate-900">
{preset.label} {preset.label}
</span> </span>
</button> </button>
@@ -957,14 +1015,14 @@ export function IAAsignaturaTab() {
{/* DRAWERS (Referencias e Historial Móvil) */} {/* DRAWERS (Referencias e Historial Móvil) */}
<Drawer open={openIA} onOpenChange={setOpenIA}> <Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl"> <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="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3"> <div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase"> <h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Referencias para la IA Referencias para la IA
</h2> </h2>
<button <button
onClick={() => setOpenIA(false)} onClick={() => setOpenIA(false)}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-slate-400 hover:text-slate-600"
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -993,9 +1051,10 @@ export function IAAsignaturaTab() {
<Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}> <Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<DrawerPortal> <DrawerPortal>
<DrawerOverlay /> <DrawerOverlay className="fixed inset-0 bg-black/40" />
<DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none"> <DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none">
<div className="flex h-full flex-col overflow-hidden pt-8"> <div className="flex h-full flex-col overflow-hidden pt-8">
{/* Reutiliza aquí el componente de la lista de chats */}
<Button <Button
onClick={() => { onClick={() => {
setActiveChatId(undefined) setActiveChatId(undefined)
@@ -1003,10 +1062,10 @@ export function IAAsignaturaTab() {
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
queryClient.setQueryData(['subject-messages', undefined], []) queryClient.setQueryData(['subject-messages', undefined], [])
setIsSidebarOpen(false) setIsSidebarOpen(false) // Cierra el drawer al crear nuevo
}} }}
variant="outline" variant="outline"
className="border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 mb-6 w-full justify-center gap-2 border-dashed" className="mb-6 w-full justify-center gap-2 border-dashed border-teal-500 bg-teal-50/50 text-teal-700 hover:bg-teal-100"
> >
<MessageSquarePlus size={18} /> Nuevo Chat <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
@@ -1015,10 +1074,11 @@ export function IAAsignaturaTab() {
<div <div
key={chat.id} key={chat.id}
className={cn( 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 w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id activeChatId === chat.id
? 'bg-accent text-accent-foreground font-medium' ? 'bg-teal-50 text-teal-900'
: 'text-muted-foreground hover:bg-muted', : 'text-slate-600 hover:bg-slate-100',
)} )}
onDoubleClick={() => { onDoubleClick={() => {
setEditingId(chat.id) setEditingId(chat.id)
@@ -1029,7 +1089,7 @@ export function IAAsignaturaTab() {
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 flex-1 items-center">
<input <input
autoFocus autoFocus
className="bg-background ring-primary w-full rounded border-none px-1 text-xs ring-1 outline-none" className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName} value={tempName}
onChange={(e) => setTempName(e.target.value)} onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)} onBlur={() => handleSaveName(chat.id)}
@@ -1041,6 +1101,7 @@ export function IAAsignaturaTab() {
</div> </div>
) : ( ) : (
<> <>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span <span
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1" className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
@@ -1049,12 +1110,13 @@ export function IAAsignaturaTab() {
{chat.nombre || chat.titulo || 'Conversación'} {chat.nombre || chat.titulo || 'Conversación'}
</span> </span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div <div
className={cn( className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100', 'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id activeChatId === chat.id
? 'bg-accent' ? 'bg-teal-50'
: 'bg-transparent', : 'bg-slate-100',
)} )}
> >
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
@@ -1066,7 +1128,7 @@ export function IAAsignaturaTab() {
setEditingId(chat.id) setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '') setTempName(chat.nombre || chat.titulo || '')
}} }}
className="hover:bg-muted hover:text-primary rounded-md p-1 transition-colors" className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
> >
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
@@ -1091,10 +1153,10 @@ export function IAAsignaturaTab() {
}) })
}} }}
className={cn( className={cn(
'hover:bg-muted rounded-md p-1 transition-colors', 'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA' chat.estado === 'ACTIVA'
? 'hover:text-destructive' ? 'hover:text-red-500'
: 'hover:text-primary', : 'hover:text-teal-600',
)} )}
> >
{chat.estado === 'ACTIVA' ? ( {chat.estado === 'ACTIVA' ? (
@@ -88,19 +88,19 @@ export function ImprovementCard({
{valor.map((u: any, idx: number) => ( {valor.map((u: any, idx: number) => (
<div <div
key={idx} key={idx}
className="bg-card border-primary/20 rounded-md border p-2 shadow-sm" className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
> >
<div className="border-border/50 text-primary mb-1 flex items-center gap-2 border-b pb-1 text-[11px] font-bold"> <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} <BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{u.temas?.map((t: any, tidx: number) => ( {u.temas?.map((t: any, tidx: number) => (
<li <li
key={tidx} key={tidx}
className="text-muted-foreground flex items-start justify-between gap-2 text-[10px]" className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
> >
<span className="leading-tight"> {t.nombre}</span> <span className="leading-tight"> {t.nombre}</span>
<span className="text-muted-foreground/70 flex shrink-0 items-center gap-0.5 font-mono"> <span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
<Clock size={10} /> {t.horasEstimadas}h <Clock size={10} /> {t.horasEstimadas}h
</span> </span>
</li> </li>
@@ -116,24 +116,24 @@ export function ImprovementCard({
if (valor[0]?.hasOwnProperty('criterio')) { if (valor[0]?.hasOwnProperty('criterio')) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-muted-foreground/70 mb-1 flex items-center gap-2 text-[10px] font-bold uppercase"> <div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
<ListChecks size={12} /> Desglose de evaluación <ListChecks size={12} /> Desglose de evaluación
</div> </div>
{valor.map((c: any, idx: number) => ( {valor.map((c: any, idx: number) => (
<div <div
key={idx} key={idx}
className="bg-card border-border flex items-center justify-between gap-3 rounded-md border p-2 shadow-sm" className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
> >
<span className="text-foreground text-[11px] leading-tight"> <span className="text-[11px] leading-tight text-slate-700">
{c.criterio} {c.criterio}
</span> </span>
<div className="border-accent/30 bg-accent/10 text-accent flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold"> <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}% {c.porcentaje}%
</div> </div>
</div> </div>
))} ))}
{/* Opcional: Suma total para verificar que de 100% */} {/* Opcional: Suma total para verificar que de 100% */}
<div className="text-muted-foreground/70 pt-1 text-right text-[9px] font-medium"> <div className="pt-1 text-right text-[9px] font-medium text-slate-400">
Total:{' '} Total:{' '}
{valor.reduce( {valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0), (acc: number, curr: any) => acc + (curr.porcentaje || 0),
@@ -156,17 +156,17 @@ export function ImprovementCard({
// --- ESTADO APLICADO --- // --- ESTADO APLICADO ---
if (sug.aceptada) { if (sug.aceptada) {
return ( return (
<div className="bg-card border-border flex flex-col rounded-xl border p-3 opacity-80 shadow-sm"> <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"> <div className="mb-3 flex items-center justify-between gap-4">
<span className="text-foreground text-sm font-bold"> <span className="text-sm font-bold text-slate-800">
{sug.campoNombre} {sug.campoNombre}
</span> </span>
<div className="border-border bg-muted/50 text-muted-foreground flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium"> <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} /> <Check size={14} />
Aplicado Aplicado
</div> </div>
</div> </div>
<div className="border-primary/20 bg-primary/5 text-muted-foreground rounded-lg border p-3 text-xs leading-relaxed"> <div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
{renderContenido(sug.valorSugerido)} {renderContenido(sug.valorSugerido)}
</div> </div>
</div> </div>
@@ -175,16 +175,16 @@ export function ImprovementCard({
// --- ESTADO PENDIENTE --- // --- ESTADO PENDIENTE ---
return ( return (
<div className="bg-card border-primary/20 hover:border-primary/40 group flex flex-col rounded-xl border p-3 shadow-sm transition-all"> <div className="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"> <div className="mb-3 flex items-center justify-between gap-4">
<span className="border-primary/20 bg-primary/10 text-primary max-w-[150px] truncate rounded-lg border px-2.5 py-1 text-[10px] font-bold tracking-wider uppercase"> <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} {sug.campoNombre}
</span> </span>
<Button <Button
size="sm" size="sm"
disabled={isApplying || !asignatura} disabled={isApplying || !asignatura}
className="h-8 w-auto px-4 text-xs font-semibold shadow-sm" className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
onClick={handleApply} onClick={handleApply}
> >
{isApplying ? ( {isApplying ? (
@@ -198,7 +198,7 @@ export function ImprovementCard({
<div <div
className={cn( className={cn(
'border-border/60 bg-muted/30 text-muted-foreground rounded-lg border border-dashed p-3 text-xs leading-relaxed', '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', !Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)} )}
> >
@@ -1,83 +0,0 @@
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface Props {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
titulo?: string
descripcion?: string
}
export const AlertaConflicto = ({
isOpen,
onOpenChange,
onConfirm,
titulo,
descripcion,
}: Props) => {
// Intentamos parsear el mensaje si viene como JSON para la lista de materias
let contenido
try {
const data = JSON.parse(descripcion as any)
contenido = (
<div className="space-y-3">
<p className="text-sm text-slate-600">{data.main}</p>
<div className="flex flex-wrap gap-2 py-2">
{data.materias.map((m: string, i: number) => (
<span
key={i}
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
>
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
{m}
</span>
))}
</div>
<p className="mt-2 text-xs font-semibold text-slate-500">
¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
seriación)?
</p>
</div>
)
} catch {
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
}
return (
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-6 w-6" />
</div>
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
{titulo}
</AlertDialog.Title>
</div>
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
<AlertDialog.Cancel asChild>
<Button variant="ghost">Cancelar</Button>
{/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
onClick={onConfirm}
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
>
Mover de todos modos
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
@@ -297,7 +297,7 @@ export function PasoFuenteClonadoInterno({
Selecciona una asignatura fuente (solo una). Selecciona una asignatura fuente (solo una).
</div> </div>
<div className="grid max-h-80 gap-2 overflow-y-auto px-1"> <div className="grid max-h-80 gap-2 overflow-y-auto">
{subjectsLoading ? ( {subjectsLoading ? (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
Cargando asignaturas Cargando asignaturas
-7
View File
@@ -9,13 +9,6 @@ export function LoginCard() {
return ( return (
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl"> <div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
<div className="flex justify-center">
<img
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
alt="La Salle México"
className="mb-6 h-20 w-auto"
/>
</div>
<h1 className="mb-1 text-center text-2xl font-semibold"> <h1 className="mb-1 text-center text-2xl font-semibold">
Iniciar sesión Iniciar sesión
</h1> </h1>
+2 -19
View File
@@ -14,7 +14,6 @@ interface PlanEstudiosCardProps {
facultad: string facultad: string
estado: string estado: string
claseColorEstado?: string claseColorEstado?: string
colorEstadoHex?: string
colorFacultad: string colorFacultad: string
onClick?: () => void onClick?: () => void
} }
@@ -27,17 +26,9 @@ export default function PlanEstudiosCard({
facultad, facultad,
estado, estado,
claseColorEstado = '', claseColorEstado = '',
colorEstadoHex,
colorFacultad, colorFacultad,
onClick, onClick,
}: PlanEstudiosCardProps) { }: PlanEstudiosCardProps) {
const badgeStyle = colorEstadoHex
? ({
backgroundColor: colorEstadoHex,
borderColor: colorEstadoHex,
} as const)
: undefined
return ( return (
<Card <Card
onClick={onClick} onClick={onClick}
@@ -45,7 +36,7 @@ export default function PlanEstudiosCard({
'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg', 'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
)} )}
> >
<div className="flex grow flex-col"> <div className="flex flex-grow flex-col">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
{/* Círculo del ícono con el color de la facultad */} {/* Círculo del ícono con el color de la facultad */}
<div <div
@@ -72,16 +63,8 @@ export default function PlanEstudiosCard({
</div> </div>
<CardFooter className="flex items-center justify-between pt-0 pb-6"> <CardFooter className="flex items-center justify-between pt-0 pb-6">
<Badge <Badge className={cn('text-sm font-semibold', claseColorEstado)}>
style={badgeStyle}
className={cn(
'text-sm font-semibold',
!colorEstadoHex && claseColorEstado,
)}
>
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
{estado} {estado}
</span>
</Badge> </Badge>
{/* Flecha animada */} {/* Flecha animada */}
@@ -56,9 +56,10 @@ export const ImprovementCard = ({
if (onApplySuccess) onApplySuccess(key) if (onApplySuccess) onApplySuccess(key)
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) { if (dbMessageId) {
updateAppliedStatus.mutate({ updateAppliedStatus.mutate({
conversacionId: dbMessageId, conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
campoAfectado: key, campoAfectado: key,
}) })
} }
@@ -80,18 +81,21 @@ export const ImprovementCard = ({
return ( return (
<div <div
key={sug.key} key={sug.key}
className={`bg-card rounded-2xl border p-5 shadow-sm transition-all ${ className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
isApplied ? 'border-primary/30 bg-primary/5' : 'border-border' isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
}`} }`}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-foreground text-sm font-bold">{sug.label}</h3> <h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
<Button <Button
size="sm" size="sm"
onClick={() => handleApply(sug.key, sug.newValue)} onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied || !!isUpdating} disabled={isApplied || !!isUpdating}
variant={isApplied ? 'secondary' : 'default'} className={`h-8 rounded-full px-4 text-xs transition-all ${
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]'
}`}
> >
{isUpdating ? ( {isUpdating ? (
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin" />
@@ -108,8 +112,8 @@ export const ImprovementCard = ({
<div <div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${ className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied isApplied
? 'border-primary/20 bg-primary/10 text-foreground' ? 'border-teal-100 bg-teal-50/50 text-slate-700'
: 'border-border bg-muted/50 text-muted-foreground' : 'border-slate-200 bg-slate-50 text-slate-500'
}`} }`}
> >
{sug.newValue} {sug.newValue}
+26
View File
@@ -0,0 +1,26 @@
import { StatusBadge } from "./StatusBadge";
export function PlanCard({ plan }: any) {
return (
<div className="bg-[#eaf6fa] rounded-2xl p-6 border hover:shadow-md transition">
<div className="flex justify-between items-start mb-4">
<span className="text-sm text-gray-500"> Ingeniería</span>
<StatusBadge status={plan.status} />
</div>
<h3 className="text-lg font-semibold text-gray-900">
{plan.title}
</h3>
<p className="text-sm text-gray-600 mb-6">
{plan.subtitle}
</p>
<div className="flex justify-between text-sm text-gray-500">
<span>{plan.cycles} ciclos</span>
<span>{plan.credits} créditos</span>
<span></span>
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { PlanCard } from './PlanCard'
const mockPlans = [
{
id: 1,
name: 'Ingeniería en Sistemas',
level: 'Licenciatura',
status: 'Activo',
},
{
id: 2,
name: 'Arquitectura',
level: 'Licenciatura',
status: 'Activo',
},
{
id: 3,
name: 'Maestría en Educación',
level: 'Maestría',
status: 'Inactivo',
},
]
export function PlanGrid() {
return (
<div>
<h2 className="text-lg font-semibold mb-4">
Planes disponibles
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{mockPlans.map(plan => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export function StatCard({ icon, value, label }: any) {
return (
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center gap-3 mb-2">
<span className="text-xl">{icon}</span>
<span className="text-2xl font-semibold">{value}</span>
</div>
<p className="text-sm text-gray-500">{label}</p>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { StatCard } from "./StatCard";
export function StatsGrid() {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard icon="📘" value="12" label="Planes Activos" />
<StatCard icon="🕒" value="4" label="En Revisión" />
<StatCard icon="✅" value="8" label="Aprobados" />
<StatCard icon="👥" value="6" label="Carreras" />
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
export function StatusBadge({ status }: { status: string }) {
const styles: any = {
revision: 'bg-blue-100 text-blue-700',
aprobado: 'bg-green-100 text-green-700',
borrador: 'bg-gray-200 text-gray-600',
}
return (
<span
className={`text-xs px-3 py-1 rounded-full font-medium ${styles[status]}`}
>
{status === 'revision'
? 'En Revisión'
: status === 'aprobado'
? 'Aprobado'
: 'Borrador'}
</span>
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import type { VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
+1 -3
View File
@@ -221,9 +221,7 @@ export async function plan_lineas_list(
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('lineas_plan') .from('lineas_plan')
.select( .select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
'id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en,color',
)
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('orden', { ascending: true }) .order('orden', { ascending: true })
+1 -53
View File
@@ -499,7 +499,6 @@ export async function lineas_insert(linea: {
plan_estudio_id: string plan_estudio_id: string
orden: number orden: number
area?: string area?: string
color?: string | null
}) { }) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
@@ -515,12 +514,7 @@ export async function lineas_insert(linea: {
// Actualizar una línea existente // Actualizar una línea existente
export async function lineas_update( export async function lineas_update(
lineaId: string, lineaId: string,
patch: { patch: { nombre?: string; orden?: number; area?: string },
nombre?: string
orden?: number
area?: string
color?: string | null
},
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
@@ -591,49 +585,3 @@ export async function bibliografia_delete(id: string) {
if (error) throw error if (error) throw error
return id return id
} }
export async function checkPrerrequisitoConflicts(
asignaturaId: string,
nuevoCiclo: number,
): Promise<Array<string>> {
const supabase = supabaseBrowser()
// CORRECCIÓN 1: Agregamos 'id' al select
// CORRECCIÓN 2: Quitamos espacios en el .or()
const { data, error } = await supabase
.from('asignaturas')
.select('id, nombre, numero_ciclo, prerrequisito_asignatura_id')
.or(`prerrequisito_asignatura_id.eq.${asignaturaId},id.eq.${asignaturaId}`)
if (error) throw error
// CORRECCIÓN 3: 'data' ahora está disponible porque lo desestructuramos arriba
if (!data) return []
const conflictos: Array<string> = []
data.forEach((asig) => {
// Caso 1: Materias que tienen a esta como prerrequisito (Hijas)
// "Si yo me muevo al ciclo 5, y mi hija está en el 4, se rompe la regla"
if (asig.prerrequisito_asignatura_id === asignaturaId) {
if (asig.numero_ciclo !== null && asig.numero_ciclo <= nuevoCiclo) {
conflictos.push(asig.nombre)
}
}
// Caso 2: El prerrequisito de la materia que estoy moviendo (Padre)
// "Si yo me muevo al ciclo 2, y mi padre está en el 3, se rompe la regla"
if (asig.id === asignaturaId && asig.prerrequisito_asignatura_id) {
const padre = data.find((p) => p.id === asig.prerrequisito_asignatura_id)
if (
padre &&
padre.numero_ciclo !== null &&
padre.numero_ciclo >= nuevoCiclo
) {
conflictos.push(padre.nombre)
}
}
})
return conflictos
}
-33
View File
@@ -1,5 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { import {
ai_generate_subject, ai_generate_subject,
@@ -7,7 +6,6 @@ import {
bibliografia_delete, bibliografia_delete,
bibliografia_insert, bibliografia_insert,
bibliografia_update, bibliografia_update,
checkPrerrequisitoConflicts,
lineas_insert, lineas_insert,
lineas_update, lineas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
@@ -319,34 +317,3 @@ export function useDeleteBibliografia(asignaturaId: string) {
}, },
}) })
} }
export function useAsignaturaConflictos() {
const [isValidating, setIsValidating] = useState(false)
const validarCambioCiclo = async (
asignaturaId: string,
nuevoCiclo: number,
) => {
setIsValidating(true)
try {
const nombresConflictivos = await checkPrerrequisitoConflicts(
asignaturaId,
nuevoCiclo,
)
if (nombresConflictivos.length > 0) {
const mensaje = `Si mueves esta materia al ciclo ${nuevoCiclo}, se perderá la seriación con:\n\n• ${nombresConflictivos.join('\n• ')}\n\n¿Deseas continuar?`
return confirm(mensaje) // Puedes usar un Modal de Shadcn aquí en lugar de confirm
}
return true // Sin conflictos
} catch (error) {
console.error(error)
return false
} finally {
setIsValidating(false)
}
}
return { validarCambioCiclo, isValidating }
}
+1 -1
View File
@@ -30,7 +30,7 @@ function RouteComponent() {
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop). 4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
5. py-6: Padding vertical (opcional, para separarse del header). 5. py-6: Padding vertical (opcional, para separarse del header).
*/} */}
<div className="mx-auto flex w-full flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8"> <div className="mx-auto flex w-full max-w-400 flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
<DashboardHeader <DashboardHeader
nombre="Dr. Carlos Mendoza" nombre="Dr. Carlos Mendoza"
rol="Jefe de Carrera" rol="Jefe de Carrera"
+1 -1
View File
@@ -8,7 +8,7 @@ export const Route = createFileRoute('/login')({
function LoginPage() { function LoginPage() {
return ( return (
<div className="login-bg flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#7b0f1d] via-[#6b0d1a] to-[#3a050a]">
<LoginCard /> <LoginCard />
</div> </div>
) )
+62 -91
View File
@@ -8,24 +8,18 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react' import { useState, useEffect, forwardRef } from 'react'
import type { Database } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { import {
Select, DropdownMenu,
SelectContent, DropdownMenuContent,
SelectItem, DropdownMenuItem,
SelectTrigger, DropdownMenuTrigger,
SelectValue, } from '@/components/ui/dropdown-menu'
} from '@/components/ui/select' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api' import { plans_get } from '@/data/api/plans.api'
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans' import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
import { cn } from '@/lib/utils'
type NivelPlanEstudio = Database['public']['Enums']['nivel_plan_estudio']
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => { loader: async ({ context: { queryClient }, params: { planId } }) => {
@@ -60,26 +54,28 @@ function RouteComponent() {
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
const [nivelPlan, setNivelPlan] = useState<NivelPlanEstudio | undefined>( const [nivelPlan, setNivelPlan] = useState('')
undefined, const [isDirty, setIsDirty] = useState(false)
)
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setNombrePlan(data.nombre || '') setNombrePlan(data.nombre || '')
setNivelPlan(data.nivel) setNivelPlan(data.nivel || '')
} }
}, [data]) }, [data])
const niveles: Array<NivelPlanEstudio> = [ const niveles = [
'Licenciatura', 'Licenciatura',
'Maestría', 'Maestría',
'Doctorado', 'Doctorado',
'Especialidad',
'Diplomado', 'Diplomado',
'Otro', 'Especialidad',
] ]
const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const MAX_CHARACTERS = 200 const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -118,20 +114,20 @@ function RouteComponent() {
} }
} }
return ( return (
<div className="bg-background min-h-screen"> <div className="min-h-screen bg-white">
{/* 1. Header Superior */} {/* 1. Header Superior */}
<div className="bg-background/80 sticky top-0 z-20 border-b shadow-sm backdrop-blur-sm"> <div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2"> <div className="px-6 py-2">
<Link <Link
to="/planes" to="/planes"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-xs transition-colors" className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
> >
<ChevronLeft size={14} /> Volver a planes <ChevronLeft size={14} /> Volver a planes
</Link> </Link>
</div> </div>
</div> </div>
<div className="mx-auto space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8"> <div className="mx-auto max-w-400 space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
{/* 2. Header del Plan */} {/* 2. Header del Plan */}
{isLoading ? ( {isLoading ? (
/* ===== SKELETON ===== */ /* ===== SKELETON ===== */
@@ -143,7 +139,7 @@ function RouteComponent() {
) : ( ) : (
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div> <div>
<h1 className="text-foreground flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight"> <h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
{/* El prefijo "Nivel en" lo mantenemos simple */} {/* El prefijo "Nivel en" lo mantenemos simple */}
<span className="shrink-0">{nivelPlan} en</span> <span className="shrink-0">{nivelPlan} en</span>
<span <span
@@ -152,105 +148,80 @@ function RouteComponent() {
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
spellCheck={false} spellCheck={false}
aria-label="Nombre del plan"
title="Nombre del plan"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste} // Añadido para controlar lo que pegan
onBlur={(e) => { onBlur={(e) => {
const nuevoNombre = e.currentTarget.textContent.trim() const nuevoNombre =
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre) setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) { if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } }) mutate({ planId, patch: { nombre: nuevoNombre } })
} }
}} }}
className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent wrap-break-word whitespace-pre-wrap no-underline transition-colors outline-none select-text sm:inline-block sm:w-auto" // Clases añadidas: break-words y whitespace-pre-wrap para el wrap
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
style={{ textDecoration: 'none' }}
> >
{nombrePlan} {nombrePlan}
</span> </span>
</h1> </h1>
<p className="text-muted-foreground mt-1 text-lg font-medium"> <p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '} {data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto} {data?.carreras?.nombre_corto}
</p> </p>
</div> </div>
{(() => { <div className="flex gap-2">
const estadoColorHex = (data?.estados_plan as any)?.color as <Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
| string
| undefined
const badgeStyle = estadoColorHex
? ({
backgroundColor: estadoColorHex,
borderColor: estadoColorHex,
} as const)
: undefined
return (
<Badge
style={badgeStyle}
className={cn(
'text-sm font-semibold',
!estadoColorHex &&
'border-primary/20 bg-primary/10 text-primary hover:bg-primary/20',
)}
>
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
{data?.estados_plan?.etiqueta} {data?.estados_plan?.etiqueta}
</span>
</Badge> </Badge>
) </div>
})()}
</div> </div>
)} )}
{/* 3. Cards de Información */} {/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all"> <DropdownMenu>
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm"> <DropdownMenuTrigger asChild>
<GraduationCap className="text-muted-foreground" /> <InfoCard
</div> icon={<GraduationCap className="text-slate-400" />}
<div className="min-w-0 flex-1"> label="Nivel"
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
Nivel
</p>
<Select
value={nivelPlan} value={nivelPlan}
onValueChange={(value) => { isEditable
const nuevoNivel = value as NivelPlanEstudio />
setNivelPlan(nuevoNivel) </DropdownMenuTrigger>
if (nuevoNivel !== data?.nivel) {
mutate({ planId, patch: { nivel: nuevoNivel } }) <DropdownMenuContent className="w-48">
{niveles.map((n) => (
<DropdownMenuItem
key={n}
onClick={() => {
setNivelPlan(n)
if (n !== data?.nivel) {
mutate({ planId, patch: { nivel: n } })
} }
}} }}
> >
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="---" />
</SelectTrigger>
<SelectContent>
{niveles.map((n) => (
<SelectItem key={n} value={n}>
{n} {n}
</SelectItem> </DropdownMenuItem>
))} ))}
</SelectContent> </DropdownMenuContent>
</Select> </DropdownMenu>
</div>
</div>
<InfoCard <InfoCard
icon={<Clock className="text-muted-foreground" />} icon={<Clock className="text-slate-400" />}
label="Duración" label="Duración"
value={`${data?.numero_ciclos || 0} Ciclos`} value={`${data?.numero_ciclos || 0} Ciclos`}
/> />
<InfoCard <InfoCard
icon={<Hash className="text-muted-foreground" />} icon={<Hash className="text-slate-400" />}
label="Créditos" label="Créditos"
value="320" value="320"
/> />
<InfoCard <InfoCard
icon={<CalendarDays className="text-muted-foreground" />} icon={<CalendarDays className="text-slate-400" />}
label="Creación" label="Creación"
value={data?.creado_en.split('T')[0]} value={data?.creado_en?.split('T')[0]}
/> />
</div> </div>
@@ -305,20 +276,20 @@ const InfoCard = forwardRef<
<div <div
ref={ref} ref={ref}
{...props} {...props}
className={`border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all ${ className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
isEditable isEditable
? 'hover:border-primary/50 hover:bg-accent focus-visible:ring-primary/40 cursor-pointer focus:outline-none focus-visible:ring-2' ? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
: '' : ''
} ${className ?? ''}`} } ${className ?? ''}`}
> >
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
{icon} {icon}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase"> <p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{label} {label}
</p> </p>
<p className="text-foreground truncate text-sm font-semibold"> <p className="truncate text-sm font-semibold text-slate-700">
{value || '---'} {value || '---'}
</p> </p>
</div> </div>
@@ -340,8 +311,8 @@ function Tab({
<Link <Link
to={to} to={to}
params={params} params={params}
className="text-muted-foreground hover:text-foreground border-b-2 border-transparent pb-3 text-sm font-medium transition-all" className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{ className: 'border-primary text-primary font-bold' }} activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
activeOptions={{ activeOptions={{
exact: true, exact: true,
}} }}
@@ -353,7 +324,7 @@ function Tab({
function DatosGeneralesSkeleton() { function DatosGeneralesSkeleton() {
return ( return (
<div className="bg-card rounded-xl border"> <div className="rounded-xl border bg-white">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b px-5 py-3"> <div className="flex items-center justify-between border-b px-5 py-3">
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
@@ -152,7 +152,7 @@ function AsignaturasPage() {
resetScroll: false, resetScroll: false,
}) })
}} }}
className="shadow-md" className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
> >
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura <Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
</Button> </Button>
@@ -122,7 +122,11 @@ function RouteComponent() {
> >
<RefreshCcw size={16} /> Regenerar <RefreshCcw size={16} /> Regenerar
</Button> </Button>
<Button size="sm" className="gap-2" onClick={handleDownloadWord}> <Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Download size={16} /> Descargar Word <Download size={16} /> Descargar Word
</Button> </Button>
<Button <Button
+1 -1
View File
@@ -146,7 +146,7 @@ function RouteComponent() {
/> />
</div> </div>
<Button className="w-full" disabled> <Button className="w-full bg-teal-600 hover:bg-teal-700" disabled>
Avanzar a Revisión Expertos Avanzar a Revisión Expertos
</Button> </Button>
</CardContent> </CardContent>
@@ -9,6 +9,7 @@ import {
User, User,
Loader2, Loader2,
Clock, Clock,
Eye,
History, History,
Calendar, Calendar,
ChevronLeft, ChevronLeft,
@@ -36,24 +37,24 @@ const getEventConfig = (tipo: string, campo: string) => {
return { return {
label: 'Creación', label: 'Creación',
icon: <PlusCircle className="h-4 w-4" />, icon: <PlusCircle className="h-4 w-4" />,
color: 'primary', color: 'teal',
} }
if (campo === 'estado') if (campo === 'estado')
return { return {
label: 'Cambio de estado', label: 'Cambio de estado',
icon: <GitBranch className="h-4 w-4" />, icon: <GitBranch className="h-4 w-4" />,
color: 'secondary', color: 'blue',
} }
if (campo === 'datos') if (campo === 'datos')
return { return {
label: 'Edición de Datos', label: 'Edición de Datos',
icon: <Edit3 className="h-4 w-4" />, icon: <Edit3 className="h-4 w-4" />,
color: 'accent', color: 'amber',
} }
return { return {
label: 'Actualización', label: 'Actualización',
icon: <RefreshCw className="h-4 w-4" />, icon: <RefreshCw className="h-4 w-4" />,
color: 'muted', color: 'slate',
} }
} }
@@ -103,7 +104,7 @@ function RouteComponent() {
}, },
} }
}) })
}, [rawData, structure, data]) }, [rawData])
const openCompareModal = (event: any) => { const openCompareModal = (event: any) => {
setSelectedEvent(event) setSelectedEvent(event)
@@ -119,7 +120,7 @@ function RouteComponent() {
if (isLoading) if (isLoading)
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin text-teal-600" />
</div> </div>
) )
@@ -127,8 +128,8 @@ function RouteComponent() {
<div className="mx-auto"> <div className="mx-auto">
<div className="mb-8 flex items-end justify-between"> <div className="mb-8 flex items-end justify-between">
<div> <div>
<h1 className="text-foreground flex items-center gap-2 text-xl font-bold"> <h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
<Clock className="text-primary h-5 w-5" /> Historial de Cambios del <Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
Plan Plan
</h1> </h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -138,11 +139,9 @@ function RouteComponent() {
</div> </div>
<div className="relative space-y-0"> <div className="relative space-y-0">
<div className="bg-border absolute top-0 bottom-0 left-6 w-px md:left-9" /> <div className="absolute top-0 bottom-0 left-6 w-px bg-slate-200 md:left-9" />
{historyEvents.length === 0 ? ( {historyEvents.length === 0 ? (
<div className="text-muted-foreground ml-20 py-10"> <div className="ml-20 py-10 text-slate-500">No hay registros.</div>
No hay registros.
</div>
) : ( ) : (
historyEvents.map((event) => ( historyEvents.map((event) => (
<div <div
@@ -150,27 +149,18 @@ function RouteComponent() {
className="group relative flex gap-3 pb-8 md:gap-6" className="group relative flex gap-3 pb-8 md:gap-6"
> >
<div className="relative z-10 flex flex-col items-center"> <div className="relative z-10 flex flex-col items-center">
<div className="border-background bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 shadow-sm transition-colors"> <div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
{event.icon} {event.icon}
</div> </div>
</div> </div>
<Card <Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
className="border-border hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
onClick={() => openCompareModal(event)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
openCompareModal(event)
}}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */} {/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-bold"> <span className="text-sm font-bold text-slate-800">
{event.type} {event.type}
</span> </span>
<Badge <Badge
@@ -185,7 +175,7 @@ function RouteComponent() {
</div> </div>
{/* Grupo de elementos alineados a la derecha */} {/* Grupo de elementos alineados a la derecha */}
<div className="text-muted-foreground flex flex-wrap items-center gap-3 md:gap-4"> <div className="flex flex-wrap items-center gap-3 text-slate-500 md:gap-4">
{/* Usuario e Icono */} {/* Usuario e Icono */}
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
<User className="h-3.5 w-3.5" /> <User className="h-3.5 w-3.5" />
@@ -194,8 +184,17 @@ function RouteComponent() {
</span> </span>
</div> </div>
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs font-medium text-teal-600 md:text-slate-500 md:hover:text-teal-600"
>
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */} {/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="text-muted-foreground/70 hidden text-[11px] lg:block"> <span className="hidden text-[11px] text-slate-400 lg:block">
{format(event.date, 'yyyy-MM-dd HH:mm')} {format(event.date, 'yyyy-MM-dd HH:mm')}
</span> </span>
</div> </div>
@@ -203,7 +202,7 @@ function RouteComponent() {
{/* LÍNEA INFERIOR: Descripción */} {/* LÍNEA INFERIOR: Descripción */}
<div className="mt-1"> <div className="mt-1">
<p className="text-muted-foreground text-sm"> <p className="text-sm text-slate-600">
{event.description} {event.description}
</p> </p>
@@ -214,16 +213,16 @@ function RouteComponent() {
<div className="mt-2 flex items-center gap-1.5"> <div className="mt-2 flex items-center gap-1.5">
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-destructive/10 text-destructive px-1.5 text-[9px]" className="bg-red-50 px-1.5 text-[9px] text-red-700"
> >
{event.details.from} {event.details.from}
</Badge> </Badge>
<span className="text-muted-foreground/70 text-[10px]"> <span className="text-[10px] text-slate-400">
</span> </span>
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-primary/10 text-primary px-1.5 text-[9px]" className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
> >
{event.details.to} {event.details.to}
</Badge> </Badge>
@@ -238,7 +237,7 @@ function RouteComponent() {
)} )}
{historyEvents.length > 0 && ( {historyEvents.length > 0 && (
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between"> <div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
<p className="text-muted-foreground text-xs"> <p className="text-xs text-slate-500">
Mostrando {rawData.length} de {totalRecords} cambios Mostrando {rawData.length} de {totalRecords} cambios
</p> </p>
@@ -256,7 +255,7 @@ function RouteComponent() {
Anterior Anterior
</Button> </Button>
<span className="text-foreground text-sm font-medium"> <span className="text-sm font-medium text-slate-700">
Página {page + 1} de {totalPages || 1} Página {page + 1} de {totalPages || 1}
</span> </span>
@@ -267,6 +266,7 @@ function RouteComponent() {
setPage((p) => p + 1) setPage((p) => p + 1)
window.scrollTo(0, 0) window.scrollTo(0, 0)
}} }}
// Ahora se deshabilita si llegamos a la última página real
disabled={page + 1 >= totalPages || isLoading} disabled={page + 1 >= totalPages || isLoading}
> >
Siguiente Siguiente
@@ -280,9 +280,9 @@ function RouteComponent() {
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */} {/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0"> <DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="bg-muted/50 border-b p-6"> <DialogHeader className="border-b bg-slate-50/50 p-6">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<History className="text-primary h-5 w-5" /> Comparación de <History className="h-5 w-5 text-teal-600" /> Comparación de
Versiones Versiones
</DialogTitle> </DialogTitle>
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs"> <div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
@@ -301,16 +301,17 @@ function RouteComponent() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6"> <div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
{/* Lado Antes: Solo se renderiza si existe valor_anterior */} {/* Lado Antes: Solo se renderiza si existe valor_anterior */}
{selectedEvent?.details.from && ( {selectedEvent?.details.from && (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1"> <div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="bg-destructive h-2 w-2 rounded-full" /> <div className="h-2 w-2 rounded-full bg-red-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase"> <span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior Versión Anterior
</span> </span>
</div> </div>
<div className="border-destructive/20 bg-destructive/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap"> <div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent.details.from)} {renderValue(selectedEvent.details.from)}
</div> </div>
</div> </div>
@@ -318,22 +319,27 @@ function RouteComponent() {
{/* Lado Después */} {/* Lado Después */}
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1"> <div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="bg-primary h-2 w-2 rounded-full" /> <div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase"> <span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Nueva Versión Nueva Versión
</span> </span>
</div> </div>
<div className="border-primary/20 bg-primary/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap"> <div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent?.details.to)} {renderValue(selectedEvent?.details.to)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-muted/30 flex justify-center border-t p-4"> <div className="flex justify-center border-t bg-slate-50 p-4">
<Badge variant="outline" className="font-mono text-[10px]"> <Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo} Campo: {selectedEvent?.campo}
{console.log(
data?.estructuras_plan?.definicion?.properties?.[
selectedEvent?.campo
]?.title,
)}
</Badge> </Badge>
</div> </div>
</DialogContent> </DialogContent>
+144 -81
View File
@@ -115,7 +115,7 @@ function RouteComponent() {
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } = const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId ?? null) useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -155,7 +155,8 @@ function RouteComponent() {
const definicion = data?.estructuras_plan const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion ?.definicion as EstructuraDefinicion
if (!definicion?.properties) return [] // Encadenamiento opcional para evitar errores si data es null
if (!definicion.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({ return Object.entries(definicion.properties).map(([key, value]) => ({
key, key,
@@ -168,24 +169,28 @@ function RouteComponent() {
return availableFields.filter( return availableFields.filter(
(field) => (field) =>
field.label.toLowerCase().includes(filterQuery.toLowerCase()) && field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
!selectedFields.some((s) => s.key === field.key), !selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
) )
}, [availableFields, filterQuery, selectedFields]) }, [availableFields, filterQuery, selectedFields])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
if (!activeChatId || !mensajesDelChat) return [] if (!activeChatId || !mensajesDelChat) return []
// flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
return mensajesDelChat.flatMap((msg: any) => { return mensajesDelChat.flatMap((msg: any) => {
const messages = [] const messages = []
// 1. Mensaje del Usuario
messages.push({ messages.push({
id: `${msg.id}-user`, id: `${msg.id}-user`,
role: 'user', role: 'user',
content: msg.mensaje, content: msg.mensaje,
selectedFields: msg.campos || [], selectedFields: msg.campos || [], // Aquí están tus campos
}) })
// 2. Mensaje del Asistente (si hay respuesta)
if (msg.respuesta) { if (msg.respuesta) {
// Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
const rawRecommendations = msg.propuesta?.recommendations || [] const rawRecommendations = msg.propuesta?.recommendations || []
messages.push({ messages.push({
@@ -214,6 +219,8 @@ function RouteComponent() {
}) })
}, [mensajesDelChat, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
// En tu componente principal (el padre)
const handleApplyMultiple = async ( const handleApplyMultiple = async (
sugerencias: Array<any>, sugerencias: Array<any>,
dbMessageId: string, dbMessageId: string,
@@ -251,12 +258,15 @@ function RouteComponent() {
conversacionId: dbMessageId, conversacionId: dbMessageId,
campoAfectado: sug.key, campoAfectado: sug.key,
}) })
console.log(sug.key)
removeSelectedField(sug.key) removeSelectedField(sug.key)
} catch (err) { } catch (err) {
console.error( console.error(
`Error al marcar aplicada la sugerencia: ${sug.key}`, `Error al marcar aplicada la sugerencia: ${sug.key}`,
err, err,
) )
// Si una falla, el bucle sigue con las demás
} }
} }
@@ -325,14 +335,17 @@ function RouteComponent() {
useEffect(() => { useEffect(() => {
if (chatMessages.length > 0) { if (chatMessages.length > 0) {
if (isInitialLoad.current) { if (isInitialLoad.current) {
// Si es el primer render con mensajes, vamos al final al instante
scrollToBottom('instant') scrollToBottom('instant')
isInitialLoad.current = false isInitialLoad.current = false
} else { } else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth') scrollToBottom('smooth')
} }
} }
}, [chatMessages]) }, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => { useEffect(() => {
isInitialLoad.current = true isInitialLoad.current = true
}, [activeChatId]) }, [activeChatId])
@@ -345,12 +358,14 @@ function RouteComponent() {
) )
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !currentChatExists && !isCreationMode) { if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
return return
} }
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if ( if (
!activeChatId && !activeChatId &&
activeChats.length > 0 && activeChats.length > 0 &&
@@ -384,7 +399,7 @@ function RouteComponent() {
}, [availableFields, routerState.location.state]) }, [availableFields, routerState.location.state])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
setMessages([ setMessages([
{ {
id: 'welcome', id: 'welcome',
@@ -393,6 +408,7 @@ function RouteComponent() {
}, },
]) ])
setInput('') setInput('')
// setSelectedFields([])
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -434,15 +450,16 @@ function RouteComponent() {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value const val = e.target.value
const cursorPosition = e.target.selectionStart const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
setInput(val) setInput(val)
// Busca un ":" seguido de letras justo antes del cursor
const textBeforeCursor = val.slice(0, cursorPosition) const textBeforeCursor = val.slice(0, cursorPosition)
const match = textBeforeCursor.match(/:(\w*)$/) const match = textBeforeCursor.match(/:(\w*)$/)
if (match) { if (match) {
setShowSuggestions(true) setShowSuggestions(true)
setFilterQuery(match[1]) setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
} else { } else {
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('') setFilterQuery('')
@@ -453,31 +470,40 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
// 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
// Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim() const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ') const fieldLabels = fields.map((f) => f.label).join(', ')
// 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
return `${cleaned}: ${fieldLabels}` return `${cleaned}: ${fieldLabels}`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => { setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key) const isSelected = prev.find((f) => f.key === field.key)
return isSelected ? prev : [...prev, field] return isSelected ? prev : [...prev, field]
}) })
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
setInput((prev) => { setInput((prev) => {
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
const nuevoTexto = prev.replace(/:(\w*)$/, field.label) const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
return nuevoTexto + ' ' return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
}) })
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('') setFilterQuery('')
} }
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => { const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
if (fields.length === 0) return userInput if (fields.length === 0) return userInput
return ` ${userInput}` return ` ${userInput}`
} }
@@ -490,6 +516,7 @@ function RouteComponent() {
setIsSending(true) setIsSending(true)
setOptimisticMessage(finalContent) setOptimisticMessage(finalContent)
setInput('') setInput('')
// setSelectedFields([])
try { try {
const payload = { const payload = {
@@ -508,6 +535,7 @@ function RouteComponent() {
setActiveChatId(response.conversacionId) setActiveChatId(response.conversacionId)
} }
// ESPERAMOS a que la caché se actualice antes de quitar el "isSending"
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], queryKey: ['conversation-by-plan', planId],
@@ -520,15 +548,19 @@ function RouteComponent() {
console.error('Error:', error) console.error('Error:', error)
setOptimisticMessage(null) setOptimisticMessage(null)
} finally { } finally {
// Solo ahora quitamos los indicadores de carga
setIsSending(false) setIsSending(false)
// setOptimisticMessage(null)
} }
} }
useEffect(() => { useEffect(() => {
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
// Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
// Ahora la validación es directa y no debería dar avisos de "unnecessary"
if (ultimoMensajeDB?.respuesta) { if (ultimoMensajeDB?.respuesta) {
setIsSyncing(false) setIsSyncing(false)
setOptimisticMessage(null) setOptimisticMessage(null)
@@ -544,13 +576,14 @@ function RouteComponent() {
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const removeSelectedField = (fieldKey: string) => { const removeSelectedField = (fieldKey: string) => {
console.log(fieldKey)
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey)) setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
} }
return ( return (
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden"> <div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
{/* --- HEADER MÓVIL --- */} {/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="bg-background flex shrink-0 items-center justify-between rounded-lg border p-2 shadow-sm md:hidden"> <div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -563,13 +596,14 @@ function RouteComponent() {
size="sm" size="sm"
onClick={() => setIsActionsOpen(true)} onClick={() => setIsActionsOpen(true)}
> >
<Lightbulb size={18} className="text-primary mr-2" /> Acciones <Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
</Button> </Button>
</div> </div>
{/* --- PANEL IZQUIERDO: HISTORIAL --- */} {/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
<div className="hidden w-64 flex-col border-r pr-4 md:flex"> <div className="hidden w-64 flex-col border-r pr-4 md:flex">
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase"> {/* ... (Tu código actual del historial de chats) ... */}
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats Chats
</h2> </h2>
<Button <Button
@@ -581,6 +615,8 @@ function RouteComponent() {
</Button> </Button>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-2"> <div className="space-y-1 pr-2">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
{!showArchived ? ( {!showArchived ? (
activeChats.map((chat) => ( activeChats.map((chat) => (
<div <div
@@ -588,19 +624,23 @@ function RouteComponent() {
onClick={() => setActiveChatId(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 items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id activeChatId === chat.id
? 'bg-accent text-foreground font-medium' ? 'bg-slate-100 font-medium text-slate-900'
: 'text-muted-foreground hover:bg-accent/50' : 'text-slate-600 hover:bg-slate-50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto */}
<div <div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200" className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{ style={{
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
// donde aparecen los botones
maskImage: maskImage:
'linear-gradient(to right, black 70%, transparent 95%)', 'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage: WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)', 'linear-gradient(to right, black 70%, transparent 95%)',
}} }}
> >
{/* pr-12 reserva espacio para los botones absolutos */}
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}> <TooltipProvider delayDuration={400}>
<Tooltip> <Tooltip>
@@ -614,7 +654,7 @@ function RouteComponent() {
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
className={`block truncate outline-none ${ className={`block truncate outline-none ${
editingChatId === chat.id editingChatId === chat.id
? 'bg-background ring-primary max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded px-1 break-all shadow-sm ring-1' ? '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' : 'cursor-pointer'
}`} }`}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -663,9 +703,10 @@ function RouteComponent() {
</TooltipProvider> </TooltipProvider>
</div> </div>
{/* LADO DERECHO: Acciones ABSOLUTAS */}
<div <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 ${ 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-accent' : 'bg-transparent' activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
}`} }`}
> >
<button <button
@@ -674,13 +715,13 @@ function RouteComponent() {
setEditingChatId(chat.id) setEditingChatId(chat.id)
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="text-muted-foreground hover:text-primary rounded-md p-1 transition-colors" className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
> >
<Send size={12} className="rotate-45" /> <Send size={12} className="rotate-45" />
</button> </button>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors" className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -688,14 +729,15 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* Sección de archivados (Simplificada para mantener consistencia) */
<div className="animate-in fade-in slide-in-from-left-2 px-1"> <div className="animate-in fade-in slide-in-from-left-2 px-1">
<p className="text-muted-foreground mb-2 px-2 text-[10px] font-bold uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedChats.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="bg-muted/50 text-muted-foreground group relative mb-1 flex w-full items-center overflow-hidden rounded-lg px-3 py-2 text-sm" 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"
> >
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10"> <div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
@@ -706,7 +748,7 @@ function RouteComponent() {
</div> </div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="bg-accent hover:text-primary absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100" className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -718,21 +760,22 @@ function RouteComponent() {
</ScrollArea> </ScrollArea>
</div> </div>
{/* --- PANEL DE CHAT PRINCIPAL --- */} {/* --- PANEL DE CHAT PRINCIPAL (Centro) --- */}
<div className="border-border/60 bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm md:h-full md:flex-[3]"> <div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:h-full md:flex-[3]">
<div className="bg-background z-10 shrink-0 border-b p-3"> {/* NUEVO: Barra superior de campos seleccionados */}
<div className="z-10 shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground text-[10px] font-bold uppercase"> <span className="text-[10px] font-bold text-slate-400 uppercase">
Mejorar con IA Mejorar con IA
</span> </span>
<button <button
onClick={() => setOpenIA(true)} onClick={() => setOpenIA(true)}
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition" 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="opacity-70" /> <Archive size={14} className="text-slate-500" />
Referencias Referencias
{totalReferencias > 0 && ( {totalReferencias > 0 && (
<span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]"> <span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias} {totalReferencias}
</span> </span>
)} )}
@@ -740,6 +783,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
{/* CONTENIDO DEL CHAT */}
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
@@ -749,12 +793,12 @@ function RouteComponent() {
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40"> <div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus <MessageSquarePlus
size={48} size={48}
className="text-muted-foreground/50 mb-4" className="mb-4 text-slate-300"
/> />
<h3 className="text-foreground text-lg font-medium"> <h3 className="text-lg font-medium text-slate-900">
No hay un chat seleccionado No hay un chat seleccionado
</h3> </h3>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-slate-500">
Selecciona un chat del historial o crea uno nuevo para Selecciona un chat del historial o crea uno nuevo para
empezar. empezar.
</p> </p>
@@ -764,6 +808,7 @@ function RouteComponent() {
{chatMessages.map((msg: any) => { {chatMessages.map((msg: any) => {
const isAI = msg.role === 'assistant' const isAI = msg.role === 'assistant'
const isUser = msg.role === 'user' const isUser = msg.role === 'user'
// IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
const isProcessing = msg.isProcessing const isProcessing = msg.isProcessing
return ( return (
@@ -776,44 +821,48 @@ function RouteComponent() {
<div <div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${ className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
isUser isUser
? 'bg-primary text-primary-foreground rounded-tr-none' ? 'rounded-tr-none bg-teal-600 text-white'
: `bg-card text-card-foreground rounded-tl-none border ${ : `rounded-tl-none border bg-white text-slate-700 ${
msg.isRefusal msg.isRefusal
? 'border-destructive/50 bg-destructive/10 ring-destructive/20 ring-1' ? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-border' : 'border-slate-100'
}` }`
}`} }`}
> >
{/* Aviso de Refusal */}
{msg.isRefusal && ( {msg.isRefusal && (
<div className="text-destructive mb-1 flex items-center gap-1 text-[10px] font-bold uppercase"> <div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span> <span>Aviso del Asistente</span>
</div> </div>
)} )}
{/* CONTENIDO CORRECTO: Usamos msg.content */}
{isAI && isProcessing ? ( {isAI && isProcessing ? (
<div className="flex items-center gap-2 py-1"> <div className="flex items-center gap-2 py-1">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
</div> </div>
</div> </div>
) : ( ) : (
msg.content msg.content // <--- CAMBIO CLAVE
)} )}
{/* Recomendaciones */}
{isAI && msg.suggestions?.length > 0 && ( {isAI && msg.suggestions?.length > 0 && (
<div className="border-border/60 bg-muted/50 mt-4 w-full space-y-3 rounded-xl border p-3"> <div className="mt-4 w-full space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<span className="text-muted-foreground text-[10px] font-bold uppercase"> <span className="text-[10px] font-bold text-slate-400 uppercase">
Sugerencias de mejora Sugerencias de mejora
</span> </span>
{/* Botón Seleccionar Todo */}
{msg.suggestions.some( {msg.suggestions.some(
(s: any) => !s.applied, (s: any) => !s.applied,
) && ( ) && (
<Button <Button
variant="ghost" variant="ghost"
className="text-primary hover:bg-primary/10 h-6 px-2 text-[10px]" className="h-6 px-2 text-[10px] text-teal-600 hover:bg-teal-100"
onClick={() => onClick={() =>
toggleAllFromMessage(msg.suggestions) toggleAllFromMessage(msg.suggestions)
} }
@@ -834,7 +883,7 @@ function RouteComponent() {
{!sug.applied && ( {!sug.applied && (
<input <input
type="checkbox" type="checkbox"
className="border-input accent-primary mt-4 h-4 w-4 shrink-0 rounded" className="mt-4 h-4 w-4 shrink-0 rounded border-slate-300 accent-teal-600"
checked={selectedImprovements.includes( checked={selectedImprovements.includes(
sug.key, sug.key,
)} )}
@@ -845,7 +894,7 @@ function RouteComponent() {
)} )}
<div className="flex-1"> <div className="flex-1">
<ImprovementCard <ImprovementCard
suggestions={[sug]} suggestions={[sug]} // Pasamos solo una para que la card actúe individualmente
dbMessageId={msg.dbMessageId} dbMessageId={msg.dbMessageId}
planId={planId} planId={planId}
currentDatos={data?.datos} currentDatos={data?.datos}
@@ -858,13 +907,14 @@ function RouteComponent() {
</div> </div>
))} ))}
{/* Botón de Acción Masiva específico para este mensaje */}
{msg.suggestions.some((s: any) => {msg.suggestions.some((s: any) =>
selectedImprovements.includes(s.key), selectedImprovements.includes(s.key),
) && ( ) && (
<Button <Button
size="sm" size="sm"
disabled={isSending} disabled={isSending}
className="w-full py-1 text-xs font-bold" className="w-full bg-teal-600 py-1 text-xs font-bold text-white hover:bg-teal-700"
onClick={() => { onClick={() => {
const seleccionadas = const seleccionadas =
msg.suggestions.filter((s: any) => msg.suggestions.filter((s: any) =>
@@ -902,20 +952,20 @@ function RouteComponent() {
{(isSending || isSyncing) && ( {(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4"> <div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm"> <Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback> <AvatarFallback>
<Sparkles size={16} className="animate-pulse" /> <Sparkles size={16} className="animate-pulse" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<div className="bg-card border-border rounded-2xl rounded-tl-none border p-4 shadow-sm"> <div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div> </div>
</div> </div>
<span className="text-muted-foreground text-[10px] font-medium italic"> <span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud... La IA está analizando tu solicitud...
</span> </span>
</div> </div>
@@ -926,8 +976,9 @@ function RouteComponent() {
</div> </div>
</ScrollArea> </ScrollArea>
{/* Botones flotantes de aplicación */}
{pendingSuggestion && !isLoading && ( {pendingSuggestion && !isLoading && (
<div className="animate-in fade-in slide-in-from-bottom-2 bg-card border-border absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border p-1.5 shadow-2xl"> <div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -936,19 +987,23 @@ function RouteComponent() {
> >
<X className="mr-1 h-3 w-3" /> Descartar <X className="mr-1 h-3 w-3" /> Descartar
</Button> </Button>
<Button size="sm" className="h-8 rounded-full text-xs"> <Button
size="sm"
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
>
<Check className="mr-1 h-3 w-3" /> Aplicar cambios <Check className="mr-1 h-3 w-3" /> Aplicar cambios
</Button> </Button>
</div> </div>
)} )}
</div> </div>
{/* INPUT FIJO AL FONDO */} {/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
<div className="bg-background border-border shrink-0 border-t p-4"> <div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full mb-2 w-full rounded-xl border shadow-2xl"> <div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl">
<div className="bg-muted text-muted-foreground border-b px-3 py-2 text-[10px] font-bold uppercase"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
Resultados para "{filterQuery}" Resultados para "{filterQuery}"
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
@@ -959,8 +1014,8 @@ function RouteComponent() {
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${ className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
index === 0 index === 0
? 'bg-primary/10 text-primary ring-primary/30 ring-1 ring-inset' ? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
: 'hover:bg-accent' : 'hover:bg-slate-50'
}`} }`}
> >
<span>{field.label}</span> <span>{field.label}</span>
@@ -972,7 +1027,7 @@ function RouteComponent() {
</button> </button>
)) ))
) : ( ) : (
<div className="text-muted-foreground p-3 text-center text-xs"> <div className="p-3 text-center text-xs text-slate-400">
No hay coincidencias No hay coincidencias
</div> </div>
)} )}
@@ -980,18 +1035,20 @@ function RouteComponent() {
</div> </div>
)} )}
<div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1"> {/* 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 ) */}
{selectedFields.length > 0 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1"> <div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
<div <div
key={field.key} key={field.key}
className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-semibold" 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"
> >
<span className="opacity-70">Campo:</span> {field.label} <span className="opacity-70">Campo:</span> {field.label}
<button <button
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors" className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200"
> >
<X size={10} /> <X size={10} />
</button> </button>
@@ -1000,6 +1057,7 @@ function RouteComponent() {
</div> </div>
)} )}
{/* 2. Área de escritura */}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<Textarea <Textarea
value={input} value={input}
@@ -1018,6 +1076,7 @@ function RouteComponent() {
setFilterQuery('') setFilterQuery('')
} }
} else { } else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if ( if (
e.key === 'Backspace' && e.key === 'Backspace' &&
input === '' && input === '' &&
@@ -1045,7 +1104,7 @@ function RouteComponent() {
isSending || (!input.trim() && selectedFields.length === 0) isSending || (!input.trim() && selectedFields.length === 0)
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0" className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
> >
{isSending ? ( {isSending ? (
<Loader2 className="animate-spin" size={16} /> <Loader2 className="animate-spin" size={16} />
@@ -1059,22 +1118,22 @@ function RouteComponent() {
</div> </div>
</div> </div>
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS --- */} {/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex"> <div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex">
<h4 className="text-foreground flex items-center gap-2 text-left text-sm font-bold"> <h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-primary" /> Acciones rápidas <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4> </h4>
<div className="space-y-2 p-1"> <div className="space-y-2 p-1">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
onClick={() => handleSend(preset.prompt)} onClick={() => handleSend(preset.prompt)}
className="bg-card hover:border-primary hover:bg-primary/5 group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm shadow-sm transition-all" className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
> >
<div className="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors"> <div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
<preset.icon size={16} /> <preset.icon size={16} />
</div> </div>
<span className="text-foreground leading-tight font-medium"> <span className="leading-tight font-medium text-slate-700">
{preset.label} {preset.label}
</span> </span>
</button> </button>
@@ -1090,12 +1149,13 @@ function RouteComponent() {
createNewChat() createNewChat()
setIsHistoryOpen(false) setIsHistoryOpen(false)
}} }}
className="mb-4 w-full" className="mb-4 w-full bg-teal-600 text-white"
> >
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat <MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
</Button> </Button>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<p className="text-muted-foreground mb-4 text-xs font-bold uppercase"> {/* Reutiliza aquí el mapeo de chats que tienes en el panel izquierdo */}
<p className="mb-4 text-xs font-bold text-slate-400 uppercase">
Historial Reciente Historial Reciente
</p> </p>
{activeChats.map((chat) => ( {activeChats.map((chat) => (
@@ -1105,7 +1165,7 @@ function RouteComponent() {
setActiveChatId(chat.id) setActiveChatId(chat.id)
setIsHistoryOpen(false) setIsHistoryOpen(false)
}} }}
className="border-border border-b p-3 text-sm" className="border-b p-3 text-sm"
> >
{chat.nombre || 'Chat sin nombre'} {chat.nombre || 'Chat sin nombre'}
</div> </div>
@@ -1118,7 +1178,7 @@ function RouteComponent() {
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}> <Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DrawerContent className="h-[60vh] p-4"> <DrawerContent className="h-[60vh] p-4">
<h4 className="mb-4 flex items-center gap-2 font-bold"> <h4 className="mb-4 flex items-center gap-2 font-bold">
<Lightbulb size={18} className="text-primary" /> Acciones rápidas <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4> </h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
@@ -1128,7 +1188,7 @@ function RouteComponent() {
handleSend(preset.prompt) handleSend(preset.prompt)
setIsActionsOpen(false) setIsActionsOpen(false)
}} }}
className="border-border flex items-center gap-3 rounded-xl border p-4 text-left text-sm" className="flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
> >
<preset.icon size={16} /> <preset.icon size={16} />
<span>{preset.label}</span> <span>{preset.label}</span>
@@ -1138,20 +1198,23 @@ function RouteComponent() {
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
{/* Tu Drawer de Referencias IA se queda igual */}
<Drawer open={openIA} onOpenChange={setOpenIA}> <Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl"> <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="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3"> {/* Cabecera más compacta */}
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase"> <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 Referencias para la IA
</h2> </h2>
<button <button
onClick={() => setOpenIA(false)} onClick={() => setOpenIA(false)}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-slate-400 transition-colors hover:text-slate-600"
> >
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* Contenido con scroll interno */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA <ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds} selectedArchivoIds={selectedArchivoIds}
+39 -20
View File
@@ -3,6 +3,7 @@ import {
useNavigate, useNavigate,
useLocation, useLocation,
} from '@tanstack/react-router' } from '@tanstack/react-router'
// import confetti from 'canvas-confetti'
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react' import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
@@ -19,6 +20,7 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlan, useUpdatePlanFields } from '@/data' import { usePlan, useUpdatePlanFields } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/')({ export const Route = createFileRoute('/planes/$planId/_detalle/')({
component: DatosGeneralesPage, component: DatosGeneralesPage,
}) })
@@ -32,20 +34,21 @@ function DatosGeneralesPage() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId) const { data, isLoading } = usePlan(planId)
const navigate = useNavigate() const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([]) const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const location = useLocation() const location = useLocation()
const updatePlan = useUpdatePlanFields() const updatePlan = useUpdatePlanFields()
// Confetti al llegar desde creación
useEffect(() => { useEffect(() => {
if (location.state.showConfetti) { if (location.state.showConfetti) {
lateralConfetti() lateralConfetti()
window.history.replaceState({}, document.title) window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
} }
}, [location.state]) }, [location.state])
// Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => { useEffect(() => {
const definicion = data?.estructuras_plan?.definicion as any const definicion = data?.estructuras_plan?.definicion as any
const properties = definicion?.properties const properties = definicion?.properties
@@ -56,13 +59,18 @@ function DatosGeneralesPage() {
if (properties && typeof properties === 'object') { if (properties && typeof properties === 'object') {
let keys = Object.keys(properties) let keys = Object.keys(properties)
// Ordenar llaves basado en la lista "required" si existe
if (Array.isArray(requiredOrder)) { if (Array.isArray(requiredOrder)) {
keys = keys.sort((a, b) => { keys = keys.sort((a, b) => {
const indexA = requiredOrder.indexOf(a) const indexA = requiredOrder.indexOf(a)
const indexB = requiredOrder.indexOf(b) const indexB = requiredOrder.indexOf(b)
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
if (indexA !== -1 && indexB === -1) return -1 if (indexA !== -1 && indexB === -1) return -1
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
if (indexA === -1 && indexB !== -1) return 1 if (indexA === -1 && indexB !== -1) return 1
// Si ambos están, comparar índices
if (indexA !== -1 && indexB !== -1) return indexA - indexB if (indexA !== -1 && indexB !== -1) return indexA - indexB
// Ninguno en la lista, mantener orden relativo
return 0 return 0
}) })
} }
@@ -100,14 +108,18 @@ function DatosGeneralesPage() {
} }
}, [data]) }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (nuevoCampo: DatosGeneralesField) => { const handleEdit = (nuevoCampo: DatosGeneralesField) => {
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
if (editingId && editingId !== nuevoCampo.id) { if (editingId && editingId !== nuevoCampo.id) {
const campoAnterior = campos.find((c) => c.id === editingId) const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) { if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue) ejecutarGuardadoSilencioso(campoAnterior, editValue)
} }
} }
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id) setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value) setEditValue(nuevoCampo.value)
} }
@@ -116,7 +128,7 @@ function DatosGeneralesPage() {
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = ( const prepararDatosActualizados = (
data: any, data: any,
campo: DatosGeneralesField, campo: DatosGeneralesField,
@@ -155,6 +167,7 @@ function DatosGeneralesPage() {
patch: { datos: datosActualizados }, patch: { datos: datosActualizados },
}) })
// Actualizar UI localmente
setCampos((prev) => setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
) )
@@ -172,11 +185,13 @@ function DatosGeneralesPage() {
currentValue !== null && currentValue !== null &&
'description' in currentValue 'description' in currentValue
) { ) {
// Caso 1: objeto con description
newValue = { newValue = {
...currentValue, ...currentValue,
description: editValue, description: editValue,
} }
} else { } else {
// Caso 2: valor plano (string, number, etc)
newValue = editValue newValue = editValue
} }
@@ -192,6 +207,7 @@ function DatosGeneralesPage() {
}, },
}) })
// UI optimista
setCampos((prev) => setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
) )
@@ -201,18 +217,17 @@ function DatosGeneralesPage() {
setEditValue('') setEditValue('')
} }
const handleIARequest = (campo: DatosGeneralesField) => { const handleIARequest = (clave: string) => {
navigate({ navigate({
to: '/planes/$planId/iaplan', to: '/planes/$planId/iaplan',
params: { params: {
planId: planId, planId: planId, // o dinámico
}, },
state: { state: {
campo_edit: campo.clave, campo_edit: clave,
} as any, } as any,
}) })
} }
return ( return (
<div className="animate-in fade-in duration-500"> <div className="animate-in fade-in duration-500">
<div className="mb-6"> <div className="mb-6">
@@ -231,19 +246,19 @@ function DatosGeneralesPage() {
return ( return (
<div <div
key={campo.id} key={campo.id}
className={`bg-card rounded-xl border transition-all ${ className={`rounded-xl border transition-all ${
isEditing isEditing
? 'border-primary ring-primary/20 shadow-lg ring-2' ? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
: 'hover:shadow-md' : 'bg-white hover:shadow-md'
}`} }`}
> >
{/* Header de la Card */} {/* Header de la Card */}
<TooltipProvider> <TooltipProvider>
<div className="bg-muted/30 flex items-center justify-between border-b px-5 py-3"> <div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<h3 className="text-foreground cursor-help text-sm font-medium"> <h3 className="cursor-help text-sm font-medium text-slate-700">
{campo.label} {campo.label}
</h3> </h3>
</TooltipTrigger> </TooltipTrigger>
@@ -253,7 +268,7 @@ function DatosGeneralesPage() {
</Tooltip> </Tooltip>
{campo.requerido && ( {campo.requerido && (
<span className="text-destructive text-xs">*</span> <span className="text-xs text-red-500">*</span>
)} )}
</div> </div>
@@ -264,7 +279,7 @@ function DatosGeneralesPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-primary hover:text-primary/90 h-8 w-8" className="h-8 w-8 text-teal-600"
onClick={() => handleIARequest(campo)} onClick={() => handleIARequest(campo)}
> >
<Sparkles size={14} /> <Sparkles size={14} />
@@ -278,7 +293,7 @@ function DatosGeneralesPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8" className="h-8 w-8"
onClick={() => handleEdit(campo)} onClick={() => handleEdit(campo)}
> >
<Pencil size={14} /> <Pencil size={14} />
@@ -309,7 +324,11 @@ function DatosGeneralesPage() {
> >
<X size={14} className="mr-1" /> Cancelar <X size={14} className="mr-1" /> Cancelar
</Button> </Button>
<Button size="sm" onClick={() => handleSave(campo)}> <Button
size="sm"
className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo)}
>
<Check size={14} className="mr-1" /> Guardar <Check size={14} className="mr-1" /> Guardar
</Button> </Button>
</div> </div>
@@ -317,12 +336,12 @@ function DatosGeneralesPage() {
) : ( ) : (
<div className="min-h-25"> <div className="min-h-25">
{campo.value ? ( {campo.value ? (
<div className="text-muted-foreground text-sm leading-relaxed"> <div className="text-sm leading-relaxed text-slate-600">
{campo.tipo === 'lista' ? ( {campo.tipo === 'lista' ? (
<ul className="space-y-1"> <ul className="space-y-1">
{campo.value.split('\n').map((item, i) => ( {campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2"> <li key={i} className="flex gap-2">
<span className="bg-primary mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" /> <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
{item} {item}
</li> </li>
))} ))}
@@ -332,7 +351,7 @@ function DatosGeneralesPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="text-muted-foreground/70 flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm text-slate-400">
<AlertCircle size={14} /> <AlertCircle size={14} />
<span>Sin contenido.</span> <span>Sin contenido.</span>
</div> </div>
+127 -391
View File
@@ -2,12 +2,12 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react' import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useMemo, useState, useEffect, Fragment, useRef } from 'react' import { useMemo, useState, useEffect, Fragment } from 'react'
import type { TipoAsignatura } from '@/data' import type { TipoAsignatura } from '@/data'
import type { Asignatura } from '@/types/plan' import type { Asignatura, LineaCurricular } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -17,8 +17,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -26,7 +24,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -34,7 +31,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { import {
useAsignaturaConflictos,
useCreateLinea, useCreateLinea,
useDeleteLinea, useDeleteLinea,
usePlan, usePlan,
@@ -43,8 +39,6 @@ import {
useUpdateAsignatura, useUpdateAsignatura,
useUpdateLinea, useUpdateLinea,
} from '@/data' } from '@/data'
import { cn } from '@/lib/utils'
import { generarColorContrastante } from '@/utils/colors'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const palette = [ const palette = [
@@ -58,16 +52,9 @@ const palette = [
'#C026D3', // fucsia '#C026D3', // fucsia
] ]
type LineaCurricularUI = {
id: string
nombre: string
orden: number
color: string
}
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
lineasApi: Array<any> = [], lineasApi: Array<any> = [],
): Array<LineaCurricularUI> => { ): Array<LineaCurricular> => {
return lineasApi.map((linea, index) => ({ return lineasApi.map((linea, index) => ({
id: linea.id, id: linea.id,
nombre: linea.nombre, nombre: linea.nombre,
@@ -289,6 +276,11 @@ function AsignaturaCardItem({
</div> </div>
</div> </div>
</div> </div>
{/* drag affordance */}
<div className="bg-background/70 pointer-events-none absolute right-3 bottom-3 rounded-full p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
<Icons.GripVertical className="text-muted-foreground/55 h-4 w-4" />
</div>
</div> </div>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -319,71 +311,20 @@ function MapaCurricularPage() {
const { data } = usePlan(planId) const { data } = usePlan(planId)
const [totalCiclos, setTotalCiclos] = useState(0) const [totalCiclos, setTotalCiclos] = useState(0)
const [editingLineaId, setEditingLineaId] = useState<string | null>(null) const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
const { mutate: createLinea, isPending: isCreatingLinea } = useCreateLinea() const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea() const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea() const { mutate: deleteLineaApi } = useDeleteLinea()
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([]) const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
const [lineas, setLineas] = useState<Array<LineaCurricularUI>>([]) const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>( const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
null, null,
) )
const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [isAddLineaDialogOpen, setIsAddLineaDialogOpen] = useState(false) const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const [selectedLineaOption, setSelectedLineaOption] = useState<
'matematicas' | 'area_comun' | 'custom' | ''
>('')
const [customLineaNombre, setCustomLineaNombre] = useState('')
const [ultimoHue, setUltimoHue] = useState<number | null>(null)
const { mutate: updateAsignatura } = useUpdateAsignatura() const { mutate: updateAsignatura } = useUpdateAsignatura()
const inputRef = useRef<HTMLInputElement>(null)
const { validarCambioCiclo } = useAsignaturaConflictos()
const [confirmState, setConfirmState] = useState<{
isOpen: boolean
resolve: (value: boolean) => void
mensaje: string
} | null>(null)
const validarConInterrupcion = async (
asignaturaId: string,
nuevoCiclo: number,
): Promise<boolean> => {
const asignatura = asignaturas.find((a) => a.id === asignaturaId)
if (!asignatura) return true
// Buscamos las materias que causan el conflicto
const materiasConflicto = asignaturas.filter((a) => {
const esPrerrequisitoConflictivo =
asignatura.prerrequisito_asignatura_id === a.id &&
(a.ciclo ?? 0) >= nuevoCiclo
const esDependienteConflictiva =
a.prerrequisito_asignatura_id === asignatura.id &&
(a.ciclo ?? 0) <= nuevoCiclo &&
a.ciclo !== null
return esPrerrequisitoConflictivo || esDependienteConflictiva
})
if (materiasConflicto.length === 0) return true
// Extraemos solo los nombres para la lista visual
const listaNombres = materiasConflicto.map((m) => m.nombre)
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
resolve,
// Guardamos la lista de nombres en el mensaje de forma que podamos procesarla
mensaje: JSON.stringify({
main: `Mover "${asignatura.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`,
materias: listaNombres,
}),
})
})
}
useEffect(() => { useEffect(() => {
if (data?.numero_ciclos) { if (data?.numero_ciclos) {
@@ -391,47 +332,7 @@ function MapaCurricularPage() {
} }
}, [data]) }, [data])
useEffect(() => { const manejarAgregarLinea = (nombre: string) => {
if (selectedLineaOption === 'custom' && inputRef.current) {
inputRef.current.focus()
}
}, [selectedLineaOption])
const handleCambioCicloSeguro = async (
asignatura: Asignatura,
nuevoCiclo: number,
) => {
// 1. Esperamos la interacción del usuario
const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo)
// 2. Limpiamos el estado del modal ANTES de seguir
setConfirmState(null)
// 3. Verificamos la respuesta
if (acepto === false) {
return // Detenemos la ejecución aquí
}
// 4. Solo si acepto es true, ejecutamos la API
const patch = { numero_ciclo: nuevoCiclo }
updateAsignatura(
{ asignaturaId: asignatura.id, patch: patch as any },
{
onSuccess: () => {
setAsignaturas((prev) =>
prev.map((m) =>
m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m,
),
)
},
onError: (err) => {
console.error('Error al actualizar:', err)
},
},
)
}
const manejarAgregarLinea = (nombre: string, color: string, hue: number) => {
const nombreNormalizado = nombre.trim() const nombreNormalizado = nombre.trim()
if (!nombreNormalizado) return if (!nombreNormalizado) return
const nombreBusqueda = nombreNormalizado const nombreBusqueda = nombreNormalizado
@@ -452,14 +353,12 @@ function MapaCurricularPage() {
return return
} }
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0) const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
createLinea( createLinea(
{ {
nombre: nombreNormalizado, nombre: nombreNormalizado,
plan_estudio_id: planId, plan_estudio_id: planId,
orden: maxOrden + 1, orden: maxOrden + 1,
area: 'sin asignar', area: 'sin asignar',
color,
}, },
{ {
onSuccess: (nueva) => { onSuccess: (nueva) => {
@@ -467,43 +366,15 @@ function MapaCurricularPage() {
id: nueva.id, id: nueva.id,
nombre: nueva.nombre, nombre: nueva.nombre,
orden: nueva.orden, orden: nueva.orden,
color: nueva.color ?? color, color: (nueva as any).color ?? '#1976d2',
} }
setLineas((prev) => [...prev, mapeada]) setLineas((prev) => [...prev, mapeada])
setUltimoHue(hue) setNombreNuevaLinea('')
setIsAddLineaDialogOpen(false)
setSelectedLineaOption('')
setCustomLineaNombre('')
},
onError: (err) => {
console.error('Error al crear linea:', err)
}, },
}, },
) )
} }
const canAddLinea =
selectedLineaOption === 'matematicas' ||
selectedLineaOption === 'area_comun' ||
(selectedLineaOption === 'custom' && customLineaNombre.trim().length > 0)
const handleAgregarLinea = () => {
if (!canAddLinea || isCreatingLinea) return
const nombreSeleccionado =
selectedLineaOption === 'matematicas'
? 'Matemáticas'
: selectedLineaOption === 'area_comun'
? 'Área Común'
: customLineaNombre.trim()
if (!nombreSeleccionado) return
const { hex, hue } = generarColorContrastante(ultimoHue)
manejarAgregarLinea(nombreSeleccionado, hex, hue)
}
const cambiarColorLinea = (lineaId: string, nuevoColor: string) => { const cambiarColorLinea = (lineaId: string, nuevoColor: string) => {
setLineas((prev) => setLineas((prev) =>
prev.map((l) => (l.id === lineaId ? { ...l, color: nuevoColor } : l)), prev.map((l) => (l.id === lineaId ? { ...l, color: nuevoColor } : l)),
@@ -550,6 +421,14 @@ function MapaCurricularPage() {
}, },
) )
} }
const tieneAreaComun = useMemo(() => {
return lineas.some(
(l) =>
l.nombre.toLowerCase() === 'área común' ||
l.nombre.toLowerCase() === 'area comun',
)
}, [lineas])
useEffect(() => { useEffect(() => {
if (asignaturaApi) if (asignaturaApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi)) setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
@@ -587,61 +466,48 @@ function MapaCurricularPage() {
return val return val
} }
const handleSaveChanges = () => {
const procesarCambioAsignatura = async ( if (!editingData) return
asignaturaId: string, setAsignaturas((prev) =>
nuevosDatos: Partial<Asignatura>, prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
) => {
const asignaturaOriginal = asignaturas.find((a) => a.id === asignaturaId)
if (!asignaturaOriginal) return
// ¿Cambió el ciclo? Si es así, validamos seriación
if (
nuevosDatos.ciclo !== undefined &&
nuevosDatos.ciclo !== asignaturaOriginal.ciclo
) {
const acepto = await validarConInterrupcion(
asignaturaId,
nuevosDatos.ciclo,
) )
setConfirmState(null) type AsignaturaPatch = {
if (!acepto) return // El usuario canceló, no guardamos nada codigo?: TablesUpdate<'asignaturas'>['codigo']
nombre?: TablesUpdate<'asignaturas'>['nombre']
tipo?: TablesUpdate<'asignaturas'>['tipo']
creditos?: TablesUpdate<'asignaturas'>['creditos']
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas']
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> = {
// Si llegamos aquí, o no cambió el ciclo o el usuario aceptó el conflicto nombre: editingData.nombre,
const patch = { codigo: editingData.clave,
nombre: nuevosDatos.nombre ?? asignaturaOriginal.nombre, creditos: editingData.creditos,
codigo: nuevosDatos.clave ?? asignaturaOriginal.clave, horas_academicas: editingData.hd,
numero_ciclo: nuevosDatos.ciclo, horas_independientes: editingData.hi,
linea_plan_id: nuevosDatos.lineaCurricularId, numero_ciclo: editingData.ciclo,
creditos: nuevosDatos.creditos, linea_plan_id: editingData.lineaCurricularId,
horas_academicas: nuevosDatos.hd, prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
horas_independientes: nuevosDatos.hi, tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
prerrequisito_asignatura_id: nuevosDatos.prerrequisito_asignatura_id,
tipo: nuevosDatos.tipo?.toUpperCase() as TipoAsignatura,
} }
updateAsignatura( updateAsignatura(
{ asignaturaId, patch: patch as any }, { asignaturaId: editingData.id, patch: patch as any },
{ {
onSuccess: () => { onSuccess: () => {
setAsignaturas((prev) => setIsEditModalOpen(false)
prev.map((m) => // Opcional: Mostrar un toast de éxito
m.id === asignaturaId ? { ...m, ...nuevosDatos } : m, },
), onError: (error) => {
) console.error('Error al guardar:', error)
setIsEditModalOpen(false) // Cerramos el modal si estaba abierto alert('Hubo un error al guardar los cambios.')
}, },
onError: (err) => console.error('Error al guardar:', err),
}, },
) )
} }
const handleSaveChanges = () => {
if (!editingData) return
// Llamamos a la lógica centralizada que incluye la alerta
procesarCambioAsignatura(editingData.id, editingData)
}
const unassignedAsignaturas = asignaturas.filter( const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null, (m) => m.ciclo === null || m.lineaCurricularId === null,
) )
@@ -707,22 +573,39 @@ function MapaCurricularPage() {
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
} }
const handleDragOver = (e: React.DragEvent) => e.preventDefault() const handleDragOver = (e: React.DragEvent) => e.preventDefault()
const handleDrop = async ( const handleDrop = (
e: React.DragEvent, e: React.DragEvent,
cicloDestino: number | null, cicloDestino: number | null,
lineaId: string | null, lineaId: string | null,
) => { ) => {
e.preventDefault() e.preventDefault()
if (!draggedAsignatura) return if (draggedAsignatura) {
// 1. Actualización optimista del UI
setAsignaturas((prev) =>
prev.map((m) =>
m.id === draggedAsignatura
? { ...m, ciclo: cicloDestino, lineaCurricularId: lineaId }
: m,
),
)
const patch = {
numero_ciclo: cicloDestino,
linea_plan_id: lineaId,
}
// Solo disparamos la lógica si realmente hay un cambio de posición updateAsignatura(
procesarCambioAsignatura(draggedAsignatura, { { asignaturaId: draggedAsignatura, patch },
ciclo: cicloDestino, {
lineaCurricularId: lineaId, onError: (error) => {
}) console.error('Error al mover:', error)
// Opcional: Revertir el estado local si falla
},
},
)
setDraggedAsignatura(null) setDraggedAsignatura(null)
} }
}
const stats = useMemo( const stats = useMemo(
() => () =>
@@ -779,36 +662,64 @@ function MapaCurricularPage() {
)} )}
</div> </div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button variant="outline" className="gap-2">
<Download size={16} /> Exportar
</Button>
{!tieneAreaComun && (
<Button <Button
variant="outline" variant="outline"
className={cn( className="text-primary border-primary/30 hover:bg-primary/8"
'inline-flex h-11 w-full items-center justify-start gap-2 rounded-md px-8 text-sm font-medium shadow-sm transition-colors', onClick={() => manejarAgregarLinea('Área Común')}
// Fondo verde claro y texto oscuro para contraste
'bg-green-100 text-green-900 hover:bg-green-200/80',
// Borde verde más oscuro y definido
'border border-green-600/30',
// Enfoque y estados (Accesibilidad)
'ring-offset-background focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2 focus-visible:outline-none',
// Soporte para modo oscuro (opcional pero recomendado)
'dark:border-green-500/40 dark:bg-green-900/30 dark:text-green-100 dark:hover:bg-green-900/50',
)}
> >
<Download + Área Común
size={16}
className="text-green-700 dark:text-green-400"
/>{' '}
Exportar a Excel
</Button> </Button>
)}
</div>
<div className="border-border/70 bg-background/70 rounded-xl border p-3 lg:col-span-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<label className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
Nueva Línea Curricular
</label>
<p className="text-muted-foreground text-xs">
Crea una línea personalizada sin abrir menús adicionales.
</p>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:min-w-90">
<Input
value={nombreNuevaLinea}
onChange={(e) => setNombreNuevaLinea(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && nombreNuevaLinea.trim()) {
manejarAgregarLinea(nombreNuevaLinea)
}
}}
placeholder="Ej: Optativas"
className="h-9"
/>
<Button
className="h-9 gap-1.5"
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
disabled={!nombreNuevaLinea.trim()}
>
<Plus size={14} /> Agregar
</Button>
</div>
</div>
</div>
</div>
</div>
{/* Barra Totales */} {/* Barra Totales */}
<div className="border-border bg-card/60 col-span-2 grid grid-cols-2 gap-3 rounded-2xl border p-3 shadow-sm md:grid-cols-4"> <div className="border-border bg-card/60 mb-8 grid grid-cols-2 gap-3 rounded-2xl border p-3 shadow-sm md:grid-cols-4">
<StatItem label="Total Créditos" value={stats.cr} total={320} /> <StatItem label="Total Créditos" value={stats.cr} total={320} />
<StatItem label="Total HD" value={stats.hd} /> <StatItem label="Total HD" value={stats.hd} />
<StatItem label="Total HI" value={stats.hi} /> <StatItem label="Total HI" value={stats.hi} />
<StatItem label="Total Horas" value={stats.hd + stats.hi} /> <StatItem label="Total Horas" value={stats.hd + stats.hi} />
</div> </div>
</div>
</div>
<div className="overflow-x-auto pb-6"> <div className="overflow-x-auto pb-6">
<div <div
@@ -992,20 +903,6 @@ function MapaCurricularPage() {
) )
})} })}
{/* Agregar línea (sticky dentro del overflow-x)
Nota: Se envuelve en un row `col-span-full` para evitar bugs de sticky en mobile/iOS
cuando el sticky es un grid-item. */}
<div className="col-span-full">
<div className="sticky left-0 z-10 w-35">
<Button
className="shadow-md"
onClick={() => setIsAddLineaDialogOpen(true)}
>
<Plus size={14} /> Agregar línea
</Button>
</div>
</div>
<div className="border-border col-span-full my-2 border-t"></div> <div className="border-border col-span-full my-2 border-t"></div>
<div className="text-foreground self-center p-2 font-bold"> <div className="text-foreground self-center p-2 font-bold">
@@ -1039,7 +936,7 @@ function MapaCurricularPage() {
) )
})} })}
<div className="text-accent-foreground border-accent/40 bg-accent flex flex-col justify-center rounded-xl border p-2 text-center text-xs font-bold shadow-sm"> <div className="text-primary-foreground border-primary/40 bg-primary flex flex-col justify-center rounded-xl border p-2 text-center text-xs font-bold shadow-sm">
<div>{stats.cr} Cr</div> <div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div> <div>{stats.hd + stats.hi} Hrs</div>
</div> </div>
@@ -1047,7 +944,7 @@ function MapaCurricularPage() {
</div> </div>
{/* Asignaturas Sin Asignar */} {/* Asignaturas Sin Asignar */}
<div className="border-border bg-card/80 mt-6 rounded-[28px] border p-5 shadow-sm backdrop-blur-sm"> <div className="border-border bg-card/80 mt-12 rounded-[28px] border p-5 shadow-sm backdrop-blur-sm">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1128,150 +1025,6 @@ function MapaCurricularPage() {
</div> </div>
{/* Modal de Edición */} {/* Modal de Edición */}
<Dialog
open={isAddLineaDialogOpen}
onOpenChange={(open) => {
setIsAddLineaDialogOpen(open)
if (!open) {
setSelectedLineaOption(undefined)
setCustomLineaNombre('')
}
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="text-foreground text-xl font-bold">
Agregar línea curricular
</DialogTitle>
</DialogHeader>
<RadioGroup
value={selectedLineaOption}
onValueChange={(val) =>
setSelectedLineaOption(val as typeof selectedLineaOption)
}
className="grid grid-cols-[1fr_auto_1fr] gap-8 py-4"
>
{/* Columna Izquierda: Predefinidas */}
<div className="space-y-4">
<div className="text-foreground mb-3 text-sm font-semibold">
Catálogo Institucional
</div>
{/* Tarjeta: Matemáticas */}
<div className="border-input has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5 hover:bg-muted/50 relative flex w-full items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none">
<RadioGroupItem
id="linea-matematicas"
value="matematicas"
className="mt-0.5 size-5 after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow gap-1">
<Label
htmlFor="linea-matematicas"
className="cursor-pointer font-semibold"
>
Matemáticas
</Label>
<p className="text-muted-foreground text-xs">
Línea base para ciencias exactas.
</p>
</div>
</div>
{/* Tarjeta: Área Común */}
<div className="border-input has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5 hover:bg-muted/50 relative flex w-full items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none">
<RadioGroupItem
id="linea-area-comun"
value="area_comun"
className="mt-0.5 size-5 after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow gap-1">
<Label
htmlFor="linea-area-comun"
className="cursor-pointer font-semibold"
>
Área Común
</Label>
<p className="text-muted-foreground text-xs">
Materias compartidas entre programas.
</p>
</div>
</div>
</div>
{/* Separador */}
<div className="flex justify-center">
<Separator orientation="vertical" />
</div>
{/* Columna Derecha: Personalizada */}
<div className="space-y-4">
<div className="text-foreground mb-3 text-sm font-semibold">
Línea personalizada
</div>
{/* Tarjeta: Custom */}
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() // Evita que la página haga scroll con el espacio
setSelectedLineaOption('custom')
inputRef.current?.focus()
}
}}
onClick={() => {
setSelectedLineaOption('custom')
inputRef.current?.focus()
}}
className={`focus-visible:ring-primary relative flex w-full cursor-pointer items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
selectedLineaOption === 'custom'
? 'border-primary/50 bg-primary/5'
: 'border-input hover:bg-muted/50'
}`}
>
{/* Omitimos after:absolute para no tapar el input */}
<RadioGroupItem
id="linea-custom"
value="custom"
className="mt-0.5 size-5 [&_svg]:size-3"
/>
<div className="grid w-full grow gap-3">
<Label
htmlFor="linea-custom"
className="cursor-pointer font-semibold"
>
Otra línea...
</Label>
<Input
ref={inputRef}
value={customLineaNombre}
onChange={(e) =>
setCustomLineaNombre(e.target.value.slice(0, 200))
}
placeholder="Escribe el nombre aquí"
maxLength={200}
disabled={selectedLineaOption !== 'custom'}
className="bg-background h-9 w-full"
/>
</div>
</div>
</div>
</RadioGroup>
<div className="mt-2 flex items-center justify-end gap-3 border-t pt-4">
<Button
className="shadow-md"
onClick={handleAgregarLinea}
disabled={!canAddLinea || isCreatingLinea}
>
<Plus size={16} /> Agregar
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}> <Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent <DialogContent
className="sm:max-w-137.5" className="sm:max-w-137.5"
@@ -1524,23 +1277,6 @@ function MapaCurricularPage() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{confirmState && (
<AlertaConflicto
isOpen={confirmState.isOpen}
onOpenChange={(open) => {
if (!open) {
confirmState.resolve(false)
setConfirmState(null)
}
}}
onConfirm={() => {
confirmState.resolve(true)
}}
titulo="Conflicto de Seriación"
descripcion={confirmState.mensaje}
/>
)}
</div> </div>
) )
} }
@@ -6,22 +6,12 @@ import {
useParams, useParams,
useRouterState, useRouterState,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { import { ArrowLeft, GraduationCap } from 'lucide-react'
ArrowLeft,
GraduationCap,
Pencil,
Hash,
BookOpen,
CalendarDays,
Tag,
} from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti' import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura, usePlanAsignaturas } from '@/data' import { useSubject, useUpdateAsignatura } from '@/data'
import { cn } from '@/lib/utils'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId', '/planes/$planId/asignaturas/$asignaturaId',
@@ -29,186 +19,65 @@ export const Route = createFileRoute(
component: AsignaturaLayout, component: AsignaturaLayout,
}) })
// --- 1. COMPONENTE PARA EDITAR EL TÍTULO SOBRE FONDO AZUL --- function EditableHeaderField({
function InlineEditTitle({
value, value,
onSave, onSave,
className,
}: { }: {
value: string
onSave: (val: string) => void
}) {
const [isEditing, setIsEditing] = useState(false)
const [tempVal, setTempVal] = useState(value)
useEffect(() => setTempVal(value), [value])
const handleSave = () => {
setIsEditing(false)
if (tempVal.trim() && tempVal !== value) onSave(tempVal.trim())
else setTempVal(value) // Revertir si está vacío
}
if (isEditing) {
return (
<input
autoFocus
value={tempVal}
onChange={(e) => setTempVal(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setTempVal(value)
setIsEditing(false)
}
}}
// Input estilizado para fondo oscuro: borde blanco sutil, texto blanco
className="focus:ring-primary/40 w-full rounded-md border-2 border-white/20 bg-transparent px-2 py-1 text-3xl font-bold text-white shadow-sm outline-none focus:ring-4"
/>
)
}
return (
<h1
onClick={() => setIsEditing(true)}
// Texto blanco por defecto, fondo blanco sutil al hover
className="group flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 text-3xl font-bold text-white transition-colors hover:bg-white/5"
>
{value}
{/* Lápiz blanco sutil */}
<Pencil className="h-5 w-5 text-white/50 opacity-0 transition-all group-hover:opacity-100 hover:text-white" />
</h1>
)
}
// --- 2. COMPONENTE PARA EDITAR LOS BADGES SOBRE FONDO AZUL ---
function InlineEditBadge({
icon,
label,
value,
suffix = '',
type = 'text',
onSave,
}: {
icon: React.ReactNode
label: string
value: string | number value: string | number
suffix?: string
type?: 'text' | 'number'
onSave: (val: string) => void onSave: (val: string) => void
className?: string
}) { }) {
const [isEditing, setIsEditing] = useState(false) const textValue = String(value)
const [tempVal, setTempVal] = useState(value)
useEffect(() => setTempVal(value), [value]) // Manejador para cuando el usuario termina de editar (pierde el foco)
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const handleSave = () => { const newValue = e.currentTarget.innerText
setIsEditing(false) if (newValue !== textValue) {
if (String(tempVal).trim() !== String(value)) { onSave(newValue)
onSave(String(tempVal))
} }
} }
if (isEditing) { const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
return ( if (e.key === 'Enter') {
// Contenedor del input con estética de badge oscuro e.preventDefault()
<div className="focus:ring-primary/40 flex h-8 items-center gap-2 rounded-md border border-white/20 bg-white/5 px-3 shadow-sm ring-1 focus-within:ring-2"> e.currentTarget.blur() // Forzamos el guardado al presionar Enter
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
{label}:
</span>
<input
autoFocus
type={type}
value={tempVal}
onChange={(e) => setTempVal(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setTempVal(value)
setIsEditing(false)
} }
}}
// Texto blanco dentro del input
className="w-16 bg-transparent text-sm font-semibold text-white outline-none"
/>
</div>
)
} }
return ( return (
<button // eslint-disable-next-line jsx-a11y/no-static-element-interactions
onClick={() => setIsEditing(true)} <span
// Badge oscuro: borde blanco sutil, texto blanco, fondo más claro al hover contentEditable
className="group flex h-8 items-center gap-2 rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white transition-all hover:border-white/20 hover:bg-white/10" suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
> >
{/* Ícono blanco sutil */} {textValue}
<span className="text-white/70">{icon}</span>
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
{label}:
</span> </span>
<span className="font-semibold text-white">
{value} {suffix}
</span>
{/* Lápiz blanco sutil */}
<Pencil className="h-3 w-3 text-white/50 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
) )
} }
interface DatosPlan { interface DatosPlan {
nombre?: string nombre?: string
} }
function AsignaturaLayout() { function AsignaturaLayout() {
const location = useLocation() const location = useLocation()
const { asignaturaId, planId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId) useSubject(asignaturaId)
const { data: todasLasAsignaturas } = usePlanAsignaturas(planId) // 1. Asegúrate de tener estos estados en tu componente principal
const [confirmState, setConfirmState] = useState<{
isOpen: boolean
resolve: (value: boolean) => void
mensaje: string
} | null>(null)
const validarConInterrupcion = async (
nuevoCiclo: number,
): Promise<boolean> => {
if (!todasLasAsignaturas || !asignaturaApi) return true
const materiasConflicto = todasLasAsignaturas.filter((a) => {
const esPrerrequisitoConflictivo =
asignaturaApi.prerrequisito_asignatura_id === a.id &&
(a.numero_ciclo ?? 0) >= nuevoCiclo
const esDependienteConflictiva =
a.prerrequisito_asignatura_id === asignaturaApi.id &&
(a.numero_ciclo ?? 0) <= nuevoCiclo
return esPrerrequisitoConflictivo || esDependienteConflictiva
})
if (materiasConflicto.length === 0) return true
const listaNombres = materiasConflicto.map((m) => m.nombre)
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
resolve,
mensaje: JSON.stringify({
main: `Mover "${asignaturaApi.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`,
materias: listaNombres,
}),
})
})
}
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({ const [headerData, setHeaderData] = useState({
codigo: '', codigo: '',
nombre: '', nombre: '',
@@ -216,6 +85,7 @@ function AsignaturaLayout() {
ciclo: 0, ciclo: 0,
}) })
// Sincronizar cuando llegue la API
useEffect(() => { useEffect(() => {
if (asignaturaApi) { if (asignaturaApi) {
setHeaderData({ setHeaderData({
@@ -227,38 +97,28 @@ function AsignaturaLayout() {
} }
}, [asignaturaApi]) }, [asignaturaApi])
const handleUpdateHeader = async (key: string, value: string | number) => { const handleUpdateHeader = (key: string, value: string | number) => {
// 1. Si es ciclo, validamos antes de hacer nada
if (key === 'ciclo') {
const nuevoCiclo = Number(value)
const acepto = await validarConInterrupcion(nuevoCiclo)
setConfirmState(null) // Cerramos el modal tras la respuesta
if (!acepto) {
// Revertimos el estado local al valor de la API si cancela
setHeaderData((prev) => ({
...prev,
ciclo: asignaturaApi?.numero_ciclo ?? 0,
}))
return
}
}
// 2. Si no es ciclo o si aceptó el conflicto, procedemos
const newData = { ...headerData, [key]: value } const newData = { ...headerData, [key]: value }
setHeaderData(newData) setHeaderData(newData)
const patch: Record<string, any> = const patch: Record<string, any> =
key === 'ciclo' ? { numero_ciclo: value } : { [key]: value } key === 'ciclo'
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({ asignaturaId, patch }) updateAsignatura.mutate({
asignaturaId,
patch,
})
} }
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}) })
// Confetti al llegar desde creación IA
useEffect(() => { useEffect(() => {
if ((location.state as any)?.showConfetti) { if ((location.state as any)?.showConfetti) {
lateralConfetti() lateralConfetti()
@@ -268,114 +128,95 @@ function AsignaturaLayout() {
if (loadingAsig) { if (loadingAsig) {
return ( return (
<div className="bg-background text-foreground flex h-screen items-center justify-center"> <div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
Cargando asignatura... Cargando asignatura...
</div> </div>
) )
} }
// Si no hay datos y no está cargando, algo falló
if (!asignaturaApi) return null if (!asignaturaApi) return null
return ( return (
<div className="bg-background min-h-screen"> <div>
{/* HEADER DE LA ASIGNATURA CON TU FONDO AZUL HARDCODEADO */} <section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<section className="border-border border-b bg-[#0b1d3a] pt-6 pb-8"> <div className="mx-auto max-w-400 p-4 py-10 md:px-6 lg:px-8">
<div className="mx-auto px-4 md:px-6 lg:px-8">
<Link <Link
to="/planes/$planId/asignaturas" to="/planes/$planId/asignaturas"
params={{ planId }} params={{ planId }}
// Enlace blanco sutil className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
className="mb-4 flex w-fit items-center gap-2 text-sm text-white/70 transition-colors hover:text-white"
> >
<ArrowLeft className="h-4 w-4" /> Volver al plan <ArrowLeft className="h-4 w-4" /> Volver al plan
</Link> </Link>
<div className="flex flex-col gap-4"> <div className="flex items-start justify-between gap-6">
{/* Título Editable (Texto blanco controlado dentro del componente) */} <div className="space-y-3">
<div className="-ml-2"> {/* CÓDIGO EDITABLE */}
<InlineEditTitle <Badge className="border border-blue-700 bg-blue-900/50">
value={headerData.nombre} <EditableHeaderField
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</div>
{/* Fila de Metadatos Alineados */}
<div className="flex flex-wrap items-center gap-3">
{/* Badge Estático del Tipo (Estilo oscuro sutil) */}
<Badge
variant="outline"
className="flex h-8 cursor-default items-center gap-1.5 border-white/10 bg-white/5 px-3 text-white"
>
<Tag size={12} className="text-white/70" />
{asignaturaApi.tipo}
</Badge>
{/* Badges Editables (Texto blanco controlado dentro de los componentes) */}
<InlineEditBadge
icon={<Hash size={14} />}
label="Código"
value={headerData.codigo} value={headerData.codigo}
onSave={(val) => handleUpdateHeader('codigo', val)} onSave={(val) => handleUpdateHeader('codigo', val)}
/> />
</Badge>
<InlineEditBadge {/* NOMBRE EDITABLE */}
icon={<BookOpen size={14} />} <h1 className="text-3xl font-bold">
label="Créditos" <EditableHeaderField
type="number" value={headerData.nombre}
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 || ''}
</span>
</span>
</div>
</div>
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos} value={headerData.creditos}
onSave={(val) => onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0) handleUpdateHeader('creditos', parseInt(val) || 0)
} }
/> />
</span>
<span>créditos</span>
</Badge>
<InlineEditBadge {/* SEMESTRE EDITABLE */}
icon={<CalendarDays size={14} />} <Badge variant="secondary" className="gap-1">
label="Semestre" <EditableHeaderField
type="number"
value={headerData.ciclo} value={headerData.ciclo}
suffix="°"
onSave={(val) => onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0) handleUpdateHeader('ciclo', parseInt(val) || 0)
} }
/> />
</div> <span>° ciclo</span>
</Badge>
{/* Subtítulo de contexto (Texto blanco sutil) */} <Badge variant="secondary">{asignaturaApi.tipo}</Badge>
<div className="mt-2 flex items-center gap-2 text-sm text-white/70">
<GraduationCap className="h-4 w-4 shrink-0 text-white/60" />
<span>Pertenece al plan:</span>
<span className="font-medium text-white">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* TABS NAVEGACIÓN (Se mantiene semántico para el cuerpo de la página)
<nav className="border-border bg-background/80 sticky top-0 z-20 border-b backdrop-blur-md">
<div className="mx-auto px-4 py-1 md:px-6 lg:px-8">
<div className="scrollbar-hide flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
*/}
{confirmState && (
<AlertaConflicto
isOpen={confirmState.isOpen}
onOpenChange={(open) => {
if (!open) {
confirmState.resolve(false)
setConfirmState(null)
}
}}
onConfirm={() => confirmState.resolve(true)}
titulo="Conflicto de Seriación"
descripcion={confirmState.mensaje}
/>
)}
{/* TABS */} {/* TABS */}
<nav className="sticky top-0 z-20 border-b bg-white"> <nav className="sticky top-0 z-20 border-b bg-white">
<div className="mx-auto p-4 py-2 md:px-6 lg:px-8"> <div className="mx-auto max-w-400 p-4 py-2 md:px-6 lg:px-8">
{/* CAMBIOS CLAVE: {/* CAMBIOS CLAVE:
1. overflow-x-auto: Permite scroll horizontal. 1. overflow-x-auto: Permite scroll horizontal.
2. scrollbar-hide: (Opcional) para que no se vea la barra fea. 2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
@@ -405,12 +246,11 @@ function AsignaturaLayout() {
} }
from="/planes/$planId/asignaturas/$asignaturaId" from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }} params={{ planId, asignaturaId }}
className={cn( className={`shrink-0 border-b-2 py-4 text-sm font-medium transition-colors ${
'shrink-0 border-b-2 py-3 text-sm font-medium transition-colors',
isActive isActive
? 'border-primary text-primary font-bold' ? 'border-blue-600 text-blue-600'
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent', : 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
)} }`}
> >
{tab.label} {tab.label}
</Link> </Link>
@@ -420,7 +260,7 @@ function AsignaturaLayout() {
</div> </div>
</nav> </nav>
<div className="mx-auto p-4 py-8 md:px-6 lg:px-8"> <div className="mx-auto max-w-400 p-4 py-8 md:px-6 lg:px-8">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+13 -14
View File
@@ -8,7 +8,6 @@ import BarraBusqueda from '@/components/planes/BarraBusqueda'
import Filtro from '@/components/planes/Filtro' import Filtro from '@/components/planes/Filtro'
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard' import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
// Hooks y Utils (ajusta las rutas de importación) // Hooks y Utils (ajusta las rutas de importación)
import { Button } from '@/components/ui/button'
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans' import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
import { getIconByName } from '@/features/planes/utils/icon-utils' import { getIconByName } from '@/features/planes/utils/icon-utils'
@@ -115,7 +114,7 @@ function RouteComponent() {
return ( return (
<main className="bg-background min-h-screen w-full"> <main className="bg-background min-h-screen w-full">
<div className="mx-auto flex w-full flex-col gap-4 px-4 py-6 md:px-6 lg:px-8"> <div className="mx-auto flex w-full max-w-400 flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:col-span-3"> <div className="flex flex-col gap-4 lg:col-span-3">
{/* Header y Botón Nuevo */} {/* Header y Botón Nuevo */}
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
@@ -132,16 +131,16 @@ function RouteComponent() {
</p> </p>
</div> </div>
</div> </div>
<Button <button
onClick={() => { onClick={() => {
console.log('planId') console.log('planId')
navigate({ to: '/planes/nuevo', resetScroll: false }) navigate({ to: '/planes/nuevo', resetScroll: false })
}} }}
className="shadow-md" className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
> >
<Icons.Plus /> Nuevo plan de estudios <Icons.Plus /> Nuevo plan de estudios
</Button> </button>
</div> </div>
{/* Barra de Filtros */} {/* Barra de Filtros */}
@@ -189,15 +188,16 @@ function RouteComponent() {
placeholder="Estado" placeholder="Estado"
/> />
</div> </div>
<Button <button
type="button" type="button"
variant="secondary"
onClick={resetFilters} onClick={resetFilters}
disabled={isClearDisabled} disabled={isClearDisabled}
className={`shadow-md`} className={`ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors ${
isClearDisabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
> >
<Icons.X className="h-4 w-4" /> Limpiar <Icons.X className="h-4 w-4" /> Limpiar
</Button> </button>
</div> </div>
</div> </div>
@@ -222,9 +222,9 @@ function RouteComponent() {
// NOTA: El color del estado no viene en BD por defecto, // NOTA: El color del estado no viene en BD por defecto,
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan' // puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
// Aquí uso un fallback simple. // Aquí uso un fallback simple.
const estadoColorHex = (estado as any)?.color as const estadoColor = estado?.es_final
| string ? 'bg-emerald-600'
| undefined : 'bg-amber-600'
return ( return (
<PlanEstudiosCard <PlanEstudiosCard
@@ -235,8 +235,7 @@ function RouteComponent() {
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`} ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
facultad={facultad?.nombre ?? 'Sin Facultad'} facultad={facultad?.nombre ?? 'Sin Facultad'}
estado={estado?.etiqueta ?? 'Desconocido'} estado={estado?.etiqueta ?? 'Desconocido'}
colorEstadoHex={estadoColorHex} claseColorEstado={estadoColor}
claseColorEstado={!estadoColorHex ? 'bg-secondary' : ''}
colorFacultad={facultad?.color ?? '#000000'} colorFacultad={facultad?.color ?? '#000000'}
onClick={() => onClick={() =>
navigate({ navigate({
-23
View File
@@ -335,26 +335,3 @@ body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* =====================================================================
Utilidades locales (sin romper el design system)
Intención:
- Evitar estilos inline en JSX para assets puntuales del proyecto
===================================================================== */
.login-bg {
background-image: url('/fondo_login.png');
}
@layer base {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role='button']:disabled {
cursor: not-allowed !important;
}
}
-36
View File
@@ -1,36 +0,0 @@
// Convierte HSL a Hexadecimal
function hslToHex(h: number, s: number, l: number): string {
l /= 100
const a = (s * Math.min(l, 1 - l)) / 100
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color)
.toString(16)
.padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`
}
/**
* Genera un color contrastante.
* @param prevHue El tono (0-360) del color anterior. Null si es el primero.
* @returns Objeto con el hex y el nuevo hue para guardar como referencia.
*/
export function generarColorContrastante(prevHue: number | null = null) {
let newHue: number
if (prevHue === null) {
// Primer color: completamente al azar
newHue = Math.floor(Math.random() * 360)
} else {
// Siguientes: Salto aleatorio entre 130° y 230° para forzar contraste
const salto = 130 + Math.floor(Math.random() * 100)
newHue = (prevHue + salto) % 360
}
// Mantenemos saturación al 70% y luminosidad al 50% para colores vivos pero no fosforescentes
const hex = hslToHex(newHue, 70, 50)
return { hex, hue: newHue }
}