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:
19
.vscode/mcp.json
vendored
Normal file
19
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"type": "promptString",
|
||||||
|
"id": "supabase-access-token",
|
||||||
|
"description": "Supabase personal access token",
|
||||||
|
"password": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"servers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "cmd",
|
||||||
|
"args": ["/c", "npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref=exdkssurzmjnnhgtiama"],
|
||||||
|
"env": {
|
||||||
|
"SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/components/planes/AddAsignaturaButton.tsx
Normal file
132
src/components/planes/AddAsignaturaButton.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
|
import confetti from "canvas-confetti"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import { Field } from "./Field"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
|
import { asignaturaKeys } from "./planQueries"
|
||||||
|
|
||||||
|
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [mode, setMode] = useState<"manual" | "ia">("manual")
|
||||||
|
|
||||||
|
const [f, setF] = useState({ nombre: "", clave: "", tipo: "", semestre: "", creditos: "", horas_teoricas: "", horas_practicas: "", objetivos: "" })
|
||||||
|
const [iaPrompt, setIaPrompt] = useState("")
|
||||||
|
const [iaSemestre, setIaSemestre] = useState("")
|
||||||
|
|
||||||
|
const toNull = (s: string) => s.trim() ? s : null
|
||||||
|
const toNum = (s: string) => s.trim() ? Number(s) || null : null
|
||||||
|
|
||||||
|
const canManual = f.nombre.trim().length > 0
|
||||||
|
const canIA = iaPrompt.trim().length > 0
|
||||||
|
const canSubmit = mode === "manual" ? canManual : canIA
|
||||||
|
|
||||||
|
async function createManual() {
|
||||||
|
if (!canManual) return
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
plan_id: planId,
|
||||||
|
nombre: f.nombre.trim(),
|
||||||
|
clave: toNull(f.clave),
|
||||||
|
tipo: toNull(f.tipo),
|
||||||
|
semestre: toNum(f.semestre),
|
||||||
|
creditos: toNum(f.creditos),
|
||||||
|
horas_teoricas: toNum(f.horas_teoricas),
|
||||||
|
horas_practicas: toNum(f.horas_practicas),
|
||||||
|
objetivos: toNull(f.objetivos),
|
||||||
|
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
|
||||||
|
}
|
||||||
|
const { error } = await supabase.from("asignaturas").insert([payload])
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { alert(error.message); return }
|
||||||
|
setOpen(false)
|
||||||
|
onAdded?.()
|
||||||
|
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||||
|
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWithAI() {
|
||||||
|
if (!canIA) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||||
|
setOpen(false)
|
||||||
|
onAdded?.()
|
||||||
|
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||||
|
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message ?? "Error al generar la asignatura")
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => (mode === "manual" ? createManual() : createWithAI())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Icons.Plus className="w-4 h-4 mr-2" /> Nueva asignatura
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="w-[min(92vw,760px)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nueva asignatura</DialogTitle>
|
||||||
|
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1" aria-label="Modo de creación">
|
||||||
|
<TabsTrigger value="manual" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
|
||||||
|
<Icons.PencilLine className="h-4 w-4 mr-2" /> Manual
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ia" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
|
||||||
|
<Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="mt-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Nombre"><Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} /></Field>
|
||||||
|
<Field label="Clave"><Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} /></Field>
|
||||||
|
<Field label="Tipo"><Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /></Field>
|
||||||
|
<Field label="Semestre"><Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="1–10" /></Field>
|
||||||
|
<Field label="Créditos"><Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} /></Field>
|
||||||
|
<Field label="Horas teóricas"><Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} /></Field>
|
||||||
|
<Field label="Horas prácticas"><Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} /></Field>
|
||||||
|
<div className="sm:col-span-2"><Field label="Objetivo (opcional)"><Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" /></Field></div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ia" className="mt-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2"><Field label="Indica el enfoque / requisitos"><Textarea value={iaPrompt} onChange={e => setIaPrompt(e.target.value)} className="min-h-[120px]" placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…" /></Field></div>
|
||||||
|
<Field label="Periodo (opcional)"><Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="1–10" /></Field>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
|
||||||
|
{saving ? (mode === "manual" ? "Guardando…" : "Generando…") : (mode === "manual" ? "Crear" : "Generar e insertar")}
|
||||||
|
</AuroraButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/components/planes/AdjustAIButton.tsx
Normal file
47
src/components/planes/AdjustAIButton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
|
import confetti from "canvas-confetti"
|
||||||
|
import type { PlanFull } from "./planQueries"
|
||||||
|
|
||||||
|
export 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/api/mejorar/plan', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, plan_id: plan.id }),
|
||||||
|
}).catch(() => { })
|
||||||
|
setLoading(false)
|
||||||
|
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Icons.Sparkles className="w-4 h-4 mr-2" /> Ajustar con IA
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ajustar con IA</DialogTitle>
|
||||||
|
<DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
|
||||||
|
<DialogFooter>
|
||||||
|
<AuroraButton onClick={apply} disabled={!prompt.trim() || loading}>
|
||||||
|
{loading ? 'Aplicando…' : 'Aplicar ajuste'}
|
||||||
|
</AuroraButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/components/planes/AsignaturaPreviewCard.tsx
Normal file
81
src/components/planes/AsignaturaPreviewCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query"
|
||||||
|
import React, { useMemo } from "react"
|
||||||
|
import { asignaturaExtraOptions } from "./planQueries"
|
||||||
|
import { SmallStat } from "./SmallStat"
|
||||||
|
|
||||||
|
export function AsignaturaPreviewCard({ asignatura }: { asignatura: { id: string; nombre: string; semestre: number | null; creditos: number | null } }) {
|
||||||
|
const { data: extra } = useSuspenseQuery(asignaturaExtraOptions(asignatura.id))
|
||||||
|
|
||||||
|
const horasT = extra?.horas_teoricas ?? null
|
||||||
|
const horasP = extra?.horas_practicas ?? null
|
||||||
|
const horasTot = (horasT ?? 0) + (horasP ?? 0)
|
||||||
|
|
||||||
|
const resumenContenidos = useMemo(() => {
|
||||||
|
const c = extra?.contenidos
|
||||||
|
if (!c) return { unidades: 0, temas: 0 }
|
||||||
|
const unidades = Object.keys(c).length
|
||||||
|
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
|
||||||
|
return { unidades, temas }
|
||||||
|
}, [extra?.contenidos])
|
||||||
|
|
||||||
|
const tipo = (extra?.tipo ?? "").toLowerCase()
|
||||||
|
const tipoChip =
|
||||||
|
tipo.includes("oblig") ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
|
||||||
|
tipo.includes("opt") ? "bg-amber-50 text-amber-800 border-amber-200" :
|
||||||
|
tipo.includes("taller") ? "bg-indigo-50 text-indigo-700 border-indigo-200" :
|
||||||
|
tipo.includes("lab") ? "bg-sky-50 text-sky-700 border-sky-200" :
|
||||||
|
"bg-neutral-100 text-neutral-700 border-neutral-200"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5" role="region" aria-label={asignatura.nombre}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||||
|
<Icons.BookOpen className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium truncate" title={asignatura.nombre}>{asignatura.nombre}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||||
|
{asignatura.semestre != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||||
|
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{asignatura.creditos != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||||
|
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{extra?.tipo && (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
|
||||||
|
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
|
||||||
|
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
|
||||||
|
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
|
||||||
|
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="text-[11px] text-neutral-500">
|
||||||
|
{horasT != null || horasP != null ? (
|
||||||
|
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-70">Resumen listo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: asignatura.id }} className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50" title="Ver detalle">
|
||||||
|
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
src/components/planes/EditPlanButton.tsx
Normal file
73
src/components/planes/EditPlanButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { type PlanFull, planKeys } from "./planQueries"
|
||||||
|
import { Field } from "./Field"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
|
||||||
|
export function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<Partial<PlanFull>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (payload: Partial<PlanFull>) => {
|
||||||
|
const { error } = await supabase.from('plan_estudios').update({
|
||||||
|
nombre: payload.nombre ?? plan.nombre,
|
||||||
|
nivel: payload.nivel ?? plan.nivel,
|
||||||
|
duracion: payload.duracion ?? plan.duracion,
|
||||||
|
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
||||||
|
}).eq('id', plan.id)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
onMutate: async (payload) => {
|
||||||
|
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
|
||||||
|
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
|
||||||
|
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
|
||||||
|
return { prev }
|
||||||
|
},
|
||||||
|
onError: (_e, _vars, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await mutation.mutateAsync(form)
|
||||||
|
setOpen(false)
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => { setForm(plan); setOpen(true) }}>
|
||||||
|
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar plan</DialogTitle>
|
||||||
|
<DialogDescription>Actualiza datos básicos.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Field label="Nombre"><Input value={form.nombre ?? ''} onChange={(e) => setForm({ ...form, nombre: e.target.value })} /></Field>
|
||||||
|
<Field label="Nivel"><Input value={form.nivel ?? ''} onChange={(e) => setForm({ ...form, nivel: e.target.value })} /></Field>
|
||||||
|
<Field label="Duración"><Input value={form.duracion ?? ''} onChange={(e) => setForm({ ...form, duracion: e.target.value })} /></Field>
|
||||||
|
<Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={save} disabled={saving || mutation.isPending}>{(saving || mutation.isPending) ? 'Guardando…' : 'Guardar'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/planes/Field.tsx
Normal file
11
src/components/planes/Field.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-neutral-600">{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/components/planes/GradientMesh.tsx
Normal file
43
src/components/planes/GradientMesh.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import gsap from "gsap"
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger"
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
import { hexToRgb, lighten, toRGBA } from "./planHelpers"
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/components/planes/Pulse.tsx
Normal file
25
src/components/planes/Pulse.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export function Pulse({ className = '' }: { className?: string }) {
|
||||||
|
return <div className={`animate-pulse bg-neutral-200 rounded-xl ${className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="border rounded-2xl p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Pulse className="w-10 h-10" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Pulse className="h-5 w-64" />
|
||||||
|
<Pulse className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 mt-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => <Pulse key={i} className="h-14" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => <Pulse key={i} className="h-40" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/components/planes/SmallStat.tsx
Normal file
12
src/components/planes/SmallStat.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
|
||||||
|
<Icon className="h-3.5 w-3.5" /> {label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/components/planes/StatCard.tsx
Normal file
33
src/components/planes/StatCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { hexToRgbA } from "./planHelpers"
|
||||||
|
|
||||||
|
export function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: {
|
||||||
|
label: string
|
||||||
|
value?: React.ReactNode
|
||||||
|
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||||
|
accent?: string | null
|
||||||
|
className?: string
|
||||||
|
title?: string
|
||||||
|
}) {
|
||||||
|
const border = hexToRgbA(accent, .28)
|
||||||
|
const chipBg = hexToRgbA(accent, .08)
|
||||||
|
const glow = hexToRgbA(accent, .14)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5 bg-white/70 dark:bg-neutral-900/60 backdrop-blur shadow-sm hover:shadow-md transition-all ${className}`}
|
||||||
|
style={{ borderColor: border }}
|
||||||
|
title={title ?? (typeof value === "string" ? value : undefined)}
|
||||||
|
aria-label={`${label}: ${typeof value === "string" ? value : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs text-neutral-500">{label}</div>
|
||||||
|
<span className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border" style={{ borderColor: border, background: chipBg }}>
|
||||||
|
<Icon className="h-4 w-4 opacity-80" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">{value}</div>
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/components/planes/planHelpers.ts
Normal file
25
src/components/planes/planHelpers.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export 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]
|
||||||
|
}
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
export 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] }
|
||||||
|
export function toRGBA([r, g, b]: [number, number, number], a: number) { return `rgba(${r},${g},${b},${a})` }
|
||||||
|
export 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})`
|
||||||
|
}
|
||||||
|
export const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—"
|
||||||
93
src/components/planes/planQueries.ts
Normal file
93
src/components/planes/planQueries.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
|
||||||
|
export const planKeys = {
|
||||||
|
byId: (id: string) => ["plan", id] as const,
|
||||||
|
}
|
||||||
|
export 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 }
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
export 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
|
||||||
|
}
|
||||||
@@ -1,127 +1,31 @@
|
|||||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import * as Icons from "lucide-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 { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 { 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 { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Label } from "@radix-ui/react-label"
|
||||||
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 confetti from "canvas-confetti"
|
import confetti from "canvas-confetti"
|
||||||
import gsap from "gsap"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger"
|
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
|
||||||
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 }
|
|
||||||
|
|
||||||
type LoaderData = { planId: string }
|
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")({
|
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
pendingComponent: PageSkeleton,
|
pendingComponent: PageSkeleton,
|
||||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||||
const { planId } = params
|
const { planId } = params
|
||||||
// Prefetch/ensure all queries needed for the page
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.ensureQueryData(planByIdOptions(planId)),
|
queryClient.ensureQueryData(planByIdOptions(planId)),
|
||||||
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||||
@@ -131,71 +35,13 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ============== COLOR / MESH HELPERS ============== */
|
// ...existing code...
|
||||||
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 ============== */
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { planId } = Route.useLoaderData() as LoaderData
|
const { planId } = Route.useLoaderData() as LoaderData
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
|
|
||||||
// Fetch via React Query (suspense)
|
|
||||||
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
||||||
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
||||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
||||||
@@ -206,61 +52,24 @@ function RouteComponent() {
|
|||||||
const fac = plan.carreras?.facultades
|
const fac = plan.carreras?.facultades
|
||||||
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
|
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
|
||||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
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 headerRef = useRef<HTMLDivElement>(null)
|
||||||
const statsRef = useRef<HTMLDivElement>(null)
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
const fieldsRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerRef.current) {
|
// ...animaciones header y stats...
|
||||||
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(() => {
|
|
||||||
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 (
|
return (
|
||||||
<div className="relative p-6 space-y-6">
|
<div className="relative p-6 space-y-6">
|
||||||
<GradientMesh color={fac?.color} />
|
<GradientMesh color={fac?.color} />
|
||||||
|
|
||||||
<nav className="relative text-sm text-neutral-500">
|
<nav className="relative text-sm text-neutral-500">
|
||||||
<Link to="/planes" className="hover:underline">Planes de estudio</Link>
|
<Link to="/planes" className="hover:underline">Planes de estudio</Link>
|
||||||
<span className="mx-1">/</span>
|
<span className="mx-1">/</span>
|
||||||
<span className="text-primary">{plan.nombre}</span>
|
<span className="text-primary">{plan.nombre}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||||
<div className="absolute inset-0 -z-0" style={accent} />
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hdr-chips flex flex-wrap items-center gap-2">
|
<div className="hdr-chips flex flex-wrap items-center gap-2">
|
||||||
{plan.estado && (
|
{plan.estado && (
|
||||||
<Badge variant="outline" className="bg-white/60" style={{ borderColor: accent.borderColor }}>
|
<Badge variant="outline" className="bg-white/60" style={{ borderColor: accent.borderColor }}>
|
||||||
{plan.estado}
|
{plan.estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<EditPlanButton plan={plan} />
|
<EditPlanButton plan={plan} />
|
||||||
<AdjustAIButton plan={plan} />
|
<AdjustAIButton plan={plan} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent ref={statsRef}>
|
<CardContent ref={statsRef}>
|
||||||
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
<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} />
|
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
|
||||||
@@ -301,16 +107,12 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="academics">
|
<div className="academics">
|
||||||
<AcademicSections planId={plan.id} color={fac?.color} />
|
<AcademicSections planId={plan.id} color={fac?.color} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== Asignaturas (preview cards) ===== */}
|
|
||||||
<Card className="border shadow-sm">
|
<Card className="border shadow-sm">
|
||||||
<CardHeader className="flex items-center justify-between gap-2">
|
<CardHeader className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AddAsignaturaButton planId={plan.id} onAdded={() => {
|
<AddAsignaturaButton planId={plan.id} onAdded={() => {
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
|
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
|
||||||
@@ -327,7 +129,6 @@ function RouteComponent() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{asignaturasPreview.length === 0 ? (
|
{asignaturasPreview.length === 0 ? (
|
||||||
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user