import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { supabase, useSupabaseAuth } from '@/auth/supabase' import * as Icons from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import gsap from 'gsap' import { ScrollTrigger } from 'gsap/ScrollTrigger' import { AcademicSections } from '@/components/planes/academic-sections' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs' gsap.registerPlugin(ScrollTrigger) type PlanFull = { id: string; nombre: string; nivel: string | null; objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null; duracion: string | null; total_creditos: number | null; competencias_genericas: string | null; competencias_especificas: string | null; sistema_evaluacion: string | null; indicadores_desempeno: string | null; pertinencia: string | null; prompt: string | null; estado: string | null; fecha_creacion: string | null; carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null } type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null } type LoaderData = { plan: PlanFull; asignaturasCount: number; asignaturasPreview: AsignaturaLite[] } /* ============== ROUTE ============== */ export const Route = createFileRoute('/_authenticated/plan/$planId')({ component: RouteComponent, pendingComponent: PageSkeleton, loader: async ({ params }): Promise => { const { data: plan, error } = await supabase .from('plan_estudios') .select(` id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos, competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno, pertinencia, prompt, estado, fecha_creacion, carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) ) `) .eq('id', params.planId) .single() if (error || !plan) throw error ?? new Error('Plan no encontrado') const { count } = await supabase .from('asignaturas') .select('*', { count: 'exact', head: true }) .eq('plan_id', params.planId) const { data: asignaturasPreview } = await supabase .from('asignaturas') .select('id, nombre, semestre, creditos') .eq('plan_id', params.planId) .order('semestre', { ascending: true }) .order('nombre', { ascending: true }) .limit(8) return { plan: plan as unknown as PlanFull, asignaturasCount: count ?? 0, asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[], } }, }) /* ============== COLOR / MESH HELPERS ============== */ function hexToRgb(hex?: string | null): [number, number, number] { if (!hex) return [37, 99, 235] const h = hex.replace('#', '') const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h const n = parseInt(v, 16) return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } function softAccentStyle(color?: string | null) { const [r, g, b] = hexToRgb(color) return { borderColor: `rgba(${r},${g},${b},.28)`, background: `linear-gradient(180deg, rgba(${r},${g},${b},.06), rgba(${r},${g},${b},.02))`, } as React.CSSProperties } function lighten([r, g, b]: [number, number, number], amt = 30) { return [r + amt, g + amt, b + amt].map(v => Math.max(0, Math.min(255, v))) as [number, number, number] } function toRGBA([r, g, b]: [number, number, number], a: number) { return `rgba(${r},${g},${b},${a})` } /* ============== GRADIENT MESH LAYER ============== */ function GradientMesh({ color }: { color?: string | null }) { const meshRef = useRef(null) const base = hexToRgb(color) const soft = lighten(base, 20) const pop = lighten(base, -20) useEffect(() => { if (!meshRef.current) return const blobs = meshRef.current.querySelectorAll('.blob') blobs.forEach((el, i) => { gsap.to(el, { x: gsap.utils.random(-30, 30), y: gsap.utils.random(-20, 20), rotate: gsap.utils.random(-6, 6), duration: gsap.utils.random(6, 10), ease: 'sine.inOut', yoyo: true, repeat: -1, delay: i * 0.2, }) }) return () => gsap.killTweensOf(blobs) }, [color]) return (
) } /* ============== PAGE ============== */ function RouteComponent() { const router = useRouter() const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData const auth = useSupabaseAuth() const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria' const showCarrera = auth.claims?.role === 'secretario_academico' const fac = plan.carreras?.facultades const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color]) const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2 // Refs para animaciones const headerRef = useRef(null) const statsRef = useRef(null) const fieldsRef = useRef(null) useEffect(() => { // Header intro if (headerRef.current) { const ctx = gsap.context(() => { const tl = gsap.timeline({ defaults: { ease: 'power3.out' } }) tl.from('.hdr-icon', { y: 12, opacity: 0, duration: .5 }) .from('.hdr-title', { y: 8, opacity: 0, duration: .4 }, '-=.25') .from('.hdr-chips > *', { y: 6, opacity: 0, stagger: .06, duration: .35 }, '-=.25') }, headerRef) return () => ctx.revert() } }, []) useEffect(() => { // Stats y campos con ScrollTrigger if (statsRef.current) { const ctx = gsap.context(() => { gsap.from('.academics', { y: 14, opacity: 0, stagger: .08, duration: .4, scrollTrigger: { trigger: statsRef.current, start: 'top 85%' } }) }, statsRef) return () => ctx.revert() } }, []) useEffect(() => { if (fieldsRef.current) { const ctx = gsap.context(() => { gsap.utils.toArray('.long-field').forEach((el, i) => { gsap.from(el, { y: 22, opacity: 0, duration: .45, delay: i * 0.03, scrollTrigger: { trigger: el, start: 'top 90%' } }) }) }, fieldsRef) return () => ctx.revert() } }, []) const facColor = plan.carreras?.facultades?.color ?? null return (
{/* Mesh global */} {/* Header con acciones y brillo */} {/* velo de color muy suave */}
{plan.nombre}
{showCarrera && plan.carreras?.nombre ? `Carrera: ${plan.carreras.nombre}` : null} {showFacultad && fac?.nombre ? `${showCarrera ? ' · ' : ''}Facultad: ${fac.nombre}` : null}
{plan.estado && ( {plan.estado} )}
{/* stats */}
Asignaturas ({asignaturasCount})
router.invalidate()} /> Ver todas
{asignaturasPreview.length === 0 && (
Sin asignaturas
)} {asignaturasPreview.map(a => ( {a.semestre ? `S${a.semestre} · ` : ''}{a.nombre} ))}
) } function hexToRgbA(hex?: string | null, a = .25) { if (!hex) return `rgba(37,99,235,${a})` const h = hex.replace("#", "") const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h const n = parseInt(v, 16) const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255 return `rgba(${r},${g},${b},${a})` } const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—" /* ===== UI bits ===== */ type StatProps = { label: string value?: React.ReactNode Icon?: React.ComponentType> accent?: string | null // color de facultad (hex) opcional className?: string title?: string } function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: StatProps) { const border = hexToRgbA(accent, .28) const chipBg = hexToRgbA(accent, .08) const glow = hexToRgbA(accent, .14) return (
{label}
{value}
{/* glow sutil en hover */}
) } /* ===== Editar ===== */ function EditPlanButton({ plan }: { plan: PlanFull }) { const [open, setOpen] = useState(false) const [form, setForm] = useState>({}) const [saving, setSaving] = useState(false) async function save() { setSaving(true) const { error } = await supabase.from('plan_estudios').update({ nombre: form.nombre ?? plan.nombre, nivel: form.nivel ?? plan.nivel, duracion: form.duracion ?? plan.duracion, total_creditos: form.total_creditos ?? plan.total_creditos, }).eq('id', plan.id) setSaving(false) if (!error) setOpen(false) } return ( <> Editar plan Actualiza datos básicos.
setForm({ ...form, nombre: e.target.value })} /> setForm({ ...form, nivel: e.target.value })} /> setForm({ ...form, duracion: e.target.value })} /> setForm({ ...form, total_creditos: Number(e.target.value) || null })} />
) } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
) } /* ===== Ajustar IA ===== */ function AdjustAIButton({ plan }: { plan: PlanFull }) { const [open, setOpen] = useState(false) const [prompt, setPrompt] = useState('') const [loading, setLoading] = useState(false) async function apply() { setLoading(true) await fetch('https://genesis-engine.apps.lci.ulsa.mx/ajustar/plan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, plan }), }).catch(() => { }) setLoading(false) setOpen(false) } return ( <> Ajustar con IA Describe cómo quieres modificar el plan actual.