diff --git a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx index 66fb691..5e4430f 100644 --- a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx +++ b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx @@ -657,13 +657,19 @@ function InfoCard({ >
- + + {Array.isArray(tempText) && tempText.length > 0 + ? `${tempText[0].code} - ${tempText[0].name}` + : undefined} +
+ Ninguna (Sin seriación) + {availableSubjects?.map((asig) => ( [C{asig.numero_ciclo}] {' '} - + {asig.codigo} - {asig.nombre} diff --git a/src/components/asignaturas/detalle/BibliographyItem.tsx b/src/components/asignaturas/detalle/BibliographyItem.tsx index c566c3a..a6c4b37 100644 --- a/src/components/asignaturas/detalle/BibliographyItem.tsx +++ b/src/components/asignaturas/detalle/BibliographyItem.tsx @@ -125,7 +125,7 @@ export function BibliographyItem() { return
Cargando bibliografía...
return ( -
+

diff --git a/src/components/asignaturas/detalle/ContenidoTematico.tsx b/src/components/asignaturas/detalle/ContenidoTematico.tsx index 70a5165..806d7af 100644 --- a/src/components/asignaturas/detalle/ContenidoTematico.tsx +++ b/src/components/asignaturas/detalle/ContenidoTematico.tsx @@ -662,7 +662,7 @@ export function ContenidoTematico() { } return ( -
+

diff --git a/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx b/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx index 17d629f..a4591a0 100644 --- a/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx +++ b/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx @@ -163,7 +163,7 @@ export function PasoFuenteClonadoInterno({ return (
- + Fuente @@ -297,7 +297,7 @@ export function PasoFuenteClonadoInterno({ Selecciona una asignatura fuente (solo una).
-
+
{subjectsLoading ? (
Cargando asignaturas… diff --git a/src/components/planes/PlanEstudiosCard.tsx b/src/components/planes/PlanEstudiosCard.tsx index 17c1799..3cd31ef 100644 --- a/src/components/planes/PlanEstudiosCard.tsx +++ b/src/components/planes/PlanEstudiosCard.tsx @@ -4,20 +4,17 @@ import type { LucideIcon } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' -import { cn } from '@/lib/utils' // Asegúrate de tener tu utilidad cn +import { cn } from '@/lib/utils' interface PlanEstudiosCardProps { - /** El componente del ícono importado de lucide-react (ej. BookOpen) */ Icono: LucideIcon nombrePrograma: string nivel: string - ciclos: string | number // Acepta "8" o "8 semestres" + ciclos: string | number facultad: string estado: string - /** Código hex o variable CSS (ej. "#ef4444" o "var(--primary)") */ claseColorEstado?: string colorFacultad: string - /** Opcional: para manejar el click en la tarjeta */ onClick?: () => void } @@ -36,45 +33,39 @@ export default function PlanEstudiosCard({ - - {/* Ícono con el color de la facultad */} -
- -
+
+ + {/* Círculo del ícono con el color de la facultad */} +
+ +
- {/* Título del Programa */} -

- {nombrePrograma} -

-
+ {/* Título del Programa */} +

+ {nombrePrograma} +

+ - -

- {nivel} • {ciclos} -

-

{facultad}

-
+ +

+ {nivel} • {ciclos} +

+

{facultad}

+
+
- - + + {estado} - {/* - {estado} - */} {/* Flecha animada */}
- + {title}
-
+
{children}
diff --git a/src/data/api/plans.api.ts b/src/data/api/plans.api.ts index f9daed5..93dfcef 100644 --- a/src/data/api/plans.api.ts +++ b/src/data/api/plans.api.ts @@ -221,7 +221,9 @@ export async function plan_lineas_list( const supabase = supabaseBrowser() const { data, error } = await supabase .from('lineas_plan') - .select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en') + .select( + 'id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en,color', + ) .eq('plan_estudio_id', planId) .order('orden', { ascending: true }) diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 08cd8b8..303b047 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -499,6 +499,7 @@ export async function lineas_insert(linea: { plan_estudio_id: string orden: number area?: string + color?: string | null }) { const supabase = supabaseBrowser() const { data, error } = await supabase @@ -514,7 +515,12 @@ export async function lineas_insert(linea: { // Actualizar una línea existente export async function lineas_update( lineaId: string, - patch: { nombre?: string; orden?: number; area?: string }, + patch: { + nombre?: string + orden?: number + area?: string + color?: string | null + }, ) { const supabase = supabaseBrowser() const { data, error } = await supabase diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 2dc7df5..7f70a9e 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -30,7 +30,7 @@ function RouteComponent() { 4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop). 5. py-6: Padding vertical (opcional, para separarse del header). */} -
+
-
+
{/* 2. Header del Plan */} {isLoading ? ( /* ===== SKELETON ===== */ diff --git a/src/routes/planes/$planId/_detalle/asignaturas.tsx b/src/routes/planes/$planId/_detalle/asignaturas.tsx index 8de9d88..cd0ef8a 100644 --- a/src/routes/planes/$planId/_detalle/asignaturas.tsx +++ b/src/routes/planes/$planId/_detalle/asignaturas.tsx @@ -129,7 +129,7 @@ function AsignaturasPage() { } return ( -
+
{/* Header */}
diff --git a/src/routes/planes/$planId/_detalle/documento.tsx b/src/routes/planes/$planId/_detalle/documento.tsx index 2c2bdbf..2e1cd47 100644 --- a/src/routes/planes/$planId/_detalle/documento.tsx +++ b/src/routes/planes/$planId/_detalle/documento.tsx @@ -102,7 +102,7 @@ function RouteComponent() { } } return ( -
+
{/* HEADER DE ACCIONES */}
diff --git a/src/routes/planes/$planId/_detalle/flujo.tsx b/src/routes/planes/$planId/_detalle/flujo.tsx index eb3ba17..61d6272 100644 --- a/src/routes/planes/$planId/_detalle/flujo.tsx +++ b/src/routes/planes/$planId/_detalle/flujo.tsx @@ -18,7 +18,7 @@ function RouteComponent() { console.log(rawData) return ( -
+
{/* Header Informativo (Opcional, si no viene del layout padre) */}
diff --git a/src/routes/planes/$planId/_detalle/historial.tsx b/src/routes/planes/$planId/_detalle/historial.tsx index 51c3b30..345b8c6 100644 --- a/src/routes/planes/$planId/_detalle/historial.tsx +++ b/src/routes/planes/$planId/_detalle/historial.tsx @@ -125,7 +125,7 @@ function RouteComponent() { ) return ( -
+

diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index 2329252..ffaee91 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -581,7 +581,7 @@ function RouteComponent() { } return ( -
+
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
@@ -333,20 +319,26 @@ function MapaCurricularPage() { const { data } = usePlan(planId) const [totalCiclos, setTotalCiclos] = useState(0) const [editingLineaId, setEditingLineaId] = useState(null) - const { mutate: createLinea } = useCreateLinea() + const { mutate: createLinea, isPending: isCreatingLinea } = 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>([]) - const [lineas, setLineas] = useState>([]) + const [lineas, setLineas] = useState>([]) const [draggedAsignatura, setDraggedAsignatura] = useState( null, ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) - const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado + const [isAddLineaDialogOpen, setIsAddLineaDialogOpen] = useState(false) + const [selectedLineaOption, setSelectedLineaOption] = useState< + 'matematicas' | 'area_comun' | 'custom' | '' + >('') + const [customLineaNombre, setCustomLineaNombre] = useState('') + const [ultimoHue, setUltimoHue] = useState(null) const { mutate: updateAsignatura } = useUpdateAsignatura() + const inputRef = useRef(null) const { validarCambioCiclo } = useAsignaturaConflictos() const [confirmState, setConfirmState] = useState<{ isOpen: boolean @@ -399,6 +391,12 @@ function MapaCurricularPage() { } }, [data]) + useEffect(() => { + if (selectedLineaOption === 'custom' && inputRef.current) { + inputRef.current.focus() + } + }, [selectedLineaOption]) + const handleCambioCicloSeguro = async ( asignatura: Asignatura, nuevoCiclo: number, @@ -433,7 +431,7 @@ function MapaCurricularPage() { ) } - const manejarAgregarLinea = (nombre: string) => { + const manejarAgregarLinea = (nombre: string, color: string, hue: number) => { const nombreNormalizado = nombre.trim() if (!nombreNormalizado) return const nombreBusqueda = nombreNormalizado @@ -454,12 +452,14 @@ function MapaCurricularPage() { 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', + color, }, { onSuccess: (nueva) => { @@ -467,10 +467,56 @@ function MapaCurricularPage() { id: nueva.id, nombre: nueva.nombre, orden: nueva.orden, - color: '#1976d2', + color: nueva.color ?? color, } setLineas((prev) => [...prev, mapeada]) - setNombreNuevaLinea('') + setUltimoHue(hue) + setIsAddLineaDialogOpen(false) + setSelectedLineaOption('') + setCustomLineaNombre('') + }, + onError: (err) => { + console.error('Error al crear linea:', err) + }, + }, + ) + } + + const canAddLinea = + selectedLineaOption === 'matematicas' || + selectedLineaOption === 'area_comun' || + (selectedLineaOption === 'custom' && customLineaNombre.trim().length > 0) + + const handleAgregarLinea = () => { + if (!canAddLinea || isCreatingLinea) return + + const nombreSeleccionado = + selectedLineaOption === 'matematicas' + ? 'Matemáticas' + : selectedLineaOption === 'area_comun' + ? 'Área Común' + : customLineaNombre.trim() + + if (!nombreSeleccionado) return + + const { hex, hue } = generarColorContrastante(ultimoHue) + + manejarAgregarLinea(nombreSeleccionado, hex, hue) + } + + const cambiarColorLinea = (lineaId: string, nuevoColor: string) => { + setLineas((prev) => + prev.map((l) => (l.id === lineaId ? { ...l, color: nuevoColor } : l)), + ) + + updateLineaApi( + { + lineaId, + patch: { color: nuevoColor }, + }, + { + onError: (err) => { + console.error('Error al actualizar color de linea:', err) }, }, ) @@ -504,14 +550,6 @@ function MapaCurricularPage() { }, ) } - 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)) @@ -741,70 +779,42 @@ function MapaCurricularPage() { )}
-
- - {!tieneAreaComun && ( - + -
-
-
- -

- Crea una línea personalizada sin abrir menús adicionales. -

-
- -
- setNombreNuevaLinea(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && nombreNuevaLinea.trim()) { - manejarAgregarLinea(nombreNuevaLinea) - } - }} - placeholder="Ej: Optativas" - className="h-9" - /> - -
-
+ {/* Barra Totales */} +
+ + + +
- {/* Barra Totales */} -
- - - - -
-
@@ -830,7 +840,7 @@ function MapaCurricularPage() { return (
-
+ +
+
+
+ + cambiarColorLinea(linea.id, e.target.value) + } + className="absolute inset-0 h-full w-full cursor-pointer opacity-0" + /> + +
+ +
+
+ +
+
+
@@ -982,7 +1039,7 @@ function MapaCurricularPage() { ) })} -
+
{stats.cr} Cr
{stats.hd + stats.hi} Hrs
@@ -990,7 +1047,7 @@ function MapaCurricularPage() {
{/* Asignaturas Sin Asignar */} -
+
@@ -1071,6 +1128,150 @@ function MapaCurricularPage() {
{/* Modal de Edición */} + { + setIsAddLineaDialogOpen(open) + if (!open) { + setSelectedLineaOption(undefined) + setCustomLineaNombre('') + } + }} + > + + + + Agregar línea curricular + + + + + setSelectedLineaOption(val as typeof selectedLineaOption) + } + className="grid grid-cols-[1fr_auto_1fr] gap-8 py-4" + > + {/* Columna Izquierda: Predefinidas */} +
+
+ Catálogo Institucional +
+ + {/* Tarjeta: Matemáticas */} +
+ +
+ +

+ Línea base para ciencias exactas. +

+
+
+ + {/* Tarjeta: Área Común */} +
+ +
+ +

+ Materias compartidas entre programas. +

+
+
+
+ + {/* Separador */} +
+ +
+ + {/* Columna Derecha: Personalizada */} +
+
+ Línea personalizada +
+ + {/* Tarjeta: Custom */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() // Evita que la página haga scroll con el espacio + setSelectedLineaOption('custom') + inputRef.current?.focus() + } + }} + onClick={() => { + setSelectedLineaOption('custom') + inputRef.current?.focus() + }} + className={`focus-visible:ring-primary relative flex w-full cursor-pointer items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${ + selectedLineaOption === 'custom' + ? 'border-primary/50 bg-primary/5' + : 'border-input hover:bg-muted/50' + }`} + > + {/* Omitimos after:absolute para no tapar el input */} + +
+ + + setCustomLineaNombre(e.target.value.slice(0, 200)) + } + placeholder="Escribe el nombre aquí" + maxLength={200} + disabled={selectedLineaOption !== 'custom'} + className="bg-background h-9 w-full" + /> +
+
+
+
+ +
+ +
+
+
+
-
+
-
+
{/* CAMBIOS CLAVE: 1. overflow-x-auto: Permite scroll horizontal. 2. scrollbar-hide: (Opcional) para que no se vea la barra fea. 3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop. */} -
+
{[ { label: 'Datos', to: '' }, { label: 'Contenido', to: 'contenido' }, @@ -322,7 +322,7 @@ function AsignaturaLayout() {
-
+
diff --git a/src/routes/planes/_lista.tsx b/src/routes/planes/_lista.tsx index f58283c..547c325 100644 --- a/src/routes/planes/_lista.tsx +++ b/src/routes/planes/_lista.tsx @@ -101,13 +101,20 @@ function RouteComponent() { setPage(0) // Resetear página al buscar } + // Deshabilitar el botón 'Limpiar' si no hay filtros distintos al valor por defecto + const isClearDisabled = + cleanSearchTerm === '' && + facultadSel === 'todas' && + carreraSel === 'todas' && + estadoSel === 'todos' + // Renderizado condicional básico if (isError) return
Error cargando planes.
return (
-
+
{/* Header y Botón Nuevo */}
@@ -184,7 +191,10 @@ function RouteComponent() { diff --git a/src/types/plan.ts b/src/types/plan.ts index 2bf4e48..56406ae 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -31,12 +31,7 @@ export interface Carrera { facultadId: string } -export interface LineaCurricular { - id: string - nombre: string - orden: number - color?: string -} +export type LineaCurricular = Tables<'lineas_plan'> export interface Asignatura { id: string diff --git a/src/types/supabase.ts b/src/types/supabase.ts index e032c3a..40dd3c5 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -833,6 +833,7 @@ export type Database = { Row: { actualizado_en: string area: string | null + color: string | null creado_en: string id: string nombre: string @@ -842,6 +843,7 @@ export type Database = { Insert: { actualizado_en?: string area?: string | null + color?: string | null creado_en?: string id?: string nombre: string @@ -851,6 +853,7 @@ export type Database = { Update: { actualizado_en?: string area?: string | null + color?: string | null creado_en?: string id?: string nombre?: string diff --git a/src/utils/colors.ts b/src/utils/colors.ts new file mode 100644 index 0000000..0e5e059 --- /dev/null +++ b/src/utils/colors.ts @@ -0,0 +1,36 @@ +// Convierte HSL a Hexadecimal +function hslToHex(h: number, s: number, l: number): string { + l /= 100 + const a = (s * Math.min(l, 1 - l)) / 100 + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color) + .toString(16) + .padStart(2, '0') + } + return `#${f(0)}${f(8)}${f(4)}` +} + +/** + * Genera un color contrastante. + * @param prevHue El tono (0-360) del color anterior. Null si es el primero. + * @returns Objeto con el hex y el nuevo hue para guardar como referencia. + */ +export function generarColorContrastante(prevHue: number | null = null) { + let newHue: number + + if (prevHue === null) { + // Primer color: completamente al azar + newHue = Math.floor(Math.random() * 360) + } else { + // Siguientes: Salto aleatorio entre 130° y 230° para forzar contraste + const salto = 130 + Math.floor(Math.random() * 100) + newHue = (prevHue + salto) % 360 + } + + // Mantenemos saturación al 70% y luminosidad al 50% para colores vivos pero no fosforescentes + const hex = hslToHex(newHue, 70, 50) + + return { hex, hue: newHue } +}