From 8ae3469e1016e893365d453e9af72308ea23c436 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Thu, 26 Mar 2026 22:24:17 -0600 Subject: [PATCH] =?UTF-8?q?mejoras=20de=20dise=C3=B1o=20de=20mapa=20curric?= =?UTF-8?q?ular=20parte=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se cambió el layout para ser más claro de usar - El botón de agregar linea abre un dialog donde eliges como llamar tu nueva línea curricular - Al agregar lineas curriculares se les asigna un color al azar que persiste en la base de datos - al quitarle zoom a la pagina los margenes permanecen --- .../wizard/PasoFuenteClonadoInterno.tsx | 2 +- src/data/api/plans.api.ts | 4 +- src/data/api/subjects.api.ts | 8 +- src/routes/dashboard.tsx | 2 +- src/routes/planes/$planId/_detalle.tsx | 2 +- src/routes/planes/$planId/_detalle/mapa.tsx | 325 +++++++++++++----- .../asignaturas/$asignaturaId/route.tsx | 6 +- src/routes/planes/_lista.tsx | 4 +- src/utils/colors.ts | 36 ++ 9 files changed, 302 insertions(+), 87 deletions(-) create mode 100644 src/utils/colors.ts diff --git a/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx b/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx index 4a1ae4b..a4591a0 100644 --- a/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx +++ b/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx @@ -297,7 +297,7 @@ export function PasoFuenteClonadoInterno({ Selecciona una asignatura fuente (solo una). -
+
{subjectsLoading ? (
Cargando asignaturas… 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 9b4ab51..e745812 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 862ba12..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/mapa.tsx b/src/routes/planes/$planId/_detalle/mapa.tsx index cf6e3ad..e3622b7 100644 --- a/src/routes/planes/$planId/_detalle/mapa.tsx +++ b/src/routes/planes/$planId/_detalle/mapa.tsx @@ -2,10 +2,10 @@ import { createFileRoute } from '@tanstack/react-router' import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react' import * as Icons from 'lucide-react' -import { useMemo, useState, useEffect, Fragment } from 'react' +import { useMemo, useState, useEffect, Fragment, useRef } from 'react' import type { TipoAsignatura } from '@/data' -import type { Asignatura, LineaCurricular } from '@/types/plan' +import type { Asignatura } from '@/types/plan' import type { TablesUpdate } from '@/types/supabase' import { Badge } from '@/components/ui/badge' @@ -17,6 +17,8 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, @@ -24,6 +26,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' import { Tooltip, TooltipContent, @@ -39,6 +42,8 @@ import { useUpdateAsignatura, useUpdateLinea, } from '@/data' +import { cn } from '@/lib/utils' +import { generarColorContrastante } from '@/utils/colors' // --- Mapeadores (Fuera del componente para mayor limpieza) --- const palette = [ @@ -52,9 +57,16 @@ const palette = [ '#C026D3', // fucsia ] +type LineaCurricularUI = { + id: string + nombre: string + orden: number + color: string +} + const mapLineasToLineaCurricular = ( lineasApi: Array = [], -): Array => { +): Array => { return lineasApi.map((linea, index) => ({ id: linea.id, nombre: linea.nombre, @@ -276,11 +288,6 @@ function AsignaturaCardItem({
- - {/* drag affordance */} -
- -
@@ -311,20 +318,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) useEffect(() => { if (data?.numero_ciclos) { @@ -332,7 +345,13 @@ function MapaCurricularPage() { } }, [data]) - const manejarAgregarLinea = (nombre: string) => { + useEffect(() => { + if (selectedLineaOption === 'custom' && inputRef.current) { + inputRef.current.focus() + } + }, [selectedLineaOption]) + + const manejarAgregarLinea = (nombre: string, color: string, hue: number) => { const nombreNormalizado = nombre.trim() if (!nombreNormalizado) return const nombreBusqueda = nombreNormalizado @@ -353,12 +372,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) => { @@ -366,15 +387,43 @@ function MapaCurricularPage() { id: nueva.id, nombre: nueva.nombre, orden: nueva.orden, - color: (nueva as any).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)), @@ -421,14 +470,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)) @@ -662,65 +703,37 @@ 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 */} -
- - - - -
-
+
+ +
+
+
@@ -936,7 +963,7 @@ function MapaCurricularPage() { ) })} -
+
{stats.cr} Cr
{stats.hd + stats.hi} Hrs
@@ -944,7 +971,7 @@ function MapaCurricularPage() {
{/* Asignaturas Sin Asignar */} -
+
@@ -1025,6 +1052,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. @@ -260,7 +260,7 @@ function AsignaturaLayout() {
-
+
diff --git a/src/routes/planes/_lista.tsx b/src/routes/planes/_lista.tsx index 28830a7..547c325 100644 --- a/src/routes/planes/_lista.tsx +++ b/src/routes/planes/_lista.tsx @@ -114,7 +114,7 @@ function RouteComponent() { return (
-
+
{/* Header y Botón Nuevo */}
@@ -193,7 +193,7 @@ function RouteComponent() { onClick={resetFilters} disabled={isClearDisabled} className={`ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors ${ - isClearDisabled ? 'opacity-50 cursor-not-allowed' : '' + isClearDisabled ? 'cursor-not-allowed opacity-50' : '' }`} > Limpiar 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 } +}