Merge pull request 'Primera parte de arreglos en el diseño' (#229) from issue/225-mejoras-en-el-diseo into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 2m2s

Reviewed-on: #229
This commit was merged in pull request #229.
This commit is contained in:
2026-03-27 17:29:18 +00:00
22 changed files with 435 additions and 191 deletions
@@ -232,7 +232,7 @@ function DatosGenerales({
if (isLoading) return <p>Cargando información...</p> if (isLoading) return <p>Cargando información...</p>
return ( return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500"> <div className="animate-in fade-in space-y-8 pb-8 duration-500">
{/* Encabezado de la Sección */} {/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div> <div>
@@ -125,7 +125,7 @@ export function BibliographyItem() {
return <div className="p-10 text-center">Cargando bibliografía...</div> return <div className="p-10 text-center">Cargando bibliografía...</div>
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500"> <div className="animate-in fade-in space-y-8 pb-8 duration-500">
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900"> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
@@ -662,7 +662,7 @@ export function ContenidoTematico() {
} }
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500"> <div className="animate-in fade-in space-y-6 pb-8 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900"> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
@@ -163,7 +163,7 @@ export function PasoFuenteClonadoInterno({
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Card> <Card className="gap-4">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Fuente</CardTitle> <CardTitle className="text-base">Fuente</CardTitle>
</CardHeader> </CardHeader>
@@ -297,7 +297,7 @@ export function PasoFuenteClonadoInterno({
Selecciona una asignatura fuente (solo una). Selecciona una asignatura fuente (solo una).
</div> </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 ? ( {subjectsLoading ? (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
Cargando asignaturas Cargando asignaturas
+28 -37
View File
@@ -4,20 +4,17 @@ import type { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' 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 { interface PlanEstudiosCardProps {
/** El componente del ícono importado de lucide-react (ej. BookOpen) */
Icono: LucideIcon Icono: LucideIcon
nombrePrograma: string nombrePrograma: string
nivel: string nivel: string
ciclos: string | number // Acepta "8" o "8 semestres" ciclos: string | number
facultad: string facultad: string
estado: string estado: string
/** Código hex o variable CSS (ej. "#ef4444" o "var(--primary)") */
claseColorEstado?: string claseColorEstado?: string
colorFacultad: string colorFacultad: string
/** Opcional: para manejar el click en la tarjeta */
onClick?: () => void onClick?: () => void
} }
@@ -36,45 +33,39 @@ export default function PlanEstudiosCard({
<Card <Card
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'group relative flex h-full cursor-pointer flex-col justify-between gap-2 overflow-hidden border-l-4 transition-all hover:shadow-lg', 'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
)} )}
// Aplicamos el color de la facultad dinámicamente al borde y un fondo muy sutil
style={{
borderLeftColor: colorFacultad,
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 95%)`, // Truco CSS moderno para fondo tintado
}}
> >
<CardHeader className="pb-2"> <div className="flex flex-grow flex-col">
{/* Ícono con el color de la facultad */} <CardHeader className="pb-2">
<div {/* Círculo del ícono con el color de la facultad */}
className="mb-2 w-fit rounded-md p-2" <div
style={{ className="mb-2 w-fit rounded-full p-2.5"
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`, style={{
}} backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
> }}
<Icono size={24} style={{ color: colorFacultad }} /> >
</div> <Icono size={24} style={{ color: colorFacultad }} />
</div>
{/* Título del Programa */} {/* Título del Programa */}
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight"> <h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
{nombrePrograma} {nombrePrograma}
</h4> </h4>
</CardHeader> </CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm"> <CardContent className="text-muted-foreground space-y-1 text-sm">
<p className="text-foreground font-medium"> <p className="text-foreground font-medium">
{nivel} {ciclos} {nivel} {ciclos}
</p> </p>
<p>{facultad}</p> <p>{facultad}</p>
</CardContent> </CardContent>
</div>
<CardFooter className="bg-background/50 flex items-center justify-between border-t px-6 pb-3 backdrop-blur-sm [.border-t]:pt-3"> <CardFooter className="flex items-center justify-between pt-0 pb-6">
<Badge className={`text-sm font-semibold ${claseColorEstado}`}> <Badge className={cn('text-sm font-semibold', claseColorEstado)}>
{estado} {estado}
</Badge> </Badge>
{/* <span className="text-foreground/80 text-sm font-semibold">
{estado}
</span> */}
{/* Flecha animada */} {/* Flecha animada */}
<div <div
+3 -3
View File
@@ -25,7 +25,7 @@ export function WizardLayout({
}} }}
> >
<div className="z-10 flex-none border-b bg-white"> <div className="z-10 flex-none border-b bg-white">
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4"> <CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-3">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<button <button
onClick={onClose} onClick={onClose}
@@ -36,10 +36,10 @@ export function WizardLayout({
</button> </button>
</CardHeader> </CardHeader>
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null} {headerSlot ? <div className="px-6 pb-3">{headerSlot}</div> : null}
</div> </div>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6"> <div className="flex-1 overflow-y-auto bg-gray-50/30 px-4 py-3 xl:px-6">
{children} {children}
</div> </div>
+3 -1
View File
@@ -221,7 +221,9 @@ export async function plan_lineas_list(
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('lineas_plan') .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) .eq('plan_estudio_id', planId)
.order('orden', { ascending: true }) .order('orden', { ascending: true })
+7 -1
View File
@@ -499,6 +499,7 @@ export async function lineas_insert(linea: {
plan_estudio_id: string plan_estudio_id: string
orden: number orden: number
area?: string area?: string
color?: string | null
}) { }) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
@@ -514,7 +515,12 @@ export async function lineas_insert(linea: {
// Actualizar una línea existente // Actualizar una línea existente
export async function lineas_update( export async function lineas_update(
lineaId: string, lineaId: string,
patch: { nombre?: string; orden?: number; area?: string }, patch: {
nombre?: string
orden?: number
area?: string
color?: string | null
},
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
+1 -1
View File
@@ -30,7 +30,7 @@ function RouteComponent() {
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop). 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). 5. py-6: Padding vertical (opcional, para separarse del header).
*/} */}
<div className="mx-auto flex w-full max-w-7xl 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 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
<DashboardHeader <DashboardHeader
nombre="Dr. Carlos Mendoza" nombre="Dr. Carlos Mendoza"
rol="Jefe de Carrera" rol="Jefe de Carrera"
+1 -1
View File
@@ -127,7 +127,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
<div className="mx-auto max-w-400 space-y-8 p-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 */} {/* 2. Header del Plan */}
{isLoading ? ( {isLoading ? (
/* ===== SKELETON ===== */ /* ===== SKELETON ===== */
@@ -129,7 +129,7 @@ function AsignaturasPage() {
} }
return ( return (
<div className="container mx-auto space-y-6 px-6 py-6"> <div className="w-full space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
@@ -102,7 +102,7 @@ function RouteComponent() {
} }
} }
return ( return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6"> <div className="flex min-h-screen flex-col gap-6 bg-slate-50/30">
{/* HEADER DE ACCIONES */} {/* HEADER DE ACCIONES */}
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div> <div>
+1 -1
View File
@@ -18,7 +18,7 @@ function RouteComponent() {
console.log(rawData) console.log(rawData)
return ( return (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6">
{/* Header Informativo (Opcional, si no viene del layout padre) */} {/* Header Informativo (Opcional, si no viene del layout padre) */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -125,7 +125,7 @@ function RouteComponent() {
) )
return ( return (
<div className="mx-auto max-w-5xl p-6"> <div className="mx-auto">
<div className="mb-8 flex items-end justify-between"> <div className="mb-8 flex items-end justify-between">
<div> <div>
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800"> <h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
@@ -581,7 +581,7 @@ function RouteComponent() {
} }
return ( return (
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 p-4 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden"> <div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */} {/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden"> <div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden">
<Button <Button
@@ -1119,11 +1119,11 @@ function RouteComponent() {
</div> </div>
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */} {/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto pr-2 md:flex"> <div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800"> <h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2 p-1">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
+1 -1
View File
@@ -229,7 +229,7 @@ function DatosGeneralesPage() {
}) })
} }
return ( return (
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500"> <div className="animate-in fade-in duration-500">
<div className="mb-6"> <div className="mb-6">
<h2 className="text-foreground text-lg font-semibold"> <h2 className="text-foreground text-lg font-semibold">
Datos Generales del Plan Datos Generales del Plan
+323 -122
View File
@@ -2,10 +2,10 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react' import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react'
import * as Icons 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 { TipoAsignatura } from '@/data'
import type { Asignatura, LineaCurricular } from '@/types/plan' import type { Asignatura } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase' import type { TablesUpdate } from '@/types/supabase'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto' import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
@@ -18,6 +18,8 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -25,6 +27,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -41,6 +44,8 @@ import {
useUpdateAsignatura, useUpdateAsignatura,
useUpdateLinea, useUpdateLinea,
} from '@/data' } from '@/data'
import { cn } from '@/lib/utils'
import { generarColorContrastante } from '@/utils/colors'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const palette = [ const palette = [
@@ -54,14 +59,21 @@ const palette = [
'#C026D3', // fucsia '#C026D3', // fucsia
] ]
type LineaCurricularUI = {
id: string
nombre: string
orden: number
color: string
}
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
lineasApi: Array<any> = [], lineasApi: Array<any> = [],
): Array<LineaCurricular> => { ): Array<LineaCurricularUI> => {
return lineasApi.map((linea, index) => ({ return lineasApi.map((linea, index) => ({
id: linea.id, id: linea.id,
nombre: linea.nombre, nombre: linea.nombre,
orden: linea.orden ?? 0, orden: linea.orden ?? 0,
color: palette[index % palette.length], color: linea.color ?? palette[index % palette.length],
})) }))
} }
@@ -187,7 +199,7 @@ function AsignaturaCardItem({
onDragStart={(e) => onDragStart(e, asignatura.id)} onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick} onClick={onClick}
className={[ className={[
'group relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left', 'group bg-background relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left',
'transition-all duration-300 ease-out', 'transition-all duration-300 ease-out',
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none', 'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
'cursor-grab active:cursor-grabbing', 'cursor-grab active:cursor-grabbing',
@@ -196,26 +208,10 @@ function AsignaturaCardItem({
: 'hover:-translate-y-1 hover:shadow-lg', : 'hover:-translate-y-1 hover:shadow-lg',
].join(' ')} ].join(' ')}
style={{ style={{
borderColor: hexToRgba(lineaColor, 0.18), borderColor: lineaColor,
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} 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"> <div className="relative flex h-full flex-col p-4">
{/* top */} {/* top */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -250,7 +246,7 @@ function AsignaturaCardItem({
{/* titulo */} {/* titulo */}
<div className="mt-4 min-h-18"> <div className="mt-4 min-h-18">
<h3 <h3
className="text-foreground text-md overflow-hidden leading-[1.08]" className="text-foreground overflow-hidden pb-1 text-sm leading-[1.08]"
style={{ style={{
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 3, WebkitLineClamp: 3,
@@ -263,47 +259,37 @@ function AsignaturaCardItem({
{/* bottom */} {/* bottom */}
<div className="mt-auto grid grid-cols-3 gap-2"> <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="bg-muted/70 border-border/70 flex flex-col items-center rounded-2xl border px-2.5 py-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5"> {/* <Icons.Award className="h-3.5 w-3.5" /> */}
<Icons.Award className="h-3.5 w-3.5" /> <span className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="text-[10px] font-medium tracking-wide uppercase"> CR
CR </span>
</span>
</div>
<div className="text-foreground text-sm font-bold"> <div className="text-foreground text-sm font-bold">
{asignatura.creditos} {asignatura.creditos}
</div> </div>
</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="bg-muted/70 border-border/70 flex flex-col items-center rounded-2xl border px-2.5 py-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5"> <span className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wide uppercase">
<Icons.Clock3 className="h-3.5 w-3.5" /> HD
<span className="text-[10px] font-medium tracking-wide uppercase"> </span>
HD
</span>
</div>
<div className="text-foreground text-sm font-bold"> <div className="text-foreground text-sm font-bold">
{asignatura.hd} {asignatura.hd}
</div> </div>
</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="bg-muted/70 border-border/70 flex flex-col items-center rounded-2xl border px-2.5 py-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5"> <span className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wide uppercase">
<Icons.BookOpenText className="h-3.5 w-3.5" /> HI
<span className="text-[10px] font-medium tracking-wide uppercase"> </span>
HI
</span>
</div>
<div className="text-foreground text-sm font-bold"> <div className="text-foreground text-sm font-bold">
{asignatura.hi} {asignatura.hi}
</div> </div>
</div> </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> </div>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -334,20 +320,26 @@ function MapaCurricularPage() {
const { data } = usePlan(planId) const { data } = usePlan(planId)
const [totalCiclos, setTotalCiclos] = useState(0) const [totalCiclos, setTotalCiclos] = useState(0)
const [editingLineaId, setEditingLineaId] = useState<string | null>(null) const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
const { mutate: createLinea } = useCreateLinea() const { mutate: createLinea, isPending: isCreatingLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea() const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea() const { mutate: deleteLineaApi } = useDeleteLinea()
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([]) const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
const [lineas, setLineas] = useState<Array<LineaCurricular>>([]) const [lineas, setLineas] = useState<Array<LineaCurricularUI>>([])
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>( const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
null, null,
) )
const [isEditModalOpen, setIsEditModalOpen] = useState(false) 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 { mutate: updateAsignatura } = useUpdateAsignatura()
const inputRef = useRef<HTMLInputElement>(null)
const { validarCambioCiclo } = useAsignaturaConflictos() const { validarCambioCiclo } = useAsignaturaConflictos()
const [confirmState, setConfirmState] = useState<{ const [confirmState, setConfirmState] = useState<{
isOpen: boolean isOpen: boolean
@@ -400,6 +392,12 @@ function MapaCurricularPage() {
} }
}, [data]) }, [data])
useEffect(() => {
if (selectedLineaOption === 'custom' && inputRef.current) {
inputRef.current.focus()
}
}, [selectedLineaOption])
const handleCambioCicloSeguro = async ( const handleCambioCicloSeguro = async (
asignatura: Asignatura, asignatura: Asignatura,
nuevoCiclo: number, nuevoCiclo: number,
@@ -433,7 +431,7 @@ function MapaCurricularPage() {
) )
} }
const manejarAgregarLinea = (nombre: string) => { const manejarAgregarLinea = (nombre: string, color: string, hue: number) => {
const nombreNormalizado = nombre.trim() const nombreNormalizado = nombre.trim()
if (!nombreNormalizado) return if (!nombreNormalizado) return
const nombreBusqueda = nombreNormalizado const nombreBusqueda = nombreNormalizado
@@ -454,12 +452,14 @@ function MapaCurricularPage() {
return return
} }
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0) const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
createLinea( createLinea(
{ {
nombre: nombreNormalizado, nombre: nombreNormalizado,
plan_estudio_id: planId, plan_estudio_id: planId,
orden: maxOrden + 1, orden: maxOrden + 1,
area: 'sin asignar', area: 'sin asignar',
color,
}, },
{ {
onSuccess: (nueva) => { onSuccess: (nueva) => {
@@ -467,10 +467,56 @@ function MapaCurricularPage() {
id: nueva.id, id: nueva.id,
nombre: nueva.nombre, nombre: nueva.nombre,
orden: nueva.orden, orden: nueva.orden,
color: '#1976d2', color: nueva.color ?? color,
} }
setLineas((prev) => [...prev, mapeada]) 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(() => { useEffect(() => {
if (asignaturaApi) if (asignaturaApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi)) setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
@@ -756,70 +794,42 @@ function MapaCurricularPage() {
)} )}
</div> </div>
<div className="flex items-center justify-start gap-2 lg:justify-end"> <Button
<Button variant="outline" className="gap-2"> variant="outline"
<Download size={16} /> Exportar className={cn(
</Button> 'inline-flex h-11 w-full items-center justify-start gap-2 rounded-md px-8 text-sm font-medium shadow-sm transition-colors',
{!tieneAreaComun && ( // Fondo verde claro y texto oscuro para contraste
<Button 'bg-green-100 text-green-900 hover:bg-green-200/80',
variant="outline" // Borde verde más oscuro y definido
className="text-primary border-primary/30 hover:bg-primary/8" 'border border-green-600/30',
onClick={() => manejarAgregarLinea('Área Común')} // 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',
+ Área Común // Soporte para modo oscuro (opcional pero recomendado)
</Button> '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"> {/* Barra Totales */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <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">
<div className="space-y-1"> <StatItem label="Total Créditos" value={stats.cr} total={320} />
<label className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"> <StatItem label="Total HD" value={stats.hd} />
Nueva Línea Curricular <StatItem label="Total HI" value={stats.hi} />
</label> <StatItem label="Total Horas" value={stats.hd + stats.hi} />
<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>
</div> </div>
</div> </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="overflow-x-auto pb-6">
<div <div
className="grid gap-3" className="grid gap-3 pl-1"
style={{ style={{
gridTemplateColumns: `140px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`, gridTemplateColumns: `140px repeat(${ciclosTotales}, 188px) 120px`,
}} }}
> >
<div className="text-muted-foreground self-end px-2 text-xs font-bold"> <div className="text-muted-foreground self-end px-2 text-xs font-bold">
@@ -845,7 +855,7 @@ function MapaCurricularPage() {
return ( return (
<Fragment key={linea.id}> <Fragment key={linea.id}>
<div <div
className={`group relative flex items-start justify-between gap-2 rounded-xl border p-3 transition-all ${editingLineaId === linea.id ? 'ring-primary/30 ring-2' : 'cursor-text'}`} className={`group relative flex flex-col gap-2 rounded-xl border p-3 transition-all ${editingLineaId === linea.id ? 'ring-primary/30 ring-2' : 'cursor-text'}`}
style={{ style={{
borderColor: hexToRgba(linea.color || '#1976d2', 0.24), borderColor: hexToRgba(linea.color || '#1976d2', 0.24),
backgroundColor: backgroundColor:
@@ -892,7 +902,40 @@ function MapaCurricularPage() {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="ml-1 flex shrink-0 items-center gap-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div
className="border-border/70 bg-background relative inline-flex h-8 w-8 items-center justify-center rounded-md border"
style={{
borderColor: hexToRgba(
linea.color || '#1976d2',
0.35,
),
}}
>
<input
type="color"
aria-label="Cambiar color de línea"
value={linea.color || '#1976d2'}
onChange={(e) =>
cambiarColorLinea(linea.id, e.target.value)
}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
<Icons.Palette
className="text-muted-foreground h-4 w-4"
aria-hidden
/>
</div>
<div
className="border-border/70 h-5 w-5 rounded-full border"
style={{ backgroundColor: linea.color || '#1976d2' }}
aria-hidden
/>
</div>
<button <button
type="button" type="button"
aria-label="Eliminar línea" aria-label="Eliminar línea"
@@ -964,6 +1007,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="border-border col-span-full my-2 border-t"></div>
<div className="text-foreground self-center p-2 font-bold"> <div className="text-foreground self-center p-2 font-bold">
@@ -997,7 +1054,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.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div> <div>{stats.hd + stats.hi} Hrs</div>
</div> </div>
@@ -1005,7 +1062,7 @@ function MapaCurricularPage() {
</div> </div>
{/* Asignaturas Sin Asignar */} {/* 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="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1086,6 +1143,150 @@ function MapaCurricularPage() {
</div> </div>
{/* Modal de Edición */} {/* 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}> <Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent <DialogContent
className="sm:max-w-137.5" className="sm:max-w-137.5"
@@ -140,7 +140,7 @@ function AsignaturaLayout() {
return ( return (
<div> <div>
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> <section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10"> <div className="mx-auto p-4 py-10 md:px-6 lg:px-8">
<Link <Link
to="/planes/$planId/asignaturas" to="/planes/$planId/asignaturas"
params={{ planId }} params={{ planId }}
@@ -216,13 +216,13 @@ function AsignaturaLayout() {
{/* TABS */} {/* TABS */}
<nav className="sticky top-0 z-20 border-b bg-white"> <nav className="sticky top-0 z-20 border-b bg-white">
<div className="mx-auto max-w-7xl px-6"> <div className="mx-auto p-4 py-2 md:px-6 lg:px-8">
{/* CAMBIOS CLAVE: {/* CAMBIOS CLAVE:
1. overflow-x-auto: Permite scroll horizontal. 1. overflow-x-auto: Permite scroll horizontal.
2. scrollbar-hide: (Opcional) para que no se vea la barra fea. 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. 3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
*/} */}
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-center"> <div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
{[ {[
{ label: 'Datos', to: '' }, { label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' }, { label: 'Contenido', to: 'contenido' },
@@ -260,7 +260,7 @@ function AsignaturaLayout() {
</div> </div>
</nav> </nav>
<div className="mx-auto max-w-7xl px-6 py-8"> <div className="mx-auto p-4 py-8 md:px-6 lg:px-8">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+12 -2
View File
@@ -101,13 +101,20 @@ function RouteComponent() {
setPage(0) // Resetear página al buscar 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 // Renderizado condicional básico
if (isError) if (isError)
return <div className="p-8 text-red-500">Error cargando planes.</div> return <div className="p-8 text-red-500">Error cargando planes.</div>
return ( return (
<main className="bg-background min-h-screen w-full"> <main className="bg-background min-h-screen w-full">
<div className="mx-auto flex w-full max-w-7xl 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"> <div className="flex flex-col gap-4 lg:col-span-3">
{/* Header y Botón Nuevo */} {/* Header y Botón Nuevo */}
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
@@ -184,7 +191,10 @@ function RouteComponent() {
<button <button
type="button" type="button"
onClick={resetFilters} onClick={resetFilters}
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" 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 ? 'cursor-not-allowed opacity-50' : ''
}`}
> >
<Icons.X className="h-4 w-4" /> Limpiar <Icons.X className="h-4 w-4" /> Limpiar
</button> </button>
+1 -6
View File
@@ -31,12 +31,7 @@ export interface Carrera {
facultadId: string facultadId: string
} }
export interface LineaCurricular { export type LineaCurricular = Tables<'lineas_plan'>
id: string
nombre: string
orden: number
color?: string
}
export interface Asignatura { export interface Asignatura {
id: string id: string
+3
View File
@@ -833,6 +833,7 @@ export type Database = {
Row: { Row: {
actualizado_en: string actualizado_en: string
area: string | null area: string | null
color: string | null
creado_en: string creado_en: string
id: string id: string
nombre: string nombre: string
@@ -842,6 +843,7 @@ export type Database = {
Insert: { Insert: {
actualizado_en?: string actualizado_en?: string
area?: string | null area?: string | null
color?: string | null
creado_en?: string creado_en?: string
id?: string id?: string
nombre: string nombre: string
@@ -851,6 +853,7 @@ export type Database = {
Update: { Update: {
actualizado_en?: string actualizado_en?: string
area?: string | null area?: string | null
color?: string | null
creado_en?: string creado_en?: string
id?: string id?: string
nombre?: string nombre?: string
+36
View File
@@ -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 }
}