1268 lines
43 KiB
TypeScript
1268 lines
43 KiB
TypeScript
/* 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 * as Icons from 'lucide-react'
|
|
import { useMemo, useState, useEffect, Fragment } from 'react'
|
|
|
|
import type { TipoAsignatura } from '@/data'
|
|
import type { Asignatura, LineaCurricular } from '@/types/plan'
|
|
import type { TablesUpdate } from '@/types/supabase'
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import {
|
|
useCreateLinea,
|
|
useDeleteLinea,
|
|
usePlan,
|
|
usePlanAsignaturas,
|
|
usePlanLineas,
|
|
useUpdateAsignatura,
|
|
useUpdateLinea,
|
|
} from '@/data'
|
|
|
|
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
|
const palette = [
|
|
'#4F46E5', // índigo
|
|
'#7C3AED', // violeta
|
|
'#EA580C', // naranja
|
|
'#059669', // esmeralda
|
|
'#DC2626', // rojo
|
|
'#0891B2', // cyan
|
|
'#CA8A04', // ámbar
|
|
'#C026D3', // fucsia
|
|
]
|
|
|
|
const mapLineasToLineaCurricular = (
|
|
lineasApi: Array<any> = [],
|
|
): Array<LineaCurricular> => {
|
|
return lineasApi.map((linea, index) => ({
|
|
id: linea.id,
|
|
nombre: linea.nombre,
|
|
orden: linea.orden ?? 0,
|
|
color: palette[index % palette.length],
|
|
}))
|
|
}
|
|
|
|
const mapAsignaturasToAsignaturas = (
|
|
asigApi: Array<any> = [],
|
|
): Array<Asignatura> => {
|
|
return asigApi.map((asig) => {
|
|
return {
|
|
id: asig.id,
|
|
clave: asig.codigo,
|
|
nombre: asig.nombre,
|
|
creditos: asig.creditos ?? 0,
|
|
ciclo: asig.numero_ciclo ?? null,
|
|
lineaCurricularId: asig.linea_plan_id ?? null,
|
|
tipo: asig.tipo,
|
|
estado: 'borrador',
|
|
orden: asig.orden_celda ?? 0,
|
|
// Mapeo directo de los nuevos campos de la API
|
|
hd: asig.horas_academicas ?? 0,
|
|
hi: asig.horas_independientes ?? 0,
|
|
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
|
|
}
|
|
})
|
|
}
|
|
|
|
const lineColors = [
|
|
'bg-blue-50 border-blue-200 text-blue-700',
|
|
'bg-purple-50 border-purple-200 text-purple-700',
|
|
'bg-orange-50 border-orange-200 text-orange-700',
|
|
'bg-emerald-50 border-emerald-200 text-emerald-700',
|
|
]
|
|
|
|
const statusBadge: Record<string, string> = {
|
|
borrador: 'bg-slate-100 text-slate-600',
|
|
revisada: 'bg-amber-100 text-amber-700',
|
|
aprobada: 'bg-emerald-100 text-emerald-700',
|
|
}
|
|
|
|
// --- Subcomponentes ---
|
|
function StatItem({
|
|
label,
|
|
value,
|
|
total,
|
|
}: {
|
|
label: string
|
|
value: number
|
|
total?: number
|
|
}) {
|
|
return (
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
|
{label}:
|
|
</span>
|
|
<span className="text-sm font-bold text-slate-700">
|
|
{value}
|
|
{total ? (
|
|
<span className="font-normal text-slate-400">/{total}</span>
|
|
) : (
|
|
''
|
|
)}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const estadoConfig: Record<
|
|
Asignatura['estado'],
|
|
{
|
|
label: string
|
|
dot: string
|
|
soft: string
|
|
icon: React.ComponentType<{ className?: string }>
|
|
}
|
|
> = {
|
|
borrador: {
|
|
label: 'Borrador',
|
|
dot: 'bg-slate-500',
|
|
soft: 'bg-slate-100 text-slate-700',
|
|
icon: Icons.FileText,
|
|
},
|
|
revisada: {
|
|
label: 'Revisada',
|
|
dot: 'bg-amber-500',
|
|
soft: 'bg-amber-100 text-amber-700',
|
|
icon: Icons.ScanSearch,
|
|
},
|
|
aprobada: {
|
|
label: 'Aprobada',
|
|
dot: 'bg-emerald-500',
|
|
soft: 'bg-emerald-100 text-emerald-700',
|
|
icon: Icons.BadgeCheck,
|
|
},
|
|
generando: {
|
|
label: 'Generando',
|
|
dot: 'bg-sky-500',
|
|
soft: 'bg-sky-100 text-sky-700',
|
|
icon: Icons.LoaderCircle,
|
|
},
|
|
}
|
|
|
|
function hexToRgba(hex: string, alpha: number) {
|
|
const clean = hex.replace('#', '')
|
|
const bigint = parseInt(clean, 16)
|
|
const r = (bigint >> 16) & 255
|
|
const g = (bigint >> 8) & 255
|
|
const b = bigint & 255
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
}
|
|
|
|
function AsignaturaCardItem({
|
|
asignatura,
|
|
lineaColor,
|
|
lineaNombre,
|
|
onDragStart,
|
|
isDragging,
|
|
onClick,
|
|
}: {
|
|
asignatura: Asignatura
|
|
lineaColor: string
|
|
lineaNombre?: string
|
|
onDragStart: (e: React.DragEvent, id: string) => void
|
|
isDragging: boolean
|
|
onClick: () => void
|
|
}) {
|
|
const estado = estadoConfig[asignatura.estado]
|
|
const EstadoIcon = estado.icon
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={150}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
draggable
|
|
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
|
onClick={onClick}
|
|
className={[
|
|
'group relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left',
|
|
'transition-all duration-300 ease-out',
|
|
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
|
|
'cursor-grab active:cursor-grabbing',
|
|
isDragging
|
|
? 'scale-[0.985] opacity-45 shadow-none'
|
|
: 'hover:-translate-y-1 hover:shadow-lg',
|
|
].join(' ')}
|
|
style={{
|
|
borderColor: hexToRgba(lineaColor, 0.18),
|
|
background: `
|
|
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
|
|
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
|
|
`,
|
|
}}
|
|
title={asignatura.nombre}
|
|
>
|
|
{/* franja */}
|
|
<div
|
|
className="absolute inset-x-0 top-0 h-2"
|
|
style={{ backgroundColor: lineaColor }}
|
|
/>
|
|
|
|
{/* glow decorativo */}
|
|
<div
|
|
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl"
|
|
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
|
|
/>
|
|
|
|
<div className="relative flex h-full flex-col p-4">
|
|
{/* top */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div
|
|
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),
|
|
color: lineaColor,
|
|
}}
|
|
>
|
|
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate">
|
|
{asignatura.clave || 'Sin clave'}
|
|
</span>
|
|
</div>
|
|
|
|
<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>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* titulo */}
|
|
<div className="mt-4 min-h-18">
|
|
<h3
|
|
className="text-foreground text-md overflow-hidden leading-[1.08] font-bold"
|
|
style={{
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 3,
|
|
WebkitBoxOrient: 'vertical',
|
|
}}
|
|
>
|
|
{asignatura.nombre}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* 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="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 tracking-wide uppercase">
|
|
CR
|
|
</span>
|
|
</div>
|
|
<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="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 tracking-wide uppercase">
|
|
HD
|
|
</span>
|
|
</div>
|
|
<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="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 tracking-wide uppercase">
|
|
HI
|
|
</span>
|
|
</div>
|
|
<div className="text-foreground text-sm font-bold">
|
|
{asignatura.hi}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* drag affordance */}
|
|
<div className="bg-background/70 pointer-events-none absolute right-3 bottom-3 rounded-full p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
|
|
<Icons.GripVertical className="text-muted-foreground/55 h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent side="bottom">
|
|
<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>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
|
component: MapaCurricularPage,
|
|
})
|
|
|
|
function MapaCurricularPage() {
|
|
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
|
|
const { data } = usePlan(planId)
|
|
const [ciclo, setCiclo] = useState(0)
|
|
const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
|
|
const [tempNombreLinea, setTempNombreLinea] = useState('')
|
|
const { mutate: createLinea } = useCreateLinea()
|
|
const { mutate: updateLineaApi } = useUpdateLinea()
|
|
const { mutate: deleteLineaApi } = useDeleteLinea()
|
|
const { data: asignaturaApi, isLoading: loadingAsig } =
|
|
usePlanAsignaturas(planId)
|
|
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
|
|
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
|
|
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
|
|
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
|
|
null,
|
|
)
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
|
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
|
|
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
|
const [seriacionValue, setSeriacionValue] = useState<string>('')
|
|
|
|
useEffect(() => {
|
|
if (data?.numero_ciclos) {
|
|
setCiclo(data.numero_ciclos)
|
|
}
|
|
}, [data])
|
|
|
|
const manejarAgregarLinea = (nombre: string) => {
|
|
const nombreNormalizado = nombre.trim()
|
|
if (!nombreNormalizado) return
|
|
const nombreBusqueda = nombreNormalizado
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
|
|
const yaExiste = lineas.some((l) => {
|
|
const lineaExistente = l.nombre
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
return lineaExistente === nombreBusqueda
|
|
})
|
|
|
|
if (yaExiste) {
|
|
alert(`La línea "${nombreNormalizado}" ya existe en este plan.`)
|
|
return
|
|
}
|
|
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
|
|
createLinea(
|
|
{
|
|
nombre: nombreNormalizado,
|
|
plan_estudio_id: planId,
|
|
orden: maxOrden + 1,
|
|
area: 'sin asignar',
|
|
},
|
|
{
|
|
onSuccess: (nueva) => {
|
|
const mapeada = {
|
|
id: nueva.id,
|
|
nombre: nueva.nombre,
|
|
orden: nueva.orden,
|
|
color: '#1976d2',
|
|
}
|
|
setLineas((prev) => [...prev, mapeada])
|
|
setNombreNuevaLinea('')
|
|
},
|
|
},
|
|
)
|
|
}
|
|
const guardarEdicionLinea = (id: string, nuevoNombre?: string) => {
|
|
// 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)
|
|
return
|
|
}
|
|
|
|
updateLineaApi(
|
|
{
|
|
lineaId: id,
|
|
patch: { nombre: nombreAFijar },
|
|
},
|
|
{
|
|
onSuccess: (lineaActualizada) => {
|
|
setLineas((prev) =>
|
|
prev.map((l) =>
|
|
l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l,
|
|
),
|
|
)
|
|
setEditingLineaId(null)
|
|
setTempNombreLinea('')
|
|
},
|
|
onError: (err) => {
|
|
console.error('Error al actualizar linea:', err)
|
|
// Opcional: revertir cambios o avisar al usuario
|
|
},
|
|
},
|
|
)
|
|
}
|
|
const tieneAreaComun = useMemo(() => {
|
|
return lineas.some(
|
|
(l) =>
|
|
l.nombre.toLowerCase() === 'área común' ||
|
|
l.nombre.toLowerCase() === 'area comun',
|
|
)
|
|
}, [lineas])
|
|
|
|
useEffect(() => {
|
|
if (asignaturaApi)
|
|
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
|
|
}, [asignaturaApi])
|
|
|
|
useEffect(() => {
|
|
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
|
}, [lineasApi])
|
|
|
|
const ciclosTotales = Number(ciclo)
|
|
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
|
|
const [editingData, setEditingData] = useState<Asignatura | null>(null)
|
|
const handleIntegerChange = (value: string) => {
|
|
if (value === '') return value
|
|
|
|
// 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 = () => {
|
|
if (!editingData) return
|
|
setAsignaturas((prev) =>
|
|
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
|
)
|
|
type AsignaturaPatch = {
|
|
codigo?: TablesUpdate<'asignaturas'>['codigo']
|
|
nombre?: TablesUpdate<'asignaturas'>['nombre']
|
|
tipo?: TablesUpdate<'asignaturas'>['tipo']
|
|
creditos?: TablesUpdate<'asignaturas'>['creditos']
|
|
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas']
|
|
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
|
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
|
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
|
prerrequisito_asignatura_id?: string | null
|
|
}
|
|
const patch: Partial<AsignaturaPatch> = {
|
|
nombre: editingData.nombre,
|
|
codigo: editingData.clave,
|
|
creditos: editingData.creditos,
|
|
horas_academicas: editingData.hd,
|
|
horas_independientes: editingData.hi,
|
|
numero_ciclo: editingData.ciclo,
|
|
linea_plan_id: editingData.lineaCurricularId,
|
|
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
|
|
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
|
}
|
|
|
|
updateAsignatura(
|
|
{ asignaturaId: editingData.id, patch: patch as any },
|
|
{
|
|
onSuccess: () => {
|
|
setIsEditModalOpen(false)
|
|
// Opcional: Mostrar un toast de éxito
|
|
},
|
|
onError: (error) => {
|
|
console.error('Error al guardar:', error)
|
|
alert('Hubo un error al guardar los cambios.')
|
|
},
|
|
},
|
|
)
|
|
}
|
|
const unassignedAsignaturas = asignaturas.filter(
|
|
(m) => m.ciclo === null || m.lineaCurricularId === null,
|
|
)
|
|
|
|
const borrarLinea = (id: string) => {
|
|
if (
|
|
!confirm(
|
|
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
deleteLineaApi(id, {
|
|
onSuccess: () => {
|
|
// 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,
|
|
),
|
|
)
|
|
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 ---
|
|
const getTotalesCiclo = (ciclo: number) => {
|
|
return asignaturas
|
|
.filter((m) => m.ciclo === ciclo)
|
|
.reduce(
|
|
(acc, m) => ({
|
|
cr: acc.cr + (m.creditos || 0),
|
|
hd: acc.hd + (m.hd || 0),
|
|
hi: acc.hi + (m.hi || 0),
|
|
}),
|
|
{ cr: 0, hd: 0, hi: 0 },
|
|
)
|
|
}
|
|
|
|
const getSubtotalLinea = (lineaId: string) => {
|
|
return asignaturas
|
|
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) // Aseguramos que pertenezca a la línea Y tenga ciclo
|
|
.reduce(
|
|
(acc, m) => ({
|
|
cr: acc.cr + (m.creditos || 0),
|
|
hd: acc.hd + (m.hd || 0),
|
|
hi: acc.hi + (m.hi || 0),
|
|
}),
|
|
{ cr: 0, hd: 0, hi: 0 },
|
|
)
|
|
}
|
|
|
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
|
setDraggedAsignatura(id)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
}
|
|
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
|
const handleDrop = (
|
|
e: React.DragEvent,
|
|
ciclo: number | null,
|
|
lineaId: string | null,
|
|
) => {
|
|
e.preventDefault()
|
|
if (draggedAsignatura) {
|
|
// 1. Actualización optimista del UI
|
|
setAsignaturas((prev) =>
|
|
prev.map((m) =>
|
|
m.id === draggedAsignatura
|
|
? { ...m, ciclo, lineaCurricularId: lineaId }
|
|
: m,
|
|
),
|
|
)
|
|
const patch = {
|
|
numero_ciclo: ciclo,
|
|
linea_plan_id: lineaId,
|
|
}
|
|
|
|
updateAsignatura(
|
|
{ asignaturaId: draggedAsignatura, patch },
|
|
{
|
|
onError: (error) => {
|
|
console.error('Error al mover:', error)
|
|
// Opcional: Revertir el estado local si falla
|
|
},
|
|
},
|
|
)
|
|
|
|
setDraggedAsignatura(null)
|
|
}
|
|
}
|
|
|
|
const stats = useMemo(
|
|
() =>
|
|
asignaturas.reduce(
|
|
(acc, m) => {
|
|
if (m.ciclo !== null) {
|
|
acc.cr += m.creditos || 0
|
|
acc.hd += m.hd || 0
|
|
acc.hi += m.hi || 0
|
|
}
|
|
return acc
|
|
},
|
|
{ cr: 0, hd: 0, hi: 0 },
|
|
),
|
|
[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)
|
|
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
|
|
|
return (
|
|
<div className="container">
|
|
{/* Header */}
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-bold">Mapa Curricular</h2>
|
|
<p className="text-sm text-slate-500">
|
|
Organiza las asignaturas de la petición por línea y ciclo
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
|
<Plus size={16} className="mr-2" /> Exportar{' '}
|
|
</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>
|
|
)}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
|
<Plus size={16} className="mr-2" /> Agregar{' '}
|
|
<ChevronDown size={14} className="ml-2" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{!tieneAreaComun && (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={() => manejarAgregarLinea('Área Común')}
|
|
className="font-bold text-teal-700"
|
|
>
|
|
+ Agregar Área Común
|
|
</DropdownMenuItem>
|
|
<div className="my-1 border-t border-slate-100" />
|
|
</>
|
|
)}
|
|
{/* Input para nombre personalizado */}
|
|
<div className="p-2">
|
|
<label className="text-[10px] font-bold text-slate-400 uppercase">
|
|
Nombre de Línea
|
|
</label>
|
|
<div className="mt-1 flex gap-1">
|
|
<Input
|
|
value={nombreNuevaLinea}
|
|
onChange={(e) => setNombreNuevaLinea(e.target.value)}
|
|
placeholder="Ej: Optativas"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
|
|
disabled={!nombreNuevaLinea.trim()}
|
|
>
|
|
<Plus size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Barra Totales */}
|
|
<div className="mb-8 flex gap-10 rounded-xl border border-slate-200 bg-slate-50/80 p-4">
|
|
<StatItem label="Total Créditos" value={stats.cr} total={320} />
|
|
<StatItem label="Total HD" value={stats.hd} />
|
|
<StatItem label="Total HI" value={stats.hi} />
|
|
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
|
|
</div>
|
|
|
|
<div className="overflow-x-auto pb-6">
|
|
<div
|
|
className="grid gap-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>
|
|
))}
|
|
|
|
<div className="self-end text-center text-xs font-bold text-slate-400">
|
|
SUBTOTAL
|
|
</div>
|
|
|
|
{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-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="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>
|
|
|
|
{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="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="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-foreground text-sm font-bold tracking-wide uppercase">
|
|
Bandeja de entrada
|
|
</h3>
|
|
|
|
<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="text-muted-foreground mt-0.5 text-sm">
|
|
Asignaturas sin ciclo o línea curricular
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDrop={(e) => handleDrop(e, null, null)}
|
|
className={[
|
|
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
|
|
'min-h-[220px]',
|
|
draggedAsignatura
|
|
? 'border-primary/35 bg-primary/6 shadow-inner'
|
|
: 'border-border bg-muted/20',
|
|
].join(' ')}
|
|
>
|
|
{unassignedAsignaturas.length > 0 ? (
|
|
<div className="flex flex-wrap gap-4">
|
|
{unassignedAsignaturas.map((m) => (
|
|
<div key={m.id} className="w-[272px] shrink-0">
|
|
<AsignaturaCardItem
|
|
asignatura={m}
|
|
lineaColor="#94A3B8"
|
|
lineaNombre="Sin asignar"
|
|
isDragging={draggedAsignatura === m.id}
|
|
onDragStart={handleDragStart}
|
|
onClick={() => {
|
|
setEditingData(m)
|
|
setIsEditModalOpen(true)
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<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-foreground text-sm font-semibold">
|
|
No hay asignaturas pendientes
|
|
</p>
|
|
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal de Edición */}
|
|
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
|
<DialogContent
|
|
className="sm:max-w-[550px]"
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="font-bold text-slate-700">
|
|
Editar Asignatura
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* Verificación de seguridad: solo renderiza si hay datos */}
|
|
{editingData ? (
|
|
<div className="grid gap-4 py-4">
|
|
{/* Fila 1: Clave y Nombre */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Clave
|
|
</label>
|
|
<Input
|
|
maxLength={100}
|
|
value={editingData.clave}
|
|
onChange={(e) =>
|
|
setEditingData({ ...editingData, clave: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Nombre
|
|
</label>
|
|
<Input
|
|
maxLength={200}
|
|
value={editingData.nombre}
|
|
onChange={(e) =>
|
|
setEditingData({ ...editingData, nombre: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fila 2: Créditos y Horas */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Créditos
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={editingData.creditos}
|
|
onChange={(e) => {
|
|
const val = handleDecimalChange(e.target.value, 10)
|
|
if (val !== null) {
|
|
setEditingData({
|
|
...editingData,
|
|
creditos: val === '' ? 0 : Number(val),
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
HD (Horas Docente)
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
value={editingData.hd}
|
|
onChange={(e) => {
|
|
const val = handleIntegerChange(e.target.value)
|
|
if (val !== null) {
|
|
setEditingData({
|
|
...editingData,
|
|
hd: Number(e.target.value),
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
HI (Horas Indep.)
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
value={editingData.hi}
|
|
onChange={(e) => {
|
|
const val = handleIntegerChange(e.target.value)
|
|
if (val !== null) {
|
|
setEditingData({
|
|
...editingData,
|
|
hi: Number(e.target.value),
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fila 3: Ciclo y Línea */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Ciclo
|
|
</label>
|
|
<Select
|
|
value={editingData.ciclo?.toString() || 'unassigned'}
|
|
onValueChange={(val) =>
|
|
setEditingData({
|
|
...editingData,
|
|
ciclo: val === 'unassigned' ? null : Number(val),
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="unassigned">
|
|
-- Sin Asignar --
|
|
</SelectItem>
|
|
{ciclosArray.map((n) => (
|
|
<SelectItem key={n} value={n.toString()}>
|
|
Ciclo {n}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Línea Curricular
|
|
</label>
|
|
<Select
|
|
value={editingData.lineaCurricularId || 'unassigned'}
|
|
onValueChange={(val) =>
|
|
setEditingData({
|
|
...editingData,
|
|
lineaCurricularId: val === 'unassigned' ? null : val,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="unassigned">
|
|
-- Sin Asignar --
|
|
</SelectItem>
|
|
{lineas.map((l) => (
|
|
<SelectItem key={l.id} value={l.id}>
|
|
{l.nombre}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fila 4: Seriación (Prerrequisitos) */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Seriación (Prerrequisito)
|
|
</label>
|
|
<Select
|
|
// Cambiamos a manejo de valor único basado en el ID de la columna
|
|
value={editingData.prerrequisito_asignatura_id || undefined}
|
|
onValueChange={(val) => {
|
|
console.log(editingData)
|
|
|
|
setEditingData({
|
|
...editingData,
|
|
prerrequisito_asignatura_id: val === 'none' ? null : val,
|
|
})
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full bg-white">
|
|
<SelectValue placeholder="Seleccionar asignatura..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
|
|
|
{asignaturas
|
|
.filter((asig) => {
|
|
// 1. No es la misma materia
|
|
const noEsMisma = asig.id !== editingData.id
|
|
// 2. El ciclo debe ser estrictamente MENOR
|
|
const esCicloMenor =
|
|
asig.ciclo !== null &&
|
|
editingData.ciclo !== null &&
|
|
asig.ciclo < editingData.ciclo
|
|
|
|
return noEsMisma && esCicloMenor
|
|
})
|
|
.sort(
|
|
(a, b) =>
|
|
(a.ciclo || 0) - (b.ciclo || 0) ||
|
|
a.nombre.localeCompare(b.nombre),
|
|
)
|
|
.map((asig) => (
|
|
<SelectItem key={asig.id} value={asig.id}>
|
|
<span className="font-bold text-teal-600">
|
|
[C{asig.ciclo}]
|
|
</span>{' '}
|
|
{asig.nombre}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Visualización del Prerrequisito con el Nombre */}
|
|
</div>
|
|
|
|
{/* Fila 5: Tipo */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
Tipo
|
|
</label>
|
|
<Select
|
|
value={editingData.tipo}
|
|
onValueChange={(val: 'OBLIGATORIA' | 'OPTATIVA') =>
|
|
setEditingData({ ...editingData, tipo: val })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="OBLIGATORIA">Obligatoria</SelectItem>
|
|
<SelectItem value="OPTATIVA">Optativa</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="mt-4 flex justify-end gap-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsEditModalOpen(false)}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
className="bg-teal-700 text-white"
|
|
onClick={handleSaveChanges}
|
|
>
|
|
Guardar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-20 text-center">No hay datos seleccionados</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|