Merge branch 'main' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia

This commit is contained in:
2026-03-23 22:34:11 +00:00
3 changed files with 311 additions and 242 deletions

View File

@@ -229,12 +229,38 @@ export function IAAsignaturaTab() {
} }
}, [activeChats, loadingConv]) }, [activeChats, loadingConv])
const filteredFields = useMemo(() => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!showSuggestions) return availableFields 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 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( return availableFields.filter(
(f) => (f) =>
@@ -254,24 +280,28 @@ export function IAAsignaturaTab() {
// 3. Función para insertar el campo y limpiar el prompt // 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => { const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) { if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field]) setSelectedFields((prev) => [...prev, field])
} }
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':') const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) { if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio const parteAntesDelColon = input.slice(0, lastColonIndex)
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
// 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) setInput(nuevoTexto)
} }
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false) setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
} }
const handleSaveName = (id: string) => { const handleSaveName = (id: string) => {
@@ -740,10 +770,28 @@ export function IAAsignaturaTab() {
<Textarea <Textarea
value={input} value={input}
onChange={(e) => { onChange={(e) => {
setInput(e.target.value) const val = e.target.value
if (e.target.value.endsWith(':')) setShowSuggestions(true) const cursor = e.target.selectionStart
else if (showSuggestions && !e.target.value.includes(':')) 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) setShowSuggestions(false)
}
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {

View File

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

View File

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