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