Merge pull request 'En el mapa curricular editar los nombres de las líneas curriculares #57' (#65) from issue/57-en-el-mapa-curricular-editar-los-nombres-de-las-ln into main

Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
2026-02-04 20:34:07 +00:00
4 changed files with 212 additions and 44 deletions

View File

@@ -243,3 +243,52 @@ export async function asignaturas_update(
throwIfError(error) throwIfError(error)
return requireData(data, 'No se pudo actualizar la asignatura.') return requireData(data, 'No se pudo actualizar la asignatura.')
} }
// Insertar una nueva línea
export async function lineas_insert(linea: {
nombre: string
plan_estudio_id: string
orden: number
area?: string
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
.insert([linea])
.select()
.single()
if (error) throw error
return data
}
// Actualizar una línea existente
export async function lineas_update(
lineaId: string,
patch: { nombre?: string; orden?: number; area?: string },
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan')
.update(patch)
.eq('id', lineaId)
.select()
.single()
if (error) throw error
return data
}
export async function lineas_delete(lineaId: string) {
const supabase = supabaseBrowser()
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
const { error } = await supabase
.from('lineas_plan')
.delete()
.eq('id', lineaId)
if (error) throw error
return lineaId
}

View File

@@ -23,6 +23,7 @@ import {
plans_update_fields, plans_update_fields,
plans_update_map, plans_update_map,
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import type { import type {
@@ -257,3 +258,15 @@ export function useGeneratePlanDocumento() {
}, },
}) })
} }
export function useDeleteLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_delete,
onSuccess: (idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
},
})
}

View File

@@ -3,6 +3,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ai_generate_subject, ai_generate_subject,
asignaturas_update, asignaturas_update,
lineas_insert,
lineas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
subjects_clone_from_existing, subjects_clone_from_existing,
subjects_create_manual, subjects_create_manual,
@@ -222,3 +224,28 @@ export function useUpdateAsignatura() {
}, },
}) })
} }
export function useCreateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_insert,
onSuccess: (nuevaLinea) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
})
},
})
}
export function useUpdateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: { lineaId: string; patch: any }) =>
lineas_update(vars.lineaId, vars.patch),
onSuccess: (updated) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', updated.plan_estudio_id],
})
},
})
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* 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 {
@@ -33,7 +34,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { usePlanAsignaturas, usePlanLineas, useUpdateAsignatura } from '@/data' import {
useCreateLinea,
useDeleteLinea,
usePlanAsignaturas,
usePlanLineas,
useUpdateAsignatura,
useUpdateLinea,
} from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
@@ -166,8 +174,11 @@ export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
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 { ciclo } = Route.useSearch()
console.log(ciclo) const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
const [tempNombreLinea, setTempNombreLinea] = useState('')
const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea()
// 1. Fetch de Datos // 1. Fetch de Datos
const { data: asignaturasApi, isLoading: loadingAsig } = const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
@@ -189,55 +200,78 @@ function MapaCurricularPage() {
const manejarAgregarLinea = (nombre: string) => { const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim() const nombreNormalizado = nombre.trim()
// 1. Validar que no esté vacío // 1. Validar vacío
if (!nombreNormalizado) return if (!nombreNormalizado) return
// 2. Validar duplicados (Insensible a mayúsculas/minúsculas y acentos) // 2. Validar duplicados en el estado local (Insensible a mayúsculas/acentos)
const nombreParaComparar = nombreNormalizado const nombreBusqueda = nombreNormalizado
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
const yaExiste = lineas.some((l) => { const yaExiste = lineas.some((l) => {
const lineaNombreBase = l.nombre const lineaExistente = l.nombre
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
return lineaNombreBase === nombreParaComparar return lineaExistente === nombreBusqueda
}) })
if (yaExiste) { if (yaExiste) {
alert(`La línea "${nombreNormalizado}" ya existe.`) alert(`La línea "${nombreNormalizado}" ya existe en este plan.`)
return return // DETIENE la ejecución aquí, no llega a la mutación
} }
// 3. Validar Área Común (usando tu lógica previa) // 3. Si pasa las validaciones, procedemos con la persistencia
const esAreaComun = const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun'
if (esAreaComun && hasAreaComun) { createLinea(
alert('El Área Común ya ha sido agregada.') {
return nombre: nombreNormalizado,
} plan_estudio_id: planId,
orden: maxOrden + 1,
// 4. Agregar la línea si todo está bien area: 'sin asignar',
const nueva = { },
id: crypto.randomUUID(), {
nombre: nombreNormalizado, onSuccess: (nueva) => {
orden: lineas.length + 1, // Sincronización local que ya teníamos
color: '#1976d2', const mapeada = {
} id: nueva.id,
nombre: nueva.nombre,
setLineas([...lineas, nueva]) orden: nueva.orden,
color: '#1976d2',
if (esAreaComun) { }
setHasAreaComun(true) setLineas((prev) => [...prev, mapeada])
} setNombreNuevaLinea('')
},
setNombreNuevaLinea('') // Limpiar input },
)
} }
const guardarEdicionLinea = (id: string) => {
if (!tempNombreLinea.trim()) {
setEditingLineaId(null)
return
}
updateLineaApi(
{
lineaId: id,
patch: { nombre: tempNombreLinea.trim() },
},
{
onSuccess: (lineaActualizada) => {
// ACTUALIZACIÓN MANUAL DEL ESTADO LOCAL
setLineas((prev) =>
prev.map((l) =>
l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l,
),
)
setEditingLineaId(null)
setTempNombreLinea('')
},
},
)
}
const tieneAreaComun = useMemo(() => { const tieneAreaComun = useMemo(() => {
return lineas.some( return lineas.some(
(l) => (l) =>
@@ -313,14 +347,37 @@ function MapaCurricularPage() {
} }
const borrarLinea = (id: string) => { const borrarLinea = (id: string) => {
setAsignaturas((prev) => // 1. Opcional: Confirmación de seguridad
prev.map((m) => if (
m.lineaCurricularId === id !confirm(
? { ...m, ciclo: null, lineaCurricularId: null } '¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
: m, )
), ) {
) return
setLineas((prev) => prev.filter((l) => l.id !== id)) }
// 2. Llamada a la API
deleteLineaApi(id, {
onSuccess: () => {
// 3. Actualización instantánea del estado local
// Primero: Las materias que estaban en esa línea pasan a ser "huérfanas"
setAsignaturas((prev) =>
prev.map((asig) =>
asig.lineaCurricularId === id
? { ...asig, ciclo: null, lineaCurricularId: null }
: asig,
),
)
// Segundo: Quitamos la línea del estado
setLineas((prev) => prev.filter((l) => l.id !== id))
},
onError: (error) => {
console.error(error)
alert('No se pudo eliminar la línea. Verifica si tiene dependencias.')
},
})
} }
// --- Selectores/Cálculos --- // --- Selectores/Cálculos ---
@@ -523,11 +580,33 @@ function MapaCurricularPage() {
<div <div
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`} className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
> >
<span className="text-xs font-bold">{linea.nombre}</span> {editingLineaId === linea.id ? (
<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
className="cursor-pointer text-xs font-bold hover:underline"
onClick={() => {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}}
>
{linea.nombre}
</span>
)}
<Trash2 <Trash2
size={14} size={14}
className="cursor-pointer text-slate-400 hover:text-red-500" className="cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => borrarLinea(linea.id)} onClick={() => borrarLinea(linea.id)} // Aquí también podrías añadir una mutación delete
/> />
</div> </div>