Merge branch 'main' into issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura-
This commit is contained in:
@@ -657,13 +657,19 @@ function InfoCard({
|
|||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<div className="flex-1 truncate text-left">
|
<div className="flex-1 truncate text-left">
|
||||||
<SelectValue placeholder="Selecciona una materia" />
|
<SelectValue placeholder="Selecciona una materia">
|
||||||
|
{Array.isArray(tempText) && tempText.length > 0
|
||||||
|
? `${tempText[0].code} - ${tempText[0].name}`
|
||||||
|
: undefined}
|
||||||
|
</SelectValue>
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">
|
<SelectItem value="none">
|
||||||
Ninguna (Sin seriación)
|
Ninguna (Sin seriación)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
{availableSubjects?.map((asig) => (
|
{availableSubjects?.map((asig) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={asig.id}
|
key={asig.id}
|
||||||
@@ -673,7 +679,7 @@ function InfoCard({
|
|||||||
<span className="text-primary font-bold">
|
<span className="text-primary font-bold">
|
||||||
[C{asig.numero_ciclo}]
|
[C{asig.numero_ciclo}]
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span className="block truncate">
|
<span className="inline-block truncate">
|
||||||
{asig.codigo} - {asig.nombre}
|
{asig.codigo} - {asig.nombre}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -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…
|
||||||
|
|||||||
@@ -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,18 +33,14 @@ 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
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-grow flex-col">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
{/* Ícono con el color de la facultad */}
|
{/* Círculo del ícono con el color de la facultad */}
|
||||||
<div
|
<div
|
||||||
className="mb-2 w-fit rounded-md p-2"
|
className="mb-2 w-fit rounded-full p-2.5"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
||||||
}}
|
}}
|
||||||
@@ -67,14 +60,12 @@ export default function PlanEstudiosCard({
|
|||||||
</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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
|
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -17,6 +17,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,
|
||||||
@@ -24,6 +26,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,
|
||||||
@@ -40,6 +43,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 = [
|
||||||
@@ -53,14 +58,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],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +198,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',
|
||||||
@@ -195,26 +207,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">
|
||||||
@@ -249,7 +245,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,
|
||||||
@@ -262,47 +258,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" />
|
|
||||||
<span className="text-[10px] font-medium tracking-wide uppercase">
|
|
||||||
HD
|
HD
|
||||||
</span>
|
</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" />
|
|
||||||
<span className="text-[10px] font-medium tracking-wide uppercase">
|
|
||||||
HI
|
HI
|
||||||
</span>
|
</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>
|
||||||
@@ -333,20 +319,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
|
||||||
@@ -399,6 +391,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))
|
||||||
@@ -741,70 +779,42 @@ function MapaCurricularPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-primary border-primary/30 hover:bg-primary/8"
|
className={cn(
|
||||||
onClick={() => manejarAgregarLinea('Área Común')}
|
'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
|
||||||
+ Área Común
|
'bg-green-100 text-green-900 hover:bg-green-200/80',
|
||||||
</Button>
|
// 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>
|
|
||||||
|
|
||||||
<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
|
<Download
|
||||||
|
size={16}
|
||||||
|
className="text-green-700 dark:text-green-400"
|
||||||
|
/>{' '}
|
||||||
|
Exportar a Excel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Barra Totales */}
|
{/* 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">
|
<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 Créditos" value={stats.cr} total={320} />
|
||||||
<StatItem label="Total HD" value={stats.hd} />
|
<StatItem label="Total HD" value={stats.hd} />
|
||||||
<StatItem label="Total HI" value={stats.hi} />
|
<StatItem label="Total HI" value={stats.hi} />
|
||||||
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
|
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
@@ -830,7 +840,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:
|
||||||
@@ -877,7 +887,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"
|
||||||
@@ -949,6 +992,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">
|
||||||
@@ -982,7 +1039,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>
|
||||||
@@ -990,7 +1047,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">
|
||||||
@@ -1071,6 +1128,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"
|
||||||
|
|||||||
@@ -192,7 +192,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 }}
|
||||||
@@ -278,13 +278,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' },
|
||||||
@@ -322,7 +322,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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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