15 Commits

Author SHA1 Message Date
7eb9ca0bec Merge pull request 'Que el contenido temático se muestre en el historial , Actualizar esta sección de seriación fix#197 fix #195' (#218) from issue/197-que-el-contenido-temtico-se-muestre-en-el-historia into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m15s
Reviewed-on: #218
2026-03-23 22:34:21 +00:00
10dc299311 Merge branch 'main' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia 2026-03-23 22:34:11 +00:00
b3954ab16c Merge branch 'issue/195-actualizar-esta-seccin-de-seriacin' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia 2026-03-23 16:29:27 -06:00
88a2a28a8d Enhance table styling and improve empty state message in AsignaturasPage for better user experience
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m25s
2026-03-23 16:16:42 -06:00
36a11e3793 Improve conditional rendering and styling for empty state in MapaCurricularPage 2026-03-23 16:15:43 -06:00
33efaed03f Adjust text sizes and spacing in AsignaturaCardItem and MapaCurricularPage for improved readability
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m22s
2026-03-23 16:05:20 -06:00
d481e9706c Fix estado assignment in AsignaturaCardItem and adjust width for improved layout
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m19s
2026-03-23 15:54:12 -06:00
ee3b7a56ec Merge pull request 'Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular' (#217) from issue/214-mejorar-el-espaciado-de-la-interfaz into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #217
2026-03-23 20:31:46 +00:00
2359e38f85 Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular 2026-03-23 14:30:16 -06:00
c262cd16be Merge pull request 'Que aparezcan los campos a mitad de texto fix #213' (#216) from issue/213-que-aparezcan-los-campos-a-mitad-de-texto into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #216
2026-03-23 19:02:56 +00:00
a08b2abf87 Que aparezcan los campos a mitad de texto
fix #213
2026-03-23 13:02:31 -06:00
a07213d959 Merge pull request 'Mejorar la responsividad de los chats fix #202 fix #203' (#215) from issue/202-mejorar-la-responsividad-de-los-chats into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 2m36s
Reviewed-on: #215
2026-03-23 18:34:30 +00:00
a2234e5022 Mejorar la responsividad de los chats fix #202 2026-03-23 12:33:12 -06:00
658c392f96 Mejorar la responsividad de los chats fix #202 2026-03-23 11:59:58 -06:00
9fd816bfa1 Actualizar esta sección de seriación fix #195 2026-03-20 16:09:39 -06:00
6 changed files with 876 additions and 517 deletions

View File

@@ -7,6 +7,13 @@ import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
@@ -14,6 +21,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { usePlanAsignaturas } from '@/data'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
@@ -64,8 +72,12 @@ export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId)
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura()
@@ -86,16 +98,54 @@ export default function AsignaturaDetailPage() {
},
})
}
const asignaturaSeriada = useMemo(() => {
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
return null
return asignaturasApi.find(
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
)
}, [asignaturaApi, asignaturasApi])
const requisitosFormateados = useMemo(() => {
if (!asignaturaSeriada) return []
return [
{
type: 'Pre-requisito',
code: asignaturaSeriada.codigo,
name: asignaturaSeriada.nombre,
id: asignaturaSeriada.id, // Guardamos el ID para el select
},
]
}, [asignaturaSeriada])
const handleUpdatePrerrequisito = (newId: string | null) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: newId,
},
})
}
/* ---------- sincronizar API ---------- */
useEffect(() => {
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi])
console.log(requisitosFormateados)
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados])
return (
<DatosGenerales
pre={requisitosFormateados}
availableSubjects={asignaturasApi}
onPersistDato={handlePersistDatoGeneral}
/>
)
}
function DatosGenerales({
onPersistDato,
pre,
availableSubjects,
}: {
onPersistDato: (clave: string, value: string) => void
}) {
@@ -270,18 +320,19 @@ function DatosGenerales({
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
initialContent={pre}
// Pasamos las materias del plan para el Select (excluyendo la actual)
availableSubjects={
availableSubjects?.filter((a) => a.id !== asignaturaId) || []
}
onPersist={({ value }) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
},
})
}}
/>
{/* Tarjeta de Evaluación */}
@@ -321,6 +372,7 @@ interface InfoCardProps {
containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number
highlightToken?: number
availableSubjects?: any
}
function InfoCard({
@@ -337,6 +389,7 @@ function InfoCard({
containerRef,
forceEditToken,
highlightToken,
availableSubjects,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
@@ -354,7 +407,8 @@ 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
@@ -397,6 +451,8 @@ function InfoCard({
const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = []
@@ -427,6 +483,25 @@ function InfoCard({
void onPersist?.({ type, clave, value: cleaned })
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 =
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
setData(tempText) // Actualiza la vista local
setIsEditing(false)
// Mandamos el ID específico a la base de datos
void onPersist?.({
type,
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
value: prerequisiteId,
})
return
}
setData(tempText)
setIsEditing(false)
@@ -546,7 +621,52 @@ function InfoCard({
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
{type === 'evaluation' ? (
{/* Condicionales de edición según el tipo */}
{type === 'requirements' ? (
<div className="space-y-3">
<label className="text-xs font-medium text-slate-500">
Materia de Seriación
</label>
<Select
value={tempText?.[0]?.id || 'none'}
onValueChange={(val) => {
const selected = availableSubjects?.find(
(s) => s.id === val,
)
if (val === 'none' || !selected) {
console.log('guardando')
setTempText([])
} else {
console.log('hola')
setTempText([
{
id: selected.id,
type: 'Pre-requisito',
code: selected.codigo,
name: selected.nombre,
},
])
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una materia" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Ninguna (Sin seriación)
</SelectItem>
{availableSubjects?.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
{asig.codigo} - {asig.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : type === 'evaluation' ? (
<div className="space-y-3">
<div className="space-y-2">
{evalRows.map((row) => (
@@ -568,85 +688,36 @@ function InfoCard({
)
}}
/>
<Input
value={row.porcentaje}
placeholder="%"
type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
onChange={(e) => {
const raw = e.target.value
// Solo permitir '' o dígitos
if (raw !== '' && !/^\d+$/.test(raw)) return
if (raw === '') {
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: '',
}
: r,
),
)
return
}
const n = Number(raw)
if (!Number.isFinite(n)) return
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return
// No permitir suma > 100
setEvalRows((prev) => {
const next = prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: raw,
}
: r,
r.id === row.id ? { ...r, porcentaje: raw } : r,
)
const total = next.reduce(
(acc, r) => acc + (Number(r.porcentaje) || 0),
0,
)
const total = next.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const nn = Number(v)
if (!Number.isFinite(nn)) return acc
const vv = Math.trunc(nn)
if (vv < 1 || vv > 100) return acc
return acc + vv
}, 0)
return total > 100 ? prev : next
})
}}
/>
<div
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
aria-hidden
>
%
</div>
<div className="text-sm text-slate-600">%</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() => {
onClick={() =>
setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id),
)
}}
aria-label="Quitar renglón"
title="Quitar"
}
>
<Minus className="h-4 w-4" />
</Button>
@@ -656,22 +727,15 @@ function InfoCard({
<div className="flex items-center justify-between">
<span
className={
'text-sm ' +
(evaluationTotal === 100
? 'text-muted-foreground'
: 'text-destructive font-semibold')
}
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
>
Total: {evaluationTotal}/100
</span>
<Button
variant="ghost"
size="sm"
className="text-emerald-700 hover:bg-emerald-50"
onClick={() => {
// Agregar una fila vacía (siempre permitido)
onClick={() =>
setEvalRows((prev) => [
...prev,
{
@@ -680,7 +744,7 @@ function InfoCard({
porcentaje: '',
},
])
}}
}
>
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button>
@@ -694,28 +758,15 @@ function InfoCard({
className="min-h-30 text-sm leading-relaxed"
/>
)}
{/* Botones de acción comunes */}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsEditing(false)
if (type === 'evaluation') {
const raw = Array.isArray(data) ? data : []
setEvalRows(
raw.map((r: CriterioEvaluacionRow) => ({
id: crypto.randomUUID(),
criterio:
typeof r.criterio === 'string' ? r.criterio : '',
porcentaje:
typeof r.porcentaje === 'number'
? String(Math.trunc(r.porcentaje))
: typeof r.porcentaje === 'string'
? String(Math.trunc(Number(r.porcentaje)))
: '',
})),
)
}
// Lógica de reset si es necesario...
}}
>
Cancelar
@@ -731,6 +782,7 @@ function InfoCard({
</div>
</div>
) : (
/* Modo Visualización */
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
@@ -739,9 +791,7 @@ function InfoCard({
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
{type === 'evaluation' && <EvaluationView items={data} />}
</div>
)}
</CardContent>

View File

@@ -20,12 +20,15 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import {
Drawer,
DrawerContent,
DrawerOverlay,
DrawerPortal,
} from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
@@ -50,16 +53,7 @@ interface SelectedField {
value: string
}
interface IAAsignaturaTabProps {
asignatura?: Record<string, any>
onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void
}
export function IAAsignaturaTab({
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
export function IAAsignaturaTab() {
const queryClient = useQueryClient()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
@@ -75,6 +69,7 @@ export function IAAsignaturaTab({
const [showSuggestions, setShowSuggestions] = useState(false)
const [isSending, setIsSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// --- DATA QUERIES ---
const { data: datosGenerales } = useSubject(asignaturaId)
@@ -141,7 +136,7 @@ export function IAAsignaturaTab({
const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
datosGenerales.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
@@ -234,12 +229,38 @@ export function IAAsignaturaTab({
}
}, [activeChats, loadingConv])
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const selectionStart = e.target.selectionStart // Posición del cursor
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)
}
}
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)
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
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
if (!query) return availableFields
return availableFields.filter(
(f) =>
@@ -259,24 +280,28 @@ export function IAAsignaturaTab({
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field])
}
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
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)
}
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
}
const handleSaveName = (id: string) => {
@@ -355,10 +380,35 @@ export function IAAsignaturaTab({
]
return (
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* PANEL IZQUIERDO */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4 flex items-center justify-between px-2">
<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">
<Button
variant="ghost"
size="sm"
className="text-slate-600"
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">
Asistente
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-slate-600"
onClick={() => setOpenIA(true)} // O el drawer de acciones/referencias
>
<FileText size={18} className="mr-2 text-teal-600" /> 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">
<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">
<History size={14} /> Historial
</h2>
@@ -378,25 +428,19 @@ export function IAAsignaturaTab({
<Button
onClick={() => {
setActiveChatId(undefined)
hasInitialSelected.current = true
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
queryClient.setQueryData(['subject-messages', undefined], [])
}}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 hover:border-teal-500"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 text-slate-600 hover:border-teal-500 hover:bg-teal-50/50"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
@@ -507,31 +551,51 @@ export function IAAsignaturaTab({
))}
</div>
</ScrollArea>
</div>
</aside>
{/* PANEL CENTRAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
<main 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:m-2">
{/* Header Interno del Recuadro */}
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA
</span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
>
<FileText size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setIsSidebarOpen(true)}
>
<History size={20} className="text-slate-500" />
</Button>
<div className="flex flex-col">
<span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
Asistente Académico
</span>
)}
</button>
<span className="text-[11px] text-slate-400">
Personalizado para tu asignatura
</span>
</div>
</div>
<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"
>
<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">
{totalReferencias}
</span>
)}
</button>
</div>
</div>
{/* Área de Mensajes */}
<div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="mx-auto max-w-3xl space-y-6 p-3 md:p-6">
{messages.map((msg) => (
<div
key={msg.id}
@@ -642,120 +706,144 @@ export function IAAsignaturaTab({
</ScrollArea>
</div>
{/* INPUT */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div>
<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>
</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>
))}
{/* 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>
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
if (e.target.value.endsWith(':')) setShowSuggestions(true)
else if (showSuggestions && !e.target.value.includes(':'))
setShowSuggestions(false)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
<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)
const textBeforeCursor = val.slice(0, cursor)
const lastColonIndex = textBeforeCursor.lastIndexOf(':')
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)
}
} 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
}
}}
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="h-9 w-9 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
</main>
{/* PANEL DERECHO ACCIONES */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
{/* 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
<Lightbulb size={18} className="text-orange-500" /> 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 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:bg-teal-50"
className="group flex w-full items-center gap-3 rounded-xl border border-slate-100 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:shadow-sm"
>
<div className="rounded-lg bg-slate-100 p-2 group-hover:bg-teal-100 group-hover:text-teal-600">
<div className="rounded-lg bg-slate-50 p-2 group-hover:bg-teal-50 group-hover:text-teal-600">
<preset.icon size={16} />
</div>
<span className="font-medium text-slate-700">{preset.label}</span>
<span className="font-medium text-slate-600 group-hover:text-slate-900">
{preset.label}
</span>
</button>
))}
</div>
</div>
{/* --- DRAWER DE REFERENCIAS --- */}
</aside>
{/* 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">
@@ -790,6 +878,140 @@ export function IAAsignaturaTab({
</div>
</DrawerContent>
</Drawer>
<Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 bg-black/40" />
<DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none">
<div className="flex h-full flex-col overflow-hidden">
{/* Reutiliza aquí el componente de la lista de chats */}
<Button
onClick={() => {
setActiveChatId(undefined)
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
queryClient.setQueryData(['subject-messages', undefined], [])
setIsSidebarOpen(false) // Cierra el drawer al crear nuevo
}}
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"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
<h2 className="mb-4 font-bold">Historial de Chats</h2>
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<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',
)}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
>
{editingId === chat.id ? (
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
}}
/>
</div>
) : (
<>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span
onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
>
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
const nuevoEstado =
chat.estado === 'ACTIVA'
? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={14} />
) : (
<History size={14} className="scale-x-[-1]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
)}
</div>
))}
</div>
</DrawerContent>
</DrawerPortal>
</Drawer>
</div>
)
}

View File

@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas')
.select(
`
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))

View File

@@ -218,26 +218,30 @@ function AsignaturasPage() {
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="w-30">Clave</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-12.5"></TableHead>
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
<TableHead className="px-6 py-4">Nombre</TableHead>
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
<TableHead className="px-6 py-4">Tipo</TableHead>
<TableHead className="px-6 py-4">Estado</TableHead>
<TableHead className="w-12.5 px-6 py-4"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAsignaturas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p>
<p className="text-xs">
Intenta cambiar los filtros de búsqueda
</p>
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
<BookOpen className="h-10 w-10 opacity-20" />
<div>
<p className="font-medium">
No se encontraron asignaturas
</p>
<p className="mt-1 text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</div>
</TableCell>
</TableRow>
@@ -251,25 +255,25 @@ function AsignaturasPage() {
to: '/planes/$planId/asignaturas/$asignaturaId',
params: {
planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
asignaturaId: asignatura.id,
},
state: {
realId: asignatura.id, // 👈 ID largo oculto
realId: asignatura.id,
asignaturaId: asignatura.id,
} as any,
})
}
>
<TableCell className="font-mono text-xs font-bold text-slate-400">
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
{asignatura.clave}
</TableCell>
<TableCell className="font-semibold text-slate-700">
<TableCell className="px-6 py-4 font-semibold text-slate-700">
{asignatura.nombre}
</TableCell>
<TableCell className="text-center font-medium">
<TableCell className="px-6 py-4 text-center font-medium">
{asignatura.creditos}
</TableCell>
<TableCell className="text-center">
<TableCell className="px-6 py-4 text-center">
{asignatura.ciclo ? (
<Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo}
@@ -278,10 +282,10 @@ function AsignaturasPage() {
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="text-sm text-slate-600">
<TableCell className="px-6 py-4 text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)}
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<Badge
variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
@@ -289,7 +293,7 @@ function AsignaturasPage() {
{tipoConfig[asignatura.tipo].label}
</Badge>
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<Badge
variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
@@ -297,7 +301,7 @@ function AsignaturasPage() {
{statusConfig[asignatura.estado].label}
</Badge>
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" />
</div>

View File

@@ -139,6 +139,10 @@ function RouteComponent() {
null,
)
const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion
@@ -206,7 +210,7 @@ function RouteComponent() {
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = (behavior = 'smooth') => {
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (scrollRef.current) {
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
@@ -214,7 +218,7 @@ function RouteComponent() {
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
behavior,
})
}
}
@@ -478,37 +482,38 @@ function RouteComponent() {
}
return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
</div>
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full flex-col gap-6 overflow-hidden p-4 md:flex-row">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex items-center justify-between rounded-lg border bg-white p-2 md:hidden">
<Button
variant="ghost"
size="sm"
onClick={() => setIsHistoryOpen(true)}
>
<Archive size={18} className="mr-2" /> Historial
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsActionsOpen(true)}
>
<Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
</Button>
</div>
{/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
<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">
Chats
</h2>
<Button
onClick={createNewChat}
variant="outline"
className="mt-2 mb-4 w-full justify-start gap-2"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
<ScrollArea className="flex-1">
<div className="space-y-1 pr-2">
{' '}
@@ -571,7 +576,7 @@ function RouteComponent() {
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle =
e.currentTarget.textContent?.trim() || ''
e.currentTarget.textContent.trim() || ''
if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
@@ -655,8 +660,9 @@ function RouteComponent() {
</div>
</ScrollArea>
</div>
{/* PANEL DE CHAT PRINCIPAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* --- 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:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between">
@@ -931,8 +937,9 @@ function RouteComponent() {
</div>
</div>
</div>
{/* PANEL LATERAL */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto pr-2 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>
@@ -953,6 +960,65 @@ function RouteComponent() {
))}
</div>
</div>
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DrawerContent className="h-[80vh] p-4">
<Button
onClick={() => {
createNewChat()
setIsHistoryOpen(false)
}}
className="mb-4 w-full bg-teal-600 text-white"
>
<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">
Historial Reciente
</p>
{activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsHistoryOpen(false)
}}
className="border-b p-3 text-sm"
>
{chat.nombre || 'Chat sin nombre'}
</div>
))}
</ScrollArea>
</DrawerContent>
</Drawer>
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
<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
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => {
handleSend(preset.prompt)
setIsActionsOpen(false)
}}
className="flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
>
<preset.icon size={16} />
<span>{preset.label}</span>
</button>
))}
</div>
</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 */}

View File

@@ -1,13 +1,8 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router'
import {
Plus,
ChevronDown,
AlertTriangle,
Trash2,
Pencil,
} from 'lucide-react'
import { Plus, ChevronDown, AlertTriangle, Trash2, Pencil } from 'lucide-react'
import * as Icons from 'lucide-react'
import { useMemo, useState, useEffect, Fragment } from 'react'
import type { TipoAsignatura } from '@/data'
@@ -36,6 +31,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useCreateLinea,
useDeleteLinea,
@@ -45,12 +46,6 @@ import {
useUpdateAsignatura,
useUpdateLinea,
} from '@/data'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
const palette = [
@@ -137,8 +132,6 @@ function StatItem({
)
}
import * as Icons from 'lucide-react'
const estadoConfig: Record<
Asignatura['estado'],
{
@@ -198,7 +191,7 @@ function AsignaturaCardItem({
isDragging: boolean
onClick: () => void
}) {
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
const estado = estadoConfig[asignatura.estado]
const EstadoIcon = estado.icon
return (
@@ -210,10 +203,10 @@ function AsignaturaCardItem({
onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick}
className={[
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
'group relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left',
'transition-all duration-300 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'active:cursor-grabbing cursor-grab',
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
'cursor-grab active:cursor-grabbing',
isDragging
? 'scale-[0.985] opacity-45 shadow-none'
: 'hover:-translate-y-1 hover:shadow-lg',
@@ -235,7 +228,7 @@ function AsignaturaCardItem({
{/* glow decorativo */}
<div
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl transition-transform duration-500 group-hover:scale-110"
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl"
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
/>
@@ -243,7 +236,7 @@ function AsignaturaCardItem({
{/* top */}
<div className="flex items-start justify-between gap-2">
<div
className="inline-flex h-8 max-w-[200px] items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
className="inline-flex h-8 max-w-32 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
style={{
borderColor: hexToRgba(lineaColor, 0.2),
backgroundColor: hexToRgba(lineaColor, 0.1),
@@ -251,37 +244,29 @@ function AsignaturaCardItem({
}}
>
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
<span className="truncate">
{asignatura.clave || 'Sin clave'}
</span>
</div>
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
<EstadoIcon
className={[
'h-3.5 w-3.5 text-foreground/65',
asignatura.estado === 'generando' ? 'animate-spin' : '',
].join(' ')}
/>
</div>
<div
className={[
'absolute right-2 flex translate-x-6 items-center gap-1.5 opacity-0 transition-all duration-300',
'group-hover:translate-x-0 group-hover:opacity-100'
].join(' ')}
>
<span className="text-[11px] font-semibold whitespace-nowrap">
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-background/70 flex h-8 items-center rounded-full px-2 backdrop-blur-sm">
<EstadoIcon className="text-foreground/65 h-3.5 w-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="left">
<span className="text-xs font-semibold">
{estado.label}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</div>
{/* titulo */}
<div className="mt-4 min-h-[72px]">
<div className="mt-4 min-h-18">
<h3
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
className="text-foreground text-md overflow-hidden leading-[1.08] font-bold"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
@@ -295,53 +280,59 @@ function AsignaturaCardItem({
{/* bottom */}
<div className="mt-auto grid grid-cols-3 gap-2">
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.Award className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
<span className="text-[10px] font-medium tracking-wide uppercase">
CR
</span>
</div>
<div className="text-sm font-bold text-foreground">
<div className="text-foreground text-sm font-bold">
{asignatura.creditos}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.Clock3 className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
<span className="text-[10px] font-medium tracking-wide uppercase">
HD
</span>
</div>
<div className="text-sm font-bold text-foreground">
<div className="text-foreground text-sm font-bold">
{asignatura.hd}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.BookOpenText className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
<span className="text-[10px] font-medium tracking-wide uppercase">
HI
</span>
</div>
<div className="text-sm font-bold text-foreground">
<div className="text-foreground text-sm font-bold">
{asignatura.hi}
</div>
</div>
</div>
{/* drag affordance */}
<div className="pointer-events-none absolute right-3 bottom-3 rounded-full bg-background/70 p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
<Icons.GripVertical className="h-4 w-4 text-muted-foreground/55" />
<div 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>
<TooltipContent side="bottom">
<div className="text-xs">
{lineaNombre ? `${lineaNombre} · ` : ''}
<div className="text-lg">
{/* ciclo */}
{asignatura.ciclo ? (
<span className="font-bold">C{asignatura.ciclo} · </span>
) : null}
{lineaNombre ? (
<span className="font-medium">{lineaNombre} · </span>
) : null}
{asignatura.nombre}
</div>
</TooltipContent>
@@ -689,7 +680,7 @@ function MapaCurricularPage() {
return <div className="p-10 text-center">Cargando mapa curricular...</div>
return (
<div className="container mx-auto px-2 py-6">
<div className="container">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
@@ -704,15 +695,15 @@ function MapaCurricularPage() {
</Button>
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '}
{
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
.length
}{' '}
sin asignar
</Badge>
)}
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '}
{
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
.length
}{' '}
sin asignar
</Badge>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-teal-700 text-white hover:bg-teal-800">
@@ -768,172 +759,198 @@ function MapaCurricularPage() {
</div>
<div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]">
<div
className="grid gap-3"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
}}
>
<div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR
<div
className="grid gap-2"
style={{
gridTemplateColumns: `140px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
}}
>
<div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR
</div>
{ciclosArray.map((n) => (
<div
key={`header-${n}`}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
>
Ciclo {n}
</div>
))}
{ciclosArray.map((n) => (
<div
key={`header-${n}`}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
>
Ciclo {n}
</div>
))}
<div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL
</div>
<div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL
</div>
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id)
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id)
return (
<Fragment key={linea.id}>
<div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
>
<div className="flex-1 overflow-hidden">
<span
contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => {
if (editingLineaId !== linea.id) {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}}
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
return (
<Fragment key={linea.id}>
<div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-3 transition-all ${
lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
>
<div className="min-w-0 flex-1 overflow-hidden">
<span
contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => {
if (editingLineaId !== linea.id) {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}}
className={`block w-full truncate text-xs font-bold break-words outline-none ${
editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer'
}`}
>
{linea.nombre}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingLineaId(linea.id)}
className="..."
>
{' '}
<Pencil size={12} />{' '}
</button>
<Trash2
onClick={() => borrarLinea(linea.id)}
className="..."
size={14}
/>
</div>
</div>
{ciclosArray.map((ciclo) => (
<div
key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
}`}
>
{asignaturas
.filter(
(m) =>
m.ciclo === ciclo &&
m.lineaCurricularId === linea.id,
)
.map((m) => (
<AsignaturaCardItem
key={m.id}
asignatura={m}
lineaColor={linea.color || '#1976d2'}
lineaNombre={linea.nombre}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
</div>
))}
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
<div>Cr: {sub.cr}</div>
<div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div>
{linea.nombre}
</span>
</div>
</Fragment>
)
})}
<div className="col-span-full my-2 border-t border-slate-200"></div>
<div className="self-center p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
return (
<div
key={`footer-${ciclo}`}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
>
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
<div>
HD: {t.hd} HI: {t.hi}
<div className="ml-2 flex flex-shrink-0 items-center gap-1">
<button
onClick={() => setEditingLineaId(linea.id)}
className="..."
>
{' '}
<Pencil size={12} />{' '}
</button>
<Trash2
onClick={() => borrarLinea(linea.id)}
className="..."
size={14}
/>
</div>
</div>
)
})}
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
<div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
</div>
{ciclosArray.map((ciclo) => (
<div
key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-35 space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20"
>
{asignaturas
.filter(
(m) =>
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
)
.map((m) => (
<AsignaturaCardItem
key={m.id}
asignatura={m}
lineaColor={linea.color || '#1976d2'}
lineaNombre={linea.nombre}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
</div>
))}
<div
className={`flex flex-col justify-center rounded-xl border p-4 text-[10px] font-medium ${
sub.cr === 0 && sub.hd === 0 && sub.hi === 0
? 'border-slate-100 bg-slate-50/50 text-slate-300'
: 'border-slate-200 bg-slate-50 text-slate-600'
}`}
>
{sub.cr === 0 && sub.hd === 0 && sub.hi === 0 ? (
<div className="text-slate-400"></div>
) : (
<>
<div className="space-y-1">
<div className="font-bold text-slate-700">
Cr: {sub.cr}
</div>
<div className="text-slate-600">
HD: {sub.hd} HI: {sub.hi}
</div>
</div>
</>
)}
</div>
</Fragment>
)
})}
<div className="col-span-full my-2 border-t border-slate-200"></div>
<div className="self-center p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
const isEmpty = t.cr === 0 && t.hd === 0 && t.hi === 0
return (
<div
key={`footer-${ciclo}`}
className={`rounded-lg p-2 text-center text-[10px] ${
isEmpty ? 'bg-slate-100/50 text-slate-400' : 'bg-slate-50'
}`}
>
{isEmpty ? (
<div className="py-1 text-xs text-slate-400"></div>
) : (
<>
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
<div>
HD: {t.hd} HI: {t.hi}
</div>
</>
)}
</div>
)
})}
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
<div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
</div>
</div>
</div>
{/* Asignaturas Sin Asignar */}
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
<div className="border-border bg-card/80 mt-12 rounded-[28px] border p-5 shadow-sm backdrop-blur-sm">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<div className="bg-muted text-muted-foreground flex h-9 w-9 items-center justify-center rounded-2xl">
<Icons.Inbox className="h-4.5 w-4.5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold tracking-wide text-foreground uppercase">
<h3 className="text-foreground text-sm font-bold tracking-wide uppercase">
Bandeja de entrada
</h3>
<div className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-[11px] font-semibold text-muted-foreground">
<div className="bg-muted text-muted-foreground inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-[11px] font-semibold">
{unassignedAsignaturas.length}
</div>
</div>
<p className="mt-0.5 text-sm text-muted-foreground">
<p className="text-muted-foreground mt-0.5 text-sm">
Asignaturas sin ciclo o línea curricular
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-dashed border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground">
<div className="border-border bg-background/80 text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-3 py-1.5 text-xs">
<Icons.MoveDown className="h-3.5 w-3.5" />
<span>Arrastra aquí para desasignar</span>
</div>
@@ -969,18 +986,18 @@ function MapaCurricularPage() {
))}
</div>
) : (
<div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<div className="border-border/70 bg-background/70 flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border px-6 text-center">
<div className="bg-muted text-muted-foreground mb-3 flex h-12 w-12 items-center justify-center rounded-2xl">
<Icons.CheckCheck className="h-5 w-5" />
</div>
<p className="text-sm font-semibold text-foreground">
<p className="text-foreground text-sm font-semibold">
No hay asignaturas pendientes
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
ciclo y línea curricular.
<p className="text-muted-foreground mt-1 max-w-md text-sm">
Todo está colocado en el mapa. Arrastra una asignatura aquí para
quitarle ciclo y línea curricular.
</p>
</div>
)}