feat: add AdjustAIButton, EditPlanButton, and AsignaturaPreviewCard components
- Implemented AdjustAIButton for AI-driven plan adjustments with a confetti effect on success. - Created EditPlanButton to allow editing of plan details with form validation and optimistic updates. - Added AsignaturaPreviewCard to display course previews with relevant statistics and details. - Introduced Field component for consistent form field labeling. - Developed GradientMesh for dynamic background effects based on color input. - Added Pulse component for skeleton loading states. - Created SmallStat and StatCard components for displaying statistical information in a card format. - Implemented utility functions in planHelpers for color manipulation and formatting. - Established planQueries for fetching plan and course data from the database. - Updated the plan detail route to utilize new components and queries for improved user experience.
This commit is contained in:
@@ -1,127 +1,31 @@
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import * as Icons from "lucide-react"
|
||||
import { useQueryClient, useSuspenseQuery, useMutation } from "@tanstack/react-query"
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
|
||||
import { GradientMesh } from "../../../components/planes/GradientMesh"
|
||||
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
|
||||
import { softAccentStyle } from "@/components/planes/planHelpers"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@radix-ui/react-dialog"
|
||||
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
|
||||
import { AcademicSections } from "@/components/planes/academic-sections"
|
||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import { Label } from "@radix-ui/react-label"
|
||||
import confetti from "canvas-confetti"
|
||||
import gsap from "gsap"
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger"
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
/* ================= Query Keys & Options ================= */
|
||||
const planKeys = {
|
||||
byId: (id: string) => ["plan", id] as const,
|
||||
}
|
||||
const asignaturaKeys = {
|
||||
count: (planId: string) => ["asignaturas", "count", planId] as const,
|
||||
preview: (planId: string) => ["asignaturas", "preview", planId] as const,
|
||||
extra: (asigId: string) => ["asignatura", "extra", asigId] as const,
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
export type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
|
||||
|
||||
type LoaderData = { planId: string }
|
||||
|
||||
/* ---------- Query option builders ---------- */
|
||||
function planByIdOptions(planId: string) {
|
||||
return {
|
||||
queryKey: planKeys.byId(planId),
|
||||
queryFn: async (): Promise<PlanFull> => {
|
||||
const { data, 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", planId)
|
||||
.maybeSingle()
|
||||
if (error || !data) throw error ?? new Error("Plan no encontrado")
|
||||
return data as unknown as PlanFull
|
||||
},
|
||||
staleTime: 60_000,
|
||||
} as const
|
||||
}
|
||||
function asignaturasCountOptions(planId: string) {
|
||||
return {
|
||||
queryKey: asignaturaKeys.count(planId),
|
||||
queryFn: async (): Promise<number> => {
|
||||
const { count, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("plan_id", planId)
|
||||
if (error) throw error
|
||||
return count ?? 0
|
||||
},
|
||||
staleTime: 30_000,
|
||||
} as const
|
||||
}
|
||||
function asignaturasPreviewOptions(planId: string) {
|
||||
return {
|
||||
queryKey: asignaturaKeys.preview(planId),
|
||||
queryFn: async (): Promise<AsignaturaLite[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("id, nombre, semestre, creditos")
|
||||
.eq("plan_id", planId)
|
||||
.order("semestre", { ascending: true })
|
||||
.order("nombre", { ascending: true })
|
||||
.limit(8)
|
||||
if (error) throw error
|
||||
return (data ?? []) as unknown as AsignaturaLite[]
|
||||
},
|
||||
staleTime: 30_000,
|
||||
} as const
|
||||
}
|
||||
function asignaturaExtraOptions(asigId: string) {
|
||||
return {
|
||||
queryKey: asignaturaKeys.extra(asigId),
|
||||
queryFn: async (): Promise<{
|
||||
tipo: string | null
|
||||
horas_teoricas: number | null
|
||||
horas_practicas: number | null
|
||||
contenidos: Record<string, Record<string, string>> | null
|
||||
} | null> => {
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("tipo, horas_teoricas, horas_practicas, contenidos")
|
||||
.eq("id", asigId)
|
||||
.maybeSingle()
|
||||
if (error) throw error
|
||||
return (data as any) ?? null
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
/* ============== ROUTE ============== */
|
||||
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||
component: RouteComponent,
|
||||
pendingComponent: PageSkeleton,
|
||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||
const { planId } = params
|
||||
// Prefetch/ensure all queries needed for the page
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(planByIdOptions(planId)),
|
||||
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||
@@ -131,71 +35,13 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||
},
|
||||
})
|
||||
|
||||
/* ============== 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<HTMLDivElement>(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 (
|
||||
<div ref={meshRef} className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="blob absolute -top-24 -left-24 w-[38rem] h-[38rem] rounded-full blur-3xl"
|
||||
style={{ background: `radial-gradient(circle, ${toRGBA(soft, .35)}, transparent 60%)` }} />
|
||||
<div className="blob absolute -bottom-28 -right-20 w-[34rem] h-[34rem] rounded-full blur-[60px]"
|
||||
style={{ background: `radial-gradient(circle, ${toRGBA(base, .28)}, transparent 60%)` }} />
|
||||
<div className="blob absolute top-1/3 left-1/2 -translate-x-1/2 w-[22rem] h-[22rem] rounded-full blur-[50px]"
|
||||
style={{ background: `radial-gradient(circle, ${toRGBA(pop, .22)}, transparent 60%)` }} />
|
||||
<div className="absolute inset-0 opacity-40"
|
||||
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============== PAGE ============== */
|
||||
// ...existing code...
|
||||
function RouteComponent() {
|
||||
const router = useRouter()
|
||||
const qc = useQueryClient()
|
||||
const { planId } = Route.useLoaderData() as LoaderData
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
// Fetch via React Query (suspense)
|
||||
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
||||
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
||||
@@ -206,61 +52,24 @@ function RouteComponent() {
|
||||
const fac = plan.carreras?.facultades
|
||||
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||
const facColor = plan.carreras?.facultades?.color ?? null
|
||||
|
||||
// Refs para animaciones
|
||||
// Animaciones y refs pueden modularizarse si se desea
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
const statsRef = useRef<HTMLDivElement>(null)
|
||||
const fieldsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
}
|
||||
// ...animaciones header y stats...
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
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<HTMLElement>('.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 (
|
||||
<div className="relative p-6 space-y-6">
|
||||
<GradientMesh color={fac?.color} />
|
||||
|
||||
<nav className="relative text-sm text-neutral-500">
|
||||
<Link to="/planes" className="hover:underline">Planes de estudio</Link>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-primary">{plan.nombre}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||
<div className="absolute inset-0 -z-0" style={accent} />
|
||||
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -277,21 +86,18 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hdr-chips flex flex-wrap items-center gap-2">
|
||||
{plan.estado && (
|
||||
<Badge variant="outline" className="bg-white/60" style={{ borderColor: accent.borderColor }}>
|
||||
{plan.estado}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<EditPlanButton plan={plan} />
|
||||
<AdjustAIButton plan={plan} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent ref={statsRef}>
|
||||
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
|
||||
@@ -301,16 +107,12 @@ function RouteComponent() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="academics">
|
||||
<AcademicSections planId={plan.id} color={fac?.color} />
|
||||
</div>
|
||||
|
||||
{/* ===== Asignaturas (preview cards) ===== */}
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AddAsignaturaButton planId={plan.id} onAdded={() => {
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
|
||||
@@ -327,7 +129,6 @@ function RouteComponent() {
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{asignaturasPreview.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
||||
|
||||
Reference in New Issue
Block a user