diff --git a/bun.lock b/bun.lock
index 43c480c..4787c3c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,7 +8,9 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
@@ -20,6 +22,8 @@
"@tanstack/router-plugin": "^1.121.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "gsap": "^3.13.0",
"lucide-react": "^0.540.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -224,6 +228,8 @@
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
+
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -236,6 +242,8 @@
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
+
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -254,10 +262,14 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.8.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A=="],
@@ -500,6 +512,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
+
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
@@ -590,6 +604,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+ "gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
+
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
diff --git a/package.json b/package.json
index d2dc55a..8ae51a8 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,9 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
@@ -26,6 +28,8 @@
"@tanstack/router-plugin": "^1.121.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "gsap": "^3.13.0",
"lucide-react": "^0.540.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
diff --git a/src/components/planes/academic-sections.tsx b/src/components/planes/academic-sections.tsx
new file mode 100644
index 0000000..8a0727e
--- /dev/null
+++ b/src/components/planes/academic-sections.tsx
@@ -0,0 +1,153 @@
+import * as Icons from "lucide-react"
+import { useMemo, useState } from "react"
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { supabase } from "@/auth/supabase"
+
+/* color 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]
+}
+const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
+
+/* texto con clamp */
+function ExpandableText({ text, mono = false }: { text?: string | null; mono?: boolean }) {
+ const [open, setOpen] = useState(false)
+ if (!text) return —
+ return (
+
+
{text}
+ {text.length > 220 && (
+
+ )}
+
+ )
+}
+
+/* panel con estilo */
+function SectionPanel({
+ title, icon: Icon, color, children,
+}: { title: string; icon: any; color?: string | null; children: React.ReactNode }) {
+ const rgb = hexToRgb(color)
+ return (
+
+
+
+
+
+
{title}
+
+
{children}
+
+ )
+}
+
+/* ---------- TABS + EDIT DIALOG ---------- */
+type PlanTextFields = {
+ objetivo_general?: string | null; sistema_evaluacion?: string | null;
+ perfil_ingreso?: string | null; perfil_egreso?: string | null;
+ competencias_genericas?: string | null; competencias_especificas?: string | null;
+ indicadores_desempeno?: string | null; pertinencia?: string | null; prompt?: string | null;
+}
+
+export function AcademicSections({
+ planId, plan, color,
+}: { planId: string; plan: PlanTextFields; color?: string | null }) {
+ // estado local editable
+ const [local, setLocal] = useState({ ...plan })
+ const [editing, setEditing] = useState(null)
+ const [draft, setDraft] = useState("")
+ const [saving, setSaving] = useState(false)
+
+ const sections = useMemo(() => [
+ { id: "obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
+ { id: "eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
+ { id: "ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
+ { id: "egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
+ { id: "cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
+ { id: "ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
+ { id: "ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
+ { id: "pert", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
+ { id: "prompt", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
+ ], [])
+
+ async function handleSave() {
+ if (!editing) return
+ setSaving(true)
+ const payload: any = { [editing.key]: draft }
+ const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
+ setSaving(false)
+ if (error) {
+ console.error(error)
+ alert("No se pudo guardar 😓")
+ return
+ }
+ setLocal(prev => ({ ...prev, [editing.key]: draft }))
+ setEditing(null)
+ }
+
+ return (
+ <>
+
+ {/* nav sticky con píldoras scrollables */}
+
+
+ {sections.map(s => (
+
+ {s.title}
+
+ ))}
+
+
+
+ {/* contenido */}
+ {sections.map(s => {
+ const text = local[s.key] ?? null
+ return (
+
+
+
+
+
+
+
+
+
+ )
+ })}
+
+
+ {/* Dialog de edición */}
+
+ >
+ )
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..8cb4ca7
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,184 @@
+"use client"
+
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { SearchIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+}) {
+ return (
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..6d51b6c
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..51f466e
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,183 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/users/procedencia-combobox.tsx b/src/components/users/procedencia-combobox.tsx
new file mode 100644
index 0000000..2209231
--- /dev/null
+++ b/src/components/users/procedencia-combobox.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useMemo, useState } from "react"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { Command, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty } from "@/components/ui/command"
+import { Button } from "@/components/ui/button"
+import { Check, ChevronsUpDown, Building2, GraduationCap } from "lucide-react"
+import { supabase } from "@/auth/supabase"
+
+/* Util simple */
+const cls = (...a: (string|false|undefined)[]) => a.filter(Boolean).join(" ")
+
+/* --------- COMBOBOX BASE --------- */
+function ComboBase({
+ placeholder, value, onChange, options, icon:Icon,
+}: {
+ placeholder: string
+ value?: string | null
+ onChange: (id: string) => void
+ options: { id: string; label: string }[]
+ icon?: any
+}) {
+ const [open, setOpen] = useState(false)
+ const current = useMemo(() => options.find(o => o.id === value), [options, value])
+
+ return (
+
+
+
+
+
+
+
+
+ Sin resultados
+
+ {options.map(o => (
+ { onChange(o.id); setOpen(false) }}
+ className="whitespace-normal"
+ >
+
+ {o.label}
+
+ ))}
+
+
+
+
+
+ )
+}
+
+/* --------- COMBO FACULTADES --------- */
+export function FacultadCombobox({
+ value, onChange,
+}: { value?: string | null; onChange: (id: string) => void }) {
+ const [items, setItems] = useState<{ id: string; label: string }[]>([])
+ useEffect(() => {
+ supabase.from("facultades").select("id, nombre, color").order("nombre", { ascending: true })
+ .then(({ data }) => setItems((data ?? []).map(f => ({ id: f.id, label: f.nombre }))))
+ }, [])
+ return
+}
+
+/* --------- COMBO CARRERAS (filtrado por facultad) --------- */
+export function CarreraCombobox({
+ facultadId, value, onChange, disabled,
+}: { facultadId?: string | null; value?: string | null; onChange: (id: string) => void; disabled?: boolean }) {
+ const [items, setItems] = useState<{ id: string; label: string }[]>([])
+ useEffect(() => {
+ if (!facultadId) { setItems([]); return }
+ supabase.from("carreras")
+ .select("id, nombre").eq("facultad_id", facultadId).order("nombre", { ascending: true })
+ .then(({ data }) => setItems((data ?? []).map(c => ({ id: c.id, label: c.nombre }))))
+ }, [facultadId])
+ return (
+
+
+
+ )
+}
diff --git a/src/main.tsx b/src/main.tsx
index 74b9e69..b17f59a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -17,9 +17,11 @@ const router = createRouter({
defaultPreloadStaleTime: 0,
context:{
auth: undefined!,
- }
+ },
})
+
+
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 1b59351..f7a84b6 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -12,13 +12,14 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as AuthenticatedUsuariosRouteImport } from './routes/_authenticated/usuarios'
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
-import { Route as AuthenticatedPlanesPlanIdRouteImport } from './routes/_authenticated/planes/$planId'
+import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
-import { Route as AuthenticatedPlanesPlanIdModalRouteImport } from './routes/_authenticated/planes/$planId/modal'
+import { Route as AuthenticatedPlanPlanIdModalRouteImport } from './routes/_authenticated/plan/$planId/modal'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@@ -34,6 +35,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const AuthenticatedUsuariosRoute = AuthenticatedUsuariosRouteImport.update({
+ id: '/usuarios',
+ path: '/usuarios',
+ getParentRoute: () => AuthenticatedRoute,
+} as any)
const AuthenticatedPlanesRoute = AuthenticatedPlanesRouteImport.update({
id: '/planes',
path: '/planes',
@@ -55,23 +61,22 @@ const AuthenticatedAsignaturasRoute =
path: '/asignaturas',
getParentRoute: () => AuthenticatedRoute,
} as any)
-const AuthenticatedPlanesPlanIdRoute =
- AuthenticatedPlanesPlanIdRouteImport.update({
- id: '/$planId',
- path: '/$planId',
- getParentRoute: () => AuthenticatedPlanesRoute,
- } as any)
+const AuthenticatedPlanPlanIdRoute = AuthenticatedPlanPlanIdRouteImport.update({
+ id: '/plan/$planId',
+ path: '/plan/$planId',
+ getParentRoute: () => AuthenticatedRoute,
+} as any)
const AuthenticatedFacultadFacultadIdRoute =
AuthenticatedFacultadFacultadIdRouteImport.update({
id: '/facultad/$facultadId',
path: '/facultad/$facultadId',
getParentRoute: () => AuthenticatedRoute,
} as any)
-const AuthenticatedPlanesPlanIdModalRoute =
- AuthenticatedPlanesPlanIdModalRouteImport.update({
+const AuthenticatedPlanPlanIdModalRoute =
+ AuthenticatedPlanPlanIdModalRouteImport.update({
id: '/modal',
path: '/modal',
- getParentRoute: () => AuthenticatedPlanesPlanIdRoute,
+ getParentRoute: () => AuthenticatedPlanPlanIdRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -80,10 +85,11 @@ export interface FileRoutesByFullPath {
'/asignaturas': typeof AuthenticatedAsignaturasRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/facultades': typeof AuthenticatedFacultadesRoute
- '/planes': typeof AuthenticatedPlanesRouteWithChildren
+ '/planes': typeof AuthenticatedPlanesRoute
+ '/usuarios': typeof AuthenticatedUsuariosRoute
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
- '/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
- '/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
+ '/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren
+ '/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -91,10 +97,11 @@ export interface FileRoutesByTo {
'/asignaturas': typeof AuthenticatedAsignaturasRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/facultades': typeof AuthenticatedFacultadesRoute
- '/planes': typeof AuthenticatedPlanesRouteWithChildren
+ '/planes': typeof AuthenticatedPlanesRoute
+ '/usuarios': typeof AuthenticatedUsuariosRoute
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
- '/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
- '/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
+ '/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren
+ '/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -104,10 +111,11 @@ export interface FileRoutesById {
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
- '/_authenticated/planes': typeof AuthenticatedPlanesRouteWithChildren
+ '/_authenticated/planes': typeof AuthenticatedPlanesRoute
+ '/_authenticated/usuarios': typeof AuthenticatedUsuariosRoute
'/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
- '/_authenticated/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
- '/_authenticated/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
+ '/_authenticated/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren
+ '/_authenticated/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -118,9 +126,10 @@ export interface FileRouteTypes {
| '/dashboard'
| '/facultades'
| '/planes'
+ | '/usuarios'
| '/facultad/$facultadId'
- | '/planes/$planId'
- | '/planes/$planId/modal'
+ | '/plan/$planId'
+ | '/plan/$planId/modal'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -129,9 +138,10 @@ export interface FileRouteTypes {
| '/dashboard'
| '/facultades'
| '/planes'
+ | '/usuarios'
| '/facultad/$facultadId'
- | '/planes/$planId'
- | '/planes/$planId/modal'
+ | '/plan/$planId'
+ | '/plan/$planId/modal'
id:
| '__root__'
| '/'
@@ -141,9 +151,10 @@ export interface FileRouteTypes {
| '/_authenticated/dashboard'
| '/_authenticated/facultades'
| '/_authenticated/planes'
+ | '/_authenticated/usuarios'
| '/_authenticated/facultad/$facultadId'
- | '/_authenticated/planes/$planId'
- | '/_authenticated/planes/$planId/modal'
+ | '/_authenticated/plan/$planId'
+ | '/_authenticated/plan/$planId/modal'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -175,6 +186,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/_authenticated/usuarios': {
+ id: '/_authenticated/usuarios'
+ path: '/usuarios'
+ fullPath: '/usuarios'
+ preLoaderRoute: typeof AuthenticatedUsuariosRouteImport
+ parentRoute: typeof AuthenticatedRoute
+ }
'/_authenticated/planes': {
id: '/_authenticated/planes'
path: '/planes'
@@ -203,12 +221,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
parentRoute: typeof AuthenticatedRoute
}
- '/_authenticated/planes/$planId': {
- id: '/_authenticated/planes/$planId'
- path: '/$planId'
- fullPath: '/planes/$planId'
- preLoaderRoute: typeof AuthenticatedPlanesPlanIdRouteImport
- parentRoute: typeof AuthenticatedPlanesRoute
+ '/_authenticated/plan/$planId': {
+ id: '/_authenticated/plan/$planId'
+ path: '/plan/$planId'
+ fullPath: '/plan/$planId'
+ preLoaderRoute: typeof AuthenticatedPlanPlanIdRouteImport
+ parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/facultad/$facultadId': {
id: '/_authenticated/facultad/$facultadId'
@@ -217,55 +235,48 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedFacultadFacultadIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
- '/_authenticated/planes/$planId/modal': {
- id: '/_authenticated/planes/$planId/modal'
+ '/_authenticated/plan/$planId/modal': {
+ id: '/_authenticated/plan/$planId/modal'
path: '/modal'
- fullPath: '/planes/$planId/modal'
- preLoaderRoute: typeof AuthenticatedPlanesPlanIdModalRouteImport
- parentRoute: typeof AuthenticatedPlanesPlanIdRoute
+ fullPath: '/plan/$planId/modal'
+ preLoaderRoute: typeof AuthenticatedPlanPlanIdModalRouteImport
+ parentRoute: typeof AuthenticatedPlanPlanIdRoute
}
}
}
-interface AuthenticatedPlanesPlanIdRouteChildren {
- AuthenticatedPlanesPlanIdModalRoute: typeof AuthenticatedPlanesPlanIdModalRoute
+interface AuthenticatedPlanPlanIdRouteChildren {
+ AuthenticatedPlanPlanIdModalRoute: typeof AuthenticatedPlanPlanIdModalRoute
}
-const AuthenticatedPlanesPlanIdRouteChildren: AuthenticatedPlanesPlanIdRouteChildren =
+const AuthenticatedPlanPlanIdRouteChildren: AuthenticatedPlanPlanIdRouteChildren =
{
- AuthenticatedPlanesPlanIdModalRoute: AuthenticatedPlanesPlanIdModalRoute,
+ AuthenticatedPlanPlanIdModalRoute: AuthenticatedPlanPlanIdModalRoute,
}
-const AuthenticatedPlanesPlanIdRouteWithChildren =
- AuthenticatedPlanesPlanIdRoute._addFileChildren(
- AuthenticatedPlanesPlanIdRouteChildren,
+const AuthenticatedPlanPlanIdRouteWithChildren =
+ AuthenticatedPlanPlanIdRoute._addFileChildren(
+ AuthenticatedPlanPlanIdRouteChildren,
)
-interface AuthenticatedPlanesRouteChildren {
- AuthenticatedPlanesPlanIdRoute: typeof AuthenticatedPlanesPlanIdRouteWithChildren
-}
-
-const AuthenticatedPlanesRouteChildren: AuthenticatedPlanesRouteChildren = {
- AuthenticatedPlanesPlanIdRoute: AuthenticatedPlanesPlanIdRouteWithChildren,
-}
-
-const AuthenticatedPlanesRouteWithChildren =
- AuthenticatedPlanesRoute._addFileChildren(AuthenticatedPlanesRouteChildren)
-
interface AuthenticatedRouteChildren {
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRoute
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
- AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRouteWithChildren
+ AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute
+ AuthenticatedUsuariosRoute: typeof AuthenticatedUsuariosRoute
AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute
+ AuthenticatedPlanPlanIdRoute: typeof AuthenticatedPlanPlanIdRouteWithChildren
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
- AuthenticatedPlanesRoute: AuthenticatedPlanesRouteWithChildren,
+ AuthenticatedPlanesRoute: AuthenticatedPlanesRoute,
+ AuthenticatedUsuariosRoute: AuthenticatedUsuariosRoute,
AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute,
+ AuthenticatedPlanPlanIdRoute: AuthenticatedPlanPlanIdRouteWithChildren,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx
index e29c5cf..2af6eeb 100644
--- a/src/routes/_authenticated.tsx
+++ b/src/routes/_authenticated.tsx
@@ -25,6 +25,7 @@ import {
LogOut,
KeySquare,
IdCard,
+ Users2Icon,
} from "lucide-react"
import { useSupabaseAuth } from "@/auth/supabase"
@@ -41,6 +42,7 @@ const nav = [
{ to: "/planes", label: "Planes", icon: GraduationCap },
{ to: "/asignaturas", label: "Asignaturas", icon: FileText },
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
+ { to: "/usuarios", label: "Usuarios", icon: Users2Icon },
] as const
function getInitials(name?: string) {
diff --git a/src/routes/_authenticated/facultad/$facultadId.tsx b/src/routes/_authenticated/facultad/$facultadId.tsx
index 156d900..b79dcd7 100644
--- a/src/routes/_authenticated/facultad/$facultadId.tsx
+++ b/src/routes/_authenticated/facultad/$facultadId.tsx
@@ -203,7 +203,7 @@ function RouteComponent() {
{recientes.length === 0 && Sin actividad}
{recientes.map((r) => (
-
+
{r.tipo === 'plan' ? : }
{r.nombre ?? '—'}
diff --git a/src/routes/_authenticated/plan/$planId.tsx b/src/routes/_authenticated/plan/$planId.tsx
new file mode 100644
index 0000000..19b9eb6
--- /dev/null
+++ b/src/routes/_authenticated/plan/$planId.tsx
@@ -0,0 +1,359 @@
+import { createFileRoute, Link, useParams } 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'
+
+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 LoaderData = { plan: PlanFull; asignaturasCount: number }
+
+/* ============== 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)
+
+ return { plan: plan as unknown as PlanFull, asignaturasCount: count ?? 0 }
+ },
+})
+
+/* ============== 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 { plan, asignaturasCount } = 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('.kv', {
+ 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()
+ }
+ }, [])
+
+ 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}
+
+ )}
+
+
Ver asignaturas
+
+
+
+
+
+ {/* stats */}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/* ===== UI bits ===== */
+function KV({ label, value, className = '' }: { label: string; value?: string | number | null; className?: string }) {
+ return (
+
+
{label}
+
{value ?? '—'}
+
+ )
+}
+
+/* ===== 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 (
+ <>
+
+
+ >
+ )
+}
+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 (
+ <>
+
+
+ >
+ )
+}
+
+/* ===== Skeleton ===== */
+function Pulse({ className = '' }: { className?: string }) {
+ return
+}
+function PageSkeleton() {
+ return (
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) =>
)}
+
+
+
+ {Array.from({ length: 6 }).map((_, i) =>
)}
+
+
+ )
+}
diff --git a/src/routes/_authenticated/planes/$planId/modal.tsx b/src/routes/_authenticated/plan/$planId/modal.tsx
similarity index 88%
rename from src/routes/_authenticated/planes/$planId/modal.tsx
rename to src/routes/_authenticated/plan/$planId/modal.tsx
index 27cf7b1..486b3e4 100644
--- a/src/routes/_authenticated/planes/$planId/modal.tsx
+++ b/src/routes/_authenticated/plan/$planId/modal.tsx
@@ -1,9 +1,11 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
+
type PlanDetail = {
id: string
nombre: string
@@ -18,19 +20,16 @@ type PlanDetail = {
} | null
}
-export const Route = createFileRoute("/_authenticated/planes/$planId/modal")({
+export const Route = createFileRoute('/_authenticated/plan/$planId/modal')({
component: RouteComponent,
loader: async ({ params }) => {
const { data, error } = await supabase
- .from("plan_estudios")
+ .from('plan_estudios')
.select(`
id, nombre, nivel, duracion, total_creditos, estado,
- carreras (
- id, nombre,
- facultades:facultades ( id, nombre, color, icon )
- )
+ carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
- .eq("id", params.planId)
+ .eq('id', params.planId)
.single()
if (error) throw error
return data
@@ -45,14 +44,22 @@ function gradientFrom(color?: string | null) {
function RouteComponent() {
const plan = Route.useLoaderData() as PlanDetail
const router = useRouter()
-
const fac = plan.carreras?.facultades
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
const headerBg = { background: gradientFrom(fac?.color) }
return (
-