Merge branch 'main' into issue/23-poner-instrucciones-ms-claras-en-el-nuevo-plan

This commit is contained in:
2026-02-09 14:33:38 +00:00
4 changed files with 208 additions and 149 deletions

View File

@@ -24,8 +24,6 @@ import { qk } from '@/data/query/keys'
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => { loader: async ({ context: { queryClient }, params: { planId } }) => {
try { try {
console.log('loader')
await queryClient.ensureQueryData({ await queryClient.ensureQueryData({
queryKey: qk.plan(planId), queryKey: qk.plan(planId),
queryFn: () => plans_get(planId), queryFn: () => plans_get(planId),
@@ -33,8 +31,6 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
} catch (e: any) { } catch (e: any) {
// PGRST116: The result contains 0 rows // PGRST116: The result contains 0 rows
if (e?.code === 'PGRST116') { if (e?.code === 'PGRST116') {
console.log('not found on', Route.path)
throw notFound() throw notFound()
} }
throw e throw e
@@ -80,31 +76,43 @@ function RouteComponent() {
mutate({ planId, patch }) mutate({ planId, patch })
} }
const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
// 1. Permitir teclas de control (Borrar, flechas, etc.) siempre
const isControlKey =
e.key === 'Backspace' ||
e.key === 'Delete' ||
e.key.includes('Arrow') ||
e.metaKey ||
e.ctrlKey
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
e.currentTarget.blur() // Esto disparará el onBlur automáticamente e.currentTarget.blur()
return
}
// 2. Bloquear si excede los 200 caracteres y no es una tecla de control
const currentText = e.currentTarget.textContent || ''
if (currentText.length >= MAX_CHARACTERS && !isControlKey) {
e.preventDefault()
} }
} }
const handleBlurNombre = (e: React.FocusEvent<HTMLSpanElement>) => { const handlePaste = (e: React.ClipboardEvent<HTMLSpanElement>) => {
const nuevoNombre = e.currentTarget.textContent || '' e.preventDefault()
setNombrePlan(nuevoNombre) const text = e.clipboardData.getData('text/plain')
const currentText = e.currentTarget.textContent || ''
// Solo guardamos si el valor es realmente distinto al de la base de datos // Calcular cuánto espacio queda
if (nuevoNombre !== data?.nombre) { const remainingSpace = MAX_CHARACTERS - currentText.length
persistChange({ nombre: nuevoNombre })
if (remainingSpace > 0) {
const slicedText = text.slice(0, remainingSpace)
document.execCommand('insertText', false, slicedText)
} }
} }
const handleSelectNivel = (n: string) => {
setNivelPlan(n)
// Guardamos inmediatamente al seleccionar
if (n !== data?.nivel) {
persistChange({ nivel: n })
}
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* 1. Header Superior */} {/* 1. Header Superior */}
@@ -131,8 +139,9 @@ function RouteComponent() {
) : ( ) : (
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div> <div>
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900"> <h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
<span>{nivelPlan} en</span> {/* El prefijo "Nivel en" lo mantenemos simple */}
<span className="shrink-0">{nivelPlan} en</span>
<span <span
role="textbox" role="textbox"
tabIndex={0} tabIndex={0}
@@ -140,14 +149,17 @@ function RouteComponent() {
suppressContentEditableWarning suppressContentEditableWarning
spellCheck={false} spellCheck={false}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} // Añadido para controlar lo que pegan
onBlur={(e) => { onBlur={(e) => {
const nuevoNombre = e.currentTarget.textContent || '' const nuevoNombre =
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre) setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) { if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } }) mutate({ planId, patch: { nombre: nuevoNombre } })
} }
}} }}
className="cursor-text border-b border-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500" // 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' }} style={{ textDecoration: 'none' }}
> >
{nombrePlan} {nombrePlan}
@@ -219,11 +231,7 @@ function RouteComponent() {
<Tab to="/planes/$planId/" params={{ planId }}> <Tab to="/planes/$planId/" params={{ planId }}>
Datos Generales Datos Generales
</Tab> </Tab>
<Tab <Tab to="/planes/$planId/mapa" params={{ planId }}>
to="/planes/$planId/mapa"
params={{ planId }}
search={{ ciclo: data?.numero_ciclos }}
>
Mapa Curricular Mapa Curricular
</Tab> </Tab>
<Tab to="/planes/$planId/asignaturas" params={{ planId }}> <Tab to="/planes/$planId/asignaturas" params={{ planId }}>
@@ -238,13 +246,7 @@ function RouteComponent() {
<Tab to="/planes/$planId/documento" params={{ planId }}> <Tab to="/planes/$planId/documento" params={{ planId }}>
Documento Documento
</Tab> </Tab>
<Tab <Tab to="/planes/$planId/historial" params={{ planId }}>
to="/planes/$planId/historial"
params={{ planId }}
search={{
structure: data?.estructuras_plan?.definicion?.properties,
}}
>
Historial Historial
</Tab> </Tab>
</nav> </nav>
@@ -298,7 +300,6 @@ const InfoCard = forwardRef<
function Tab({ function Tab({
to, to,
params, params,
search,
children, children,
}: { }: {
to: string to: string
@@ -306,12 +307,10 @@ function Tab({
search?: any search?: any
children: React.ReactNode children: React.ReactNode
}) { }) {
console.log(search)
return ( return (
<Link <Link
to={to} to={to}
params={params} params={params}
search={search}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800" 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' }} activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
activeOptions={{ activeOptions={{

View File

@@ -13,7 +13,7 @@ import {
History, History,
Calendar, Calendar,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
@@ -23,13 +23,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { usePlanHistorial } from '@/data/hooks/usePlans' import { usePlan, usePlanHistorial } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({ export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent, component: RouteComponent,
validateSearch: (search: { structure?: any }) => ({
structure: search.structure ?? null,
}),
}) })
const getEventConfig = (tipo: string, campo: string) => { const getEventConfig = (tipo: string, campo: string) => {
@@ -61,14 +58,23 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(planId) const { data: rawData, isLoading } = usePlanHistorial(planId)
const { structure } = Route.useSearch() const [structure, setStructure] = useState<any>(null)
console.log(structure?.vigencia?.title) const { data } = usePlan(planId)
console.log(structure) console.log('analizando estructura')
console.log(data?.estructuras_plan?.definicion?.properties)
// console.log(structure)
// ESTADOS PARA EL MODAL // ESTADOS PARA EL MODAL
const [selectedEvent, setSelectedEvent] = useState<any>(null) const [selectedEvent, setSelectedEvent] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
useEffect(() => {
if (data?.estructuras_plan?.definicion?.properties) {
setStructure(data.estructuras_plan.definicion.properties)
}
}, [data])
const historyEvents = useMemo(() => { const historyEvents = useMemo(() => {
if (!rawData) return [] if (!rawData) return []
return rawData.map((item: any) => { return rawData.map((item: any) => {

View File

@@ -93,7 +93,6 @@ function DatosGeneralesPage() {
requerido: true, requerido: true,
// 👇 TIPO DE CAMPO
tipo: Array.isArray(schema?.enum) tipo: Array.isArray(schema?.enum)
? 'select' ? 'select'
: schema?.type === 'number' : schema?.type === 'number'
@@ -107,8 +106,6 @@ function DatosGeneralesPage() {
setCampos(datosTransformados) setCampos(datosTransformados)
} }
console.log(properties)
}, [data]) }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales) // 3. Manejadores de acciones (Ahora como funciones locales)
@@ -220,8 +217,6 @@ function DatosGeneralesPage() {
} }
const handleIARequest = (clave: string) => { const handleIARequest = (clave: string) => {
console.log(clave)
navigate({ navigate({
to: '/planes/$planId/iaplan', to: '/planes/$planId/iaplan',
params: { params: {
@@ -244,9 +239,8 @@ function DatosGeneralesPage() {
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{campos.map((campo, key) => { {campos.map((campo) => {
const isEditing = editingId === campo.id const isEditing = editingId === campo.id
console.log(campo)
return ( return (
<div <div

View File

@@ -1,4 +1,4 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */ /* 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 {
@@ -7,6 +7,7 @@ import {
AlertTriangle, AlertTriangle,
GripVertical, GripVertical,
Trash2, Trash2,
Pencil,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
@@ -37,6 +38,7 @@ import {
import { import {
useCreateLinea, useCreateLinea,
useDeleteLinea, useDeleteLinea,
usePlan,
usePlanAsignaturas, usePlanAsignaturas,
usePlanLineas, usePlanLineas,
useUpdateAsignatura, useUpdateAsignatura,
@@ -166,44 +168,39 @@ function AsignaturaCardItem({
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({ export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
component: MapaCurricularPage, component: MapaCurricularPage,
validateSearch: (search: { ciclo?: number }) => ({
ciclo: search.ciclo ?? null,
}),
}) })
function MapaCurricularPage() { function MapaCurricularPage() {
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
const { ciclo } = Route.useSearch() const { data } = usePlan(planId)
const [ciclo, setCiclo] = useState(0)
const [editingLineaId, setEditingLineaId] = useState<string | null>(null) const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
const [tempNombreLinea, setTempNombreLinea] = useState('') const [tempNombreLinea, setTempNombreLinea] = useState('')
const { mutate: createLinea } = useCreateLinea() const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea() const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea() const { mutate: deleteLineaApi } = useDeleteLinea()
// 1. Fetch de Datos
const { data: asignaturasApi, isLoading: loadingAsig } = const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
// 2. Estado Local (Para interactividad)
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([]) const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
const [lineas, setLineas] = useState<Array<LineaCurricular>>([]) const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>( const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
null, null,
) )
const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [selectedAsignatura, setSelectedAsignatura] =
useState<Asignatura | null>(null)
const [hasAreaComun, setHasAreaComun] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const { mutate: updateAsignatura, isPending } = useUpdateAsignatura() const { mutate: updateAsignatura } = useUpdateAsignatura()
const [seriacionValue, setSeriacionValue] = useState<string>('unassigned')
useEffect(() => {
if (data?.numero_ciclos) {
setCiclo(data.numero_ciclos)
}
}, [data])
const manejarAgregarLinea = (nombre: string) => { const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim() const nombreNormalizado = nombre.trim()
// 1. Validar vacío
if (!nombreNormalizado) return if (!nombreNormalizado) return
// 2. Validar duplicados en el estado local (Insensible a mayúsculas/acentos)
const nombreBusqueda = nombreNormalizado const nombreBusqueda = nombreNormalizado
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
@@ -219,12 +216,9 @@ function MapaCurricularPage() {
if (yaExiste) { if (yaExiste) {
alert(`La línea "${nombreNormalizado}" ya existe en este plan.`) alert(`La línea "${nombreNormalizado}" ya existe en este plan.`)
return // DETIENE la ejecución aquí, no llega a la mutación return
} }
// 3. Si pasa las validaciones, procedemos con la persistencia
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0) const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
createLinea( createLinea(
{ {
nombre: nombreNormalizado, nombre: nombreNormalizado,
@@ -234,7 +228,6 @@ function MapaCurricularPage() {
}, },
{ {
onSuccess: (nueva) => { onSuccess: (nueva) => {
// Sincronización local que ya teníamos
const mapeada = { const mapeada = {
id: nueva.id, id: nueva.id,
nombre: nueva.nombre, nombre: nueva.nombre,
@@ -247,8 +240,13 @@ function MapaCurricularPage() {
}, },
) )
} }
const guardarEdicionLinea = (id: string) => { const guardarEdicionLinea = (id: string, nuevoNombre?: string) => {
if (!tempNombreLinea.trim()) { // Usamos el nombre que viene por parámetro o el del estado como fallback
const nombreAFijar = (
nuevoNombre !== undefined ? nuevoNombre : tempNombreLinea
).trim()
if (!nombreAFijar) {
setEditingLineaId(null) setEditingLineaId(null)
return return
} }
@@ -256,11 +254,10 @@ function MapaCurricularPage() {
updateLineaApi( updateLineaApi(
{ {
lineaId: id, lineaId: id,
patch: { nombre: tempNombreLinea.trim() }, patch: { nombre: nombreAFijar },
}, },
{ {
onSuccess: (lineaActualizada) => { onSuccess: (lineaActualizada) => {
// ACTUALIZACIÓN MANUAL DEL ESTADO LOCAL
setLineas((prev) => setLineas((prev) =>
prev.map((l) => prev.map((l) =>
l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l, l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l,
@@ -269,6 +266,10 @@ function MapaCurricularPage() {
setEditingLineaId(null) setEditingLineaId(null)
setTempNombreLinea('') setTempNombreLinea('')
}, },
onError: (err) => {
console.error('Error al actualizar linea:', err)
// Opcional: revertir cambios o avisar al usuario
},
}, },
) )
} }
@@ -280,7 +281,6 @@ function MapaCurricularPage() {
) )
}, [lineas]) }, [lineas])
// 3. Sincronizar API -> Estado Local
useEffect(() => { useEffect(() => {
if (asignaturasApi) if (asignaturasApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi)) setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi))
@@ -292,20 +292,37 @@ function MapaCurricularPage() {
const ciclosTotales = Number(ciclo) const ciclosTotales = Number(ciclo)
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1) const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
// Nuevo estado para controlar los datos temporales del modal de edición
const [editingData, setEditingData] = useState<Asignatura | null>(null) const [editingData, setEditingData] = useState<Asignatura | null>(null)
const handleIntegerChange = (value: string) => {
if (value === '') return value
// 1. FUNCION DE GUARDAR MODAL // Solo números, máximo 3 cifras
const regex = /^\d{1,3}$/
if (!regex.test(value)) return null
return value
}
const handleDecimalChange = (value: string, max?: number): string | null => {
if (value === '') return ''
const val = value.replace(',', '.')
const regex = /^\d*\.?\d{0,2}$/
if (!regex.test(val)) return null
if (max !== undefined) {
const num = Number(val)
if (!isNaN(num) && num > max) {
return max.toFixed(2)
}
}
return val
}
const handleSaveChanges = () => { const handleSaveChanges = () => {
if (!editingData) return if (!editingData) return
console.log(asignaturas)
setAsignaturas((prev) => setAsignaturas((prev) =>
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
) )
// setIsEditModalOpen(false)
// Preparamos el patch con la estructura de tu tabla
const patch = { const patch = {
nombre: editingData.nombre, nombre: editingData.nombre,
codigo: editingData.clave, codigo: editingData.clave,
@@ -332,22 +349,11 @@ function MapaCurricularPage() {
}, },
) )
} }
// 2. MODIFICACIÓN: Zona de soltado siempre visible
// Cambiamos la condición: Mostramos la sección si hay asignaturas sin asignar
// O si simplemente queremos tener el "depósito" disponible.
const unassignedAsignaturas = asignaturas.filter( const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null, (m) => m.ciclo === null || m.lineaCurricularId === null,
) )
// --- Lógica de Gestión ---
const agregarLinea = (nombre: string) => {
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
setLineas([...lineas, nueva])
}
const borrarLinea = (id: string) => { const borrarLinea = (id: string) => {
// 1. Opcional: Confirmación de seguridad
if ( if (
!confirm( !confirm(
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.', '¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
@@ -356,11 +362,8 @@ function MapaCurricularPage() {
return return
} }
// 2. Llamada a la API
deleteLineaApi(id, { deleteLineaApi(id, {
onSuccess: () => { onSuccess: () => {
// 3. Actualización instantánea del estado local
// Primero: Las materias que estaban en esa línea pasan a ser "huérfanas" // Primero: Las materias que estaban en esa línea pasan a ser "huérfanas"
setAsignaturas((prev) => setAsignaturas((prev) =>
prev.map((asig) => prev.map((asig) =>
@@ -369,8 +372,6 @@ function MapaCurricularPage() {
: asig, : asig,
), ),
) )
// Segundo: Quitamos la línea del estado
setLineas((prev) => prev.filter((l) => l.id !== id)) setLineas((prev) => prev.filter((l) => l.id !== id))
}, },
onError: (error) => { onError: (error) => {
@@ -427,8 +428,6 @@ function MapaCurricularPage() {
: m, : m,
), ),
) )
// 2. Persistir en la API
const patch = { const patch = {
numero_ciclo: ciclo, numero_ciclo: ciclo,
linea_plan_id: lineaId, linea_plan_id: lineaId,
@@ -464,6 +463,33 @@ function MapaCurricularPage() {
[asignaturas], [asignaturas],
) )
const handleKeyDownLinea = (
e: React.KeyboardEvent<HTMLSpanElement>,
id: string,
) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}
const handleBlurLinea = (
e: React.FocusEvent<HTMLSpanElement>,
id: string,
) => {
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
// Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id)
if (nuevoNombre !== lineaOriginal?.nombre) {
// IMPORTANTE: Pasamos nuevoNombre directamente
guardarEdicionLinea(id, nuevoNombre)
} else {
setEditingLineaId(null)
}
}
if (loadingAsig || loadingLineas) if (loadingAsig || loadingLineas)
return <div className="p-10 text-center">Cargando mapa curricular...</div> return <div className="p-10 text-center">Cargando mapa curricular...</div>
@@ -578,36 +604,52 @@ function MapaCurricularPage() {
}} }}
> >
<div <div
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`} 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' : ''}`}
> >
{editingLineaId === linea.id ? ( <div className="flex-1 overflow-hidden">
<Input
autoFocus
className="h-7 bg-white text-xs"
value={tempNombreLinea}
onChange={(e) => setTempNombreLinea(e.target.value)}
onBlur={() => guardarEdicionLinea(linea.id)}
onKeyDown={(e) =>
e.key === 'Enter' && guardarEdicionLinea(linea.id)
}
/>
) : (
<span <span
className="cursor-pointer text-xs font-bold hover:underline" contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => { onClick={() => {
setEditingLineaId(linea.id) if (editingLineaId !== linea.id) {
setTempNombreLinea(linea.nombre) setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}} }}
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-pointer'
}`}
> >
{linea.nombre} {linea.nombre}
</span> </span>
)} </div>
<Trash2 <div className="flex items-center gap-2">
size={14} {/* Botón de edición que aparece en hover o si está editando */}
className="cursor-pointer text-slate-400 hover:text-red-500" <button
onClick={() => borrarLinea(linea.id)} // Aquí también podrías añadir una mutación delete onClick={() => setEditingLineaId(linea.id)}
/> className={`text-slate-400 transition-opacity hover:text-teal-600 ${
editingLineaId === linea.id
? 'opacity-0'
: 'opacity-0 group-hover:opacity-100'
}`}
>
<Pencil size={12} />
</button>
<Trash2
size={14}
className="cursor-pointer text-slate-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500"
onClick={() => borrarLinea(linea.id)}
/>
</div>
</div> </div>
{ciclosArray.map((ciclo) => ( {ciclosArray.map((ciclo) => (
@@ -747,6 +789,7 @@ function MapaCurricularPage() {
Clave Clave
</label> </label>
<Input <Input
maxLength={100}
value={editingData.clave} value={editingData.clave}
onChange={(e) => onChange={(e) =>
setEditingData({ ...editingData, clave: e.target.value }) setEditingData({ ...editingData, clave: e.target.value })
@@ -758,6 +801,7 @@ function MapaCurricularPage() {
Nombre Nombre
</label> </label>
<Input <Input
maxLength={200}
value={editingData.nombre} value={editingData.nombre}
onChange={(e) => onChange={(e) =>
setEditingData({ ...editingData, nombre: e.target.value }) setEditingData({ ...editingData, nombre: e.target.value })
@@ -774,13 +818,17 @@ function MapaCurricularPage() {
</label> </label>
<Input <Input
type="number" type="number"
min={0}
value={editingData.creditos} value={editingData.creditos}
onChange={(e) => onChange={(e) => {
setEditingData({ const val = handleDecimalChange(e.target.value, 10)
...editingData, if (val !== null) {
creditos: Number(e.target.value), setEditingData({
}) ...editingData,
} creditos: val === '' ? 0 : Number(val),
})
}
}}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -790,12 +838,15 @@ function MapaCurricularPage() {
<Input <Input
type="number" type="number"
value={editingData.hd} value={editingData.hd}
onChange={(e) => onChange={(e) => {
setEditingData({ const val = handleIntegerChange(e.target.value)
...editingData, if (val !== null) {
hd: Number(e.target.value), setEditingData({
}) ...editingData,
} hd: Number(e.target.value),
})
}
}}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -805,12 +856,15 @@ function MapaCurricularPage() {
<Input <Input
type="number" type="number"
value={editingData.hi} value={editingData.hi}
onChange={(e) => onChange={(e) => {
setEditingData({ const val = handleIntegerChange(e.target.value)
...editingData, if (val !== null) {
hi: Number(e.target.value), setEditingData({
}) ...editingData,
} hi: Number(e.target.value),
})
}
}}
/> />
</div> </div>
</div> </div>
@@ -882,23 +936,29 @@ function MapaCurricularPage() {
Seriación (Prerrequisitos) Seriación (Prerrequisitos)
</label> </label>
<Select <Select
value={seriacionValue}
onValueChange={(val) => { onValueChange={(val) => {
// Evitamos duplicados en la lista de prerrequisitos local if (val === 'unassigned') {
setSeriacionValue('unassigned')
return
}
if (!editingData.prerrequisitos.includes(val)) { if (!editingData.prerrequisitos.includes(val)) {
setEditingData({ setEditingData({
...editingData, ...editingData,
prerrequisitos: [...editingData.prerrequisitos, val], prerrequisitos: [...editingData.prerrequisitos, val],
}) })
} }
setSeriacionValue('unassigned')
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Seleccionar asignatura..." /> <SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* FILTRO CLAVE: <SelectItem value="unassigned">
Solo mostramos materias cuyo ID sea diferente al de la materia que estamos editando -- Sin Seriación --
*/} </SelectItem>
{asignaturas {asignaturas
.filter((m) => m.id !== editingData.id) .filter((m) => m.id !== editingData.id)
.map((m) => ( .map((m) => (