mejoras de diseño de mapa curricular parte 3
- 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
This commit is contained in:
@@ -297,7 +297,7 @@ export function PasoFuenteClonadoInterno({
|
||||
Selecciona una asignatura fuente (solo una).
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-80 gap-2 overflow-y-auto">
|
||||
<div className="grid max-h-80 gap-2 overflow-y-auto px-1">
|
||||
{subjectsLoading ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Cargando asignaturas…
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*/}
|
||||
<div className="mx-auto flex w-full max-w-400 flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||
<div className="mx-auto flex w-full flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||
<DashboardHeader
|
||||
nombre="Dr. Carlos Mendoza"
|
||||
rol="Jefe de Carrera"
|
||||
|
||||
@@ -127,7 +127,7 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-400 space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||
<div className="mx-auto space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||
{/* 2. Header del Plan */}
|
||||
{isLoading ? (
|
||||
/* ===== SKELETON ===== */
|
||||
|
||||
@@ -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<any> = [],
|
||||
): Array<LineaCurricular> => {
|
||||
): Array<LineaCurricularUI> => {
|
||||
return lineasApi.map((linea, index) => ({
|
||||
id: linea.id,
|
||||
nombre: linea.nombre,
|
||||
@@ -276,11 +288,6 @@ function AsignaturaCardItem({
|
||||
</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>
|
||||
@@ -311,20 +318,26 @@ function MapaCurricularPage() {
|
||||
const { data } = usePlan(planId)
|
||||
const [totalCiclos, setTotalCiclos] = useState(0)
|
||||
const [editingLineaId, setEditingLineaId] = useState<string | null>(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<Array<Asignatura>>([])
|
||||
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
|
||||
const [lineas, setLineas] = useState<Array<LineaCurricularUI>>([])
|
||||
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
|
||||
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<number | null>(null)
|
||||
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
||||
const inputRef = useRef<HTMLInputElement>(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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-2 lg:justify-end">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Download size={16} /> Exportar
|
||||
</Button>
|
||||
{!tieneAreaComun && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary border-primary/30 hover:bg-primary/8"
|
||||
onClick={() => manejarAgregarLinea('Área Común')}
|
||||
>
|
||||
+ Área Común
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'inline-flex h-11 w-full items-center justify-start gap-2 rounded-md px-8 text-sm font-medium shadow-sm transition-colors',
|
||||
// Fondo verde claro y texto oscuro para contraste
|
||||
'bg-green-100 text-green-900 hover:bg-green-200/80',
|
||||
// Borde verde más oscuro y definido
|
||||
'border border-green-600/30',
|
||||
// Enfoque y estados (Accesibilidad)
|
||||
'ring-offset-background focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
// Soporte para modo oscuro (opcional pero recomendado)
|
||||
'dark:border-green-500/40 dark:bg-green-900/30 dark:text-green-100 dark:hover:bg-green-900/50',
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<Download
|
||||
size={16}
|
||||
className="text-green-700 dark:text-green-400"
|
||||
/>{' '}
|
||||
Exportar a Excel
|
||||
</Button>
|
||||
|
||||
<div className="border-border/70 bg-background/70 rounded-xl border p-3 lg:col-span-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<label className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
|
||||
Nueva Línea Curricular
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Crea una línea personalizada sin abrir menús adicionales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 sm:w-auto sm:min-w-90">
|
||||
<Input
|
||||
value={nombreNuevaLinea}
|
||||
onChange={(e) => setNombreNuevaLinea(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && nombreNuevaLinea.trim()) {
|
||||
manejarAgregarLinea(nombreNuevaLinea)
|
||||
}
|
||||
}}
|
||||
placeholder="Ej: Optativas"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
className="h-9 gap-1.5"
|
||||
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
|
||||
disabled={!nombreNuevaLinea.trim()}
|
||||
>
|
||||
<Plus size={14} /> Agregar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Barra Totales */}
|
||||
<div className="border-border bg-card/60 col-span-2 grid grid-cols-2 gap-3 rounded-2xl border p-3 shadow-sm md:grid-cols-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>
|
||||
</div>
|
||||
|
||||
{/* Barra Totales */}
|
||||
<div className="border-border bg-card/60 mb-8 grid grid-cols-2 gap-3 rounded-2xl border p-3 shadow-sm md:grid-cols-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-3 pl-1"
|
||||
@@ -903,6 +916,20 @@ function MapaCurricularPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Agregar línea (sticky dentro del overflow-x)
|
||||
Nota: Se envuelve en un row `col-span-full` para evitar bugs de sticky en mobile/iOS
|
||||
cuando el sticky es un grid-item. */}
|
||||
<div className="col-span-full">
|
||||
<div className="sticky left-0 z-10 w-35">
|
||||
<Button
|
||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 w-full items-center justify-start gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||
onClick={() => setIsAddLineaDialogOpen(true)}
|
||||
>
|
||||
<Plus size={14} /> Agregar línea
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border col-span-full my-2 border-t"></div>
|
||||
|
||||
<div className="text-foreground self-center p-2 font-bold">
|
||||
@@ -936,7 +963,7 @@ function MapaCurricularPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="text-primary-foreground border-primary/40 bg-primary flex flex-col justify-center rounded-xl border p-2 text-center text-xs font-bold shadow-sm">
|
||||
<div className="text-accent-foreground border-accent/40 bg-accent flex flex-col justify-center rounded-xl border p-2 text-center text-xs font-bold shadow-sm">
|
||||
<div>{stats.cr} Cr</div>
|
||||
<div>{stats.hd + stats.hi} Hrs</div>
|
||||
</div>
|
||||
@@ -944,7 +971,7 @@ function MapaCurricularPage() {
|
||||
</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="border-border bg-card/80 mt-6 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">
|
||||
@@ -1025,6 +1052,150 @@ function MapaCurricularPage() {
|
||||
</div>
|
||||
|
||||
{/* Modal de Edición */}
|
||||
<Dialog
|
||||
open={isAddLineaDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddLineaDialogOpen(open)
|
||||
if (!open) {
|
||||
setSelectedLineaOption(undefined)
|
||||
setCustomLineaNombre('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground text-xl font-bold">
|
||||
Agregar línea curricular
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<RadioGroup
|
||||
value={selectedLineaOption}
|
||||
onValueChange={(val) =>
|
||||
setSelectedLineaOption(val as typeof selectedLineaOption)
|
||||
}
|
||||
className="grid grid-cols-[1fr_auto_1fr] gap-8 py-4"
|
||||
>
|
||||
{/* Columna Izquierda: Predefinidas */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-foreground mb-3 text-sm font-semibold">
|
||||
Catálogo Institucional
|
||||
</div>
|
||||
|
||||
{/* Tarjeta: Matemáticas */}
|
||||
<div className="border-input has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5 hover:bg-muted/50 relative flex w-full items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none">
|
||||
<RadioGroupItem
|
||||
id="linea-matematicas"
|
||||
value="matematicas"
|
||||
className="mt-0.5 size-5 after:absolute after:inset-0 [&_svg]:size-3"
|
||||
/>
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
htmlFor="linea-matematicas"
|
||||
className="cursor-pointer font-semibold"
|
||||
>
|
||||
Matemáticas
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Línea base para ciencias exactas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tarjeta: Área Común */}
|
||||
<div className="border-input has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5 hover:bg-muted/50 relative flex w-full items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none">
|
||||
<RadioGroupItem
|
||||
id="linea-area-comun"
|
||||
value="area_comun"
|
||||
className="mt-0.5 size-5 after:absolute after:inset-0 [&_svg]:size-3"
|
||||
/>
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
htmlFor="linea-area-comun"
|
||||
className="cursor-pointer font-semibold"
|
||||
>
|
||||
Área Común
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Materias compartidas entre programas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separador */}
|
||||
<div className="flex justify-center">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
{/* Columna Derecha: Personalizada */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-foreground mb-3 text-sm font-semibold">
|
||||
Línea personalizada
|
||||
</div>
|
||||
|
||||
{/* Tarjeta: Custom */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
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 */}
|
||||
<RadioGroupItem
|
||||
id="linea-custom"
|
||||
value="custom"
|
||||
className="mt-0.5 size-5 [&_svg]:size-3"
|
||||
/>
|
||||
<div className="grid w-full grow gap-3">
|
||||
<Label
|
||||
htmlFor="linea-custom"
|
||||
className="cursor-pointer font-semibold"
|
||||
>
|
||||
Otra línea...
|
||||
</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={customLineaNombre}
|
||||
onChange={(e) =>
|
||||
setCustomLineaNombre(e.target.value.slice(0, 200))
|
||||
}
|
||||
placeholder="Escribe el nombre aquí"
|
||||
maxLength={200}
|
||||
disabled={selectedLineaOption !== 'custom'}
|
||||
className="bg-background h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-3 border-t pt-4">
|
||||
<Button
|
||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||
onClick={handleAgregarLinea}
|
||||
disabled={!canAddLinea || isCreatingLinea}
|
||||
>
|
||||
<Plus size={16} /> Agregar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-137.5"
|
||||
|
||||
@@ -140,7 +140,7 @@ function AsignaturaLayout() {
|
||||
return (
|
||||
<div>
|
||||
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="mx-auto max-w-400 p-4 py-10 md:px-6 lg:px-8">
|
||||
<div className="mx-auto p-4 py-10 md:px-6 lg:px-8">
|
||||
<Link
|
||||
to="/planes/$planId/asignaturas"
|
||||
params={{ planId }}
|
||||
@@ -216,7 +216,7 @@ function AsignaturaLayout() {
|
||||
{/* TABS */}
|
||||
|
||||
<nav className="sticky top-0 z-20 border-b bg-white">
|
||||
<div className="mx-auto max-w-400 p-4 py-2 md:px-6 lg:px-8">
|
||||
<div className="mx-auto p-4 py-2 md:px-6 lg:px-8">
|
||||
{/* 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() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="mx-auto max-w-400 p-4 py-8 md:px-6 lg:px-8">
|
||||
<div className="mx-auto p-4 py-8 md:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ function RouteComponent() {
|
||||
|
||||
return (
|
||||
<main className="bg-background min-h-screen w-full">
|
||||
<div className="mx-auto flex w-full max-w-400 flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 lg:col-span-3">
|
||||
{/* Header y Botón Nuevo */}
|
||||
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
||||
@@ -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' : ''
|
||||
}`}
|
||||
>
|
||||
<Icons.X className="h-4 w-4" /> Limpiar
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user