feat: implement dashboard with KPIs, recent activity, and health metrics

- Added dashboard route with loader fetching KPIs, recent plans and subjects, and health metrics.
- Created visual components for displaying KPIs and recent activity.
- Implemented gradient background and user greeting based on role.
- Added input for global search and quick links for creating new plans and subjects.

refactor: update facultad progress ring rendering

- Fixed rendering of progress ring in facultad detail view.

fix: remove unnecessary link to subjects in plan detail view

- Removed link to view subjects from the plan detail page for cleaner UI.

feat: add create plan dialog in planes route

- Introduced a dialog for creating new plans with form validation and role-based field visibility.
- Integrated Supabase for creating plans and handling user roles.

feat: enhance user management with create user dialog

- Added functionality to create new users with role and claims management.
- Implemented password generation and input handling for user creation.

fix: update login redirect to dashboard

- Changed default redirect after login from /planes to /dashboard for better user experience.
This commit is contained in:
2025-08-22 14:32:43 -06:00
parent 9727f4c691
commit ca3fed69b2
16 changed files with 2274 additions and 118 deletions

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/archivos')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_authenticated/archivos"!</div>
}

View File

@@ -0,0 +1,352 @@
// routes/_authenticated/asignatura/$asignaturaId.tsx
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from "@/components/ui/accordion"
/* ================== Tipos ================== */
type Asignatura = {
id: string; nombre: string; clave: string | null; tipo: string | null; semestre: number | null;
creditos: number | null; horas_teoricas: number | null; horas_practicas: number | null;
objetivos: string | null; contenidos: Record<string, Record<string, string>> | null;
bibliografia: string[] | null; criterios_evaluacion: string | null; plan_id: string | null;
}
type PlanMini = { id: string; nombre: string }
/* ================== Ruta ================== */
export const Route = createFileRoute("/_authenticated/asignatura/$asignaturaId")({
component: Page,
loader: async ({ params }) => {
const { data: a, error } = await supabase
.from("asignaturas")
.select("id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas, objetivos, contenidos, bibliografia, criterios_evaluacion, plan_id")
.eq("id", params.asignaturaId)
.single()
if (error || !a) throw error ?? new Error("Asignatura no encontrada")
let plan: PlanMini | null = null
if (a.plan_id) {
const { data: p } = await supabase
.from("plan_estudios").select("id, nombre").eq("id", a.plan_id).single()
plan = p as PlanMini | null
}
return { a: a as Asignatura, plan }
},
})
/* ================== Helpers UI ================== */
function typeStyle(tipo?: string | null) {
const t = (tipo ?? "").toLowerCase()
if (t.includes("oblig")) return { chip: "bg-emerald-50 text-emerald-700 border-emerald-200", halo: "from-emerald-100/60" }
if (t.includes("opt")) return { chip: "bg-amber-50 text-amber-800 border-amber-200", halo: "from-amber-100/60" }
if (t.includes("taller")) return { chip: "bg-indigo-50 text-indigo-700 border-indigo-200", halo: "from-indigo-100/60" }
if (t.includes("lab")) return { chip: "bg-sky-50 text-sky-700 border-sky-200", halo: "from-sky-100/60" }
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
}
function Stat({ icon: Icon, label, value }:{
icon: any; label: string; value: string | number
}) {
return (
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icon className="h-4 w-4" />
</div>
<div>
<div className="text-xs text-neutral-500">{label}</div>
<div className="text-lg font-semibold tabular-nums">{value}</div>
</div>
</div>
)
}
function Section({ id, title, icon: Icon, children }:{
id: string; title: string; icon: any; children: React.ReactNode
}) {
return (
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
<header className="flex items-center gap-2 mb-2">
<div className="h-8 w-8 rounded-lg grid place-items-center border bg-white/80"><Icon className="h-4 w-4" /></div>
<h3 className="text-sm font-semibold">{title}</h3>
</header>
{children}
</section>
)
}
/* ================== Página ================== */
function Page() {
const router = useRouter()
const { a, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const horas = horasT + horasP
const style = typeStyle(a.tipo)
// ordenar unidades de forma “natural”
const unidades = useMemo(() => {
const entries = Object.entries(a.contenidos ?? {})
const norm = (s: string) => {
const m = String(s).match(/^\s*(\d+)/)
return m ? [parseInt(m[1], 10), s] as const : [Number.POSITIVE_INFINITY, s] as const
}
return entries
.map(([k, v]) => ({ key: k, order: norm(k)[0], title: norm(k)[1], temas: Object.entries(v) }))
.sort((A, B) => (A.order === B.order ? A.title.localeCompare(B.title) : A.order - B.order))
.map(u => ({ ...u, temas: u.temas.sort(([a],[b]) => Number(a) - Number(b)) }))
}, [a.contenidos])
const temasCount = useMemo(() => unidades.reduce((acc, u) => acc + u.temas.length, 0), [unidades])
// buscar dentro del syllabus
const [query, setQuery] = useState("")
const filteredUnidades = useMemo(() => {
const t = query.trim().toLowerCase()
if (!t) return unidades
return unidades.map(u => ({
...u,
temas: u.temas.filter(([, tema]) => String(tema).toLowerCase().includes(t)),
})).filter(u => u.temas.length > 0)
}, [query, unidades])
// atajos y compartir
const searchRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); searchRef.current?.focus() }
if (e.key === "Escape") router.history.back()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [router])
async function share() {
const url = window.location.href
try {
if (navigator.share) await navigator.share({ title: a.nombre, url })
else {
await navigator.clipboard.writeText(url)
// feedback visual mínimo
alert("Enlace copiado")
}
} catch { /* noop */ }
}
return (
<div className="relative p-6 space-y-6">
{/* ===== Migas ===== */}
<nav className="text-sm text-neutral-500">
<Link
to={plan ? "/plan/$planId" : "/planes"}
params={plan ? { planId: plan.id } : undefined}
className="hover:underline"
>
{plan ? plan.nombre : "Planes"}
</Link>
<span className="mx-1">/</span>
<span className="text-neutral-900">{a.nombre}</span>
</nav>
{/* ===== Hero ===== */}
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
<div className={`absolute inset-0 bg-gradient-to-br ${style.halo} via-white to-transparent`} />
<div className="relative p-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
<Icons.BookOpen className="h-4 w-4" /> Asignatura
{plan && <>
<span>·</span>
<Link to="/plan/$planId" params={{ planId: plan.id }} className="hover:underline">
{plan.nombre}
</Link>
</>}
</div>
<h1 className="mt-1 text-2xl md:text-3xl font-bold truncate">{a.nombre}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
{a.clave && <Badge variant="outline">Clave: {a.clave}</Badge>}
{a.tipo && <Badge className={style.chip} variant="secondary">{a.tipo}</Badge>}
{a.creditos != null && <Badge variant="outline">{a.creditos} créditos</Badge>}
<Badge variant="outline">H T/P: {horasT}/{horasP}</Badge>
<Badge variant="outline">Semestre {a.semestre ?? "—"}</Badge>
</div>
</div>
{/* Acciones rápidas */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Icons.Printer className="h-4 w-4 mr-2" /> Imprimir
</Button>
<Button variant="outline" size="sm" onClick={share}>
<Icons.Share2 className="h-4 w-4 mr-2" /> Compartir
</Button>
</div>
</div>
{/* Stats rápidos */}
<div className="relative px-6 pb-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat icon={Icons.Coins} label="Créditos" value={a.creditos ?? "—"} />
<Stat icon={Icons.Clock} label="Horas totales" value={horas} />
<Stat icon={Icons.ListTree} label="Unidades" value={unidades.length} />
<Stat icon={Icons.BookMarked} label="Temas" value={temasCount} />
</div>
</div>
</div>
{/* ===== Layout principal ===== */}
<div className="grid gap-6 lg:grid-cols-[1fr,320px]">
{/* ===== Columna principal ===== */}
<div className="space-y-6">
{/* Objetivo */}
{a.objetivos && (
<Section id="objetivo" title="Objetivo de la asignatura" icon={Icons.Target}>
<p className="text-sm leading-relaxed text-neutral-800">{a.objetivos}</p>
</Section>
)}
{/* Syllabus */}
{unidades.length > 0 && (
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar tema dentro del programa (⌘/Ctrl K)…"
className="pl-8"
/>
</div>
{query && (
<Button variant="ghost" onClick={() => setQuery("")}>
Limpiar
</Button>
)}
</div>
<Accordion type="multiple" className="mt-2">
{filteredUnidades.map((u, i) => (
<AccordionItem key={u.key} value={`u-${i}`} className="border rounded-xl mb-2 overflow-hidden">
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
<div className="flex items-center justify-between w-full">
<span className="font-medium">
{/^\s*\d+/.test(u.key) ? `Unidad ${u.key}` : u.title}
</span>
<span className="text-[11px] text-neutral-500">{u.temas.length} tema(s)</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-5 pb-3">
<ul className="list-disc ml-5 text-[13px] text-neutral-700 space-y-1">
{u.temas.map(([k, t]) => <li key={k} className="break-words">{t}</li>)}
</ul>
</AccordionContent>
</AccordionItem>
))}
{filteredUnidades.length === 0 && (
<div className="text-sm text-neutral-500 py-6 text-center">No hay temas que coincidan.</div>
)}
</Accordion>
</Section>
)}
{/* Bibliografía */}
{a.bibliografia && a.bibliografia.length > 0 && (
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
<ul className="space-y-2 text-sm text-neutral-800">
{a.bibliografia.map((ref, i) => (
<li key={i} className="flex items-start gap-2 leading-relaxed">
<span className="mt-1 text-neutral-400"></span>
<span className="break-words">{ref}</span>
</li>
))}
</ul>
</Section>
)}
{/* Evaluación */}
{a.criterios_evaluacion && (
<Section id="evaluacion" title="Criterios de evaluación" icon={Icons.ClipboardCheck}>
<p className="text-sm text-neutral-800 leading-relaxed">{a.criterios_evaluacion}</p>
</Section>
)}
</div>
{/* ===== Sidebar ===== */}
<aside className="space-y-4 lg:sticky lg:top-6 self-start">
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4">
<h4 className="font-semibold text-sm mb-2">Resumen</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<MiniKV label="Créditos" value={a.creditos ?? "—"} />
<MiniKV label="Semestre" value={a.semestre ?? "—"} />
<MiniKV label="Horas teóricas" value={horasT} />
<MiniKV label="Horas prácticas" value={horasP} />
<MiniKV label="Unidades" value={unidades.length} />
<MiniKV label="Temas" value={temasCount} />
</div>
</div>
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4">
<h4 className="font-semibold text-sm mb-2">Navegación</h4>
<nav className="text-sm space-y-1">
{a.objetivos && <Anchor href="#objetivo" label="Objetivo" />}
{unidades.length > 0 && <Anchor href="#syllabus" label="Programa / Contenidos" />}
{a.bibliografia && a.bibliografia.length > 0 && <Anchor href="#bibliografia" label="Bibliografía" />}
{a.criterios_evaluacion && <Anchor href="#evaluacion" label="Evaluación" />}
</nav>
</div>
{plan && (
<Link
to="/plan/$planId"
params={{ planId: plan.id }}
className="block rounded-2xl border p-4 hover:bg-neutral-50 transition"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.ScrollText className="h-4 w-4" />
</div>
<div>
<div className="text-xs text-neutral-500">Plan de estudios</div>
<div className="font-medium truncate">{plan.nombre}</div>
</div>
</div>
</Link>
)}
</aside>
</div>
{/* ===== Volver ===== */}
<div className="pt-2">
<Button variant="outline" asChild>
<Link to={plan ? "/plan/$planId" : "/planes"} params={plan ? { planId: plan.id } : undefined}>
<Icons.ArrowLeft className="h-4 w-4 mr-2" /> Volver
</Link>
</Button>
</div>
</div>
)
}
/* ===== Bits Sidebar ===== */
function MiniKV({ label, value }:{ label: string; value: string | number }) {
return (
<div className="rounded-xl border bg-white/60 p-2">
<div className="text-[11px] text-neutral-500">{label}</div>
<div className="font-medium tabular-nums">{value}</div>
</div>
)
}
function Anchor({ href, label }:{ href: string; label: string }) {
return (
<a href={href} className="flex items-center gap-2 text-neutral-700 hover:underline">
<Icons.Dot className="h-5 w-5 -ml-1" /> {label}
</a>
)
}

View File

@@ -0,0 +1,447 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
/* ================== Tipos ================== */
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
type CarMini = { id: string; nombre: string; facultad: FacMini | null }
type PlanMini = { id: string; nombre: string; carrera: CarMini | null }
type Asignatura = {
id: string
nombre: string
clave: string | null
tipo: string | null
semestre: number | null
creditos: number | null
horas_teoricas: number | null
horas_practicas: number | null
objetivos: string | null
contenidos: Record<string, Record<string, string>> | null
bibliografia: string[] | null
criterios_evaluacion: string | null
fecha_creacion: string | null
plan: PlanMini | null
}
type LoaderData = {
asignaturas: Asignatura[]
}
/* ================== Ruta ================== */
export const Route = createFileRoute('/_authenticated/asignaturas')({
component: RouteComponent,
pendingComponent: PageSkeleton,
// Podemos filtrar por planId/carreraId/facultadId desde la URL si se envían
validateSearch: (search: Record<string, unknown>) => {
return {
q: (search.q as string) ?? '',
planId: (search.planId as string) ?? '',
carreraId: (search.carreraId as string) ?? '',
facultadId: (search.facultadId as string) ?? '',
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
}
},
loader: async (ctx): Promise<LoaderData> => {
// TanStack: el search vive en ctx.location.search
const search = (ctx.location?.search ?? {}) as {
q?: string
planId?: string
carreraId?: string
facultadId?: string
f?: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
}
const { planId, carreraId, facultadId } = search
// Resolver alcance por IDs opcionales (para filtrar antes de traer asignaturas)
let planIds: string[] | null = null
if (planId) {
planIds = [planId]
} else if (carreraId) {
const { data: planesCar, error } = await supabase
.from('plan_estudios')
.select('id')
.eq('carrera_id', carreraId)
if (error) throw error
planIds = (planesCar ?? []).map(p => p.id)
} else if (facultadId) {
const { data: carreras, error: carErr } = await supabase
.from('carreras')
.select('id')
.eq('facultad_id', facultadId)
if (carErr) throw carErr
const cIds = (carreras ?? []).map(c => c.id)
if (!cIds.length) {
// No hay carreras en la facultad ⇒ no hay asignaturas
return { asignaturas: [] }
}
const { data: planesFac, error: plaErr } = await supabase
.from('plan_estudios')
.select('id')
.in('carrera_id', cIds)
if (plaErr) throw plaErr
planIds = (planesFac ?? []).map(p => p.id)
}
// Si sabemos que no habrá resultados, evitamos pegarle a Supabase
if (planIds && planIds.length === 0) {
return { asignaturas: [] }
}
// Traer asignaturas + contexto de plan/carrera/facultad
let query = supabase
.from('asignaturas')
.select(`
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id,
plan:plan_estudios (
id, nombre,
carrera:carreras (
id, nombre,
facultad:facultades ( id, nombre, color, icon )
)
)
`)
.order('semestre', { ascending: true })
.order('nombre', { ascending: true })
if (planIds) {
query = query.in('plan_id', planIds)
}
const { data, error: aErr } = await query
if (aErr) throw aErr
return { asignaturas: (data ?? []) as unknown as Asignatura[] }
},
})
/* ================== Página ================== */
function RouteComponent() {
const { asignaturas } = Route.useLoaderData() as LoaderData
const router = useRouter()
const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' }
// Estado de filtros locales (arrancan con la URL)
const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos')
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
// Valores de selects
const semestres = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(String(a.semestre ?? '—')))
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud (contadores)
const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
for (const a of asignaturas) {
if (!a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)) sinBibliografia++
if (!a.criterios_evaluacion || !a.criterios_evaluacion.trim()) sinCriterios++
if (!a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)) sinContenidos++
}
return { sinBibliografia, sinCriterios, sinContenidos }
}, [asignaturas])
// Filtrado
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK
})
}, [q, sem, tipo, flag, asignaturas])
// Agrupación
const groups = useMemo(() => {
if (groupBy === 'ninguno') return [['Todas', filtered] as [string, Asignatura[]]]
const m = new Map<number | string, Asignatura[]>()
for (const a of filtered) {
const k = a.semestre ?? '—'
if (!m.has(k)) m.set(k, [])
m.get(k)!.push(a)
}
return Array.from(m.entries()).sort(([a], [b]) => {
if (a === '—') return 1
if (b === '—') return -1
return Number(a) - Number(b)
})
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
return (
<div className="p-6 space-y-6">
{/* HEADER */}
<div className="rounded-3xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm">
<div className="p-5 flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-bold flex items-center gap-2">
<Icons.BookOpen className="w-5 h-5" />
Asignaturas
</h1>
<div className="flex items-center gap-2">
{/* Crear nueva — puedes cambiar el destino si ya tienes ruta específica */}
<Link
to="/planes"
className="hidden sm:inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
search={{ crear: 'asignatura' }}
>
<Icons.Plus className="w-4 h-4" /> Nueva asignatura
</Link>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<Icons.RefreshCcw className="w-4 h-4" />
</Button>
</div>
</div>
{/* Filtros */}
<div className="grid gap-2 sm:grid-cols-[1fr,140px,180px,150px]">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre, clave, plan, carrera, facultad…"
className="w-full"
/>
<Select value={sem} onValueChange={setSem}>
<SelectTrigger><SelectValue placeholder="Semestre" /></SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
</SelectContent>
</Select>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger><SelectValue placeholder="Tipo" /></SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="todos">Todos</SelectItem>
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
<SelectTrigger><SelectValue placeholder="Agrupar por" /></SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Agrupar por semestre</SelectItem>
<SelectItem value="ninguno">Sin agrupación</SelectItem>
</SelectContent>
</Select>
</div>
{/* Chips de salud (toggle) */}
<div className="flex flex-wrap items-center gap-2">
<HealthChip
active={flag === 'sinBibliografia'}
onClick={() => setFlag(flag === 'sinBibliografia' ? '' : 'sinBibliografia')}
icon={<Icons.BookMarked className="w-3.5 h-3.5" />}
label="Sin bibliografía"
value={salud.sinBibliografia}
/>
<HealthChip
active={flag === 'sinCriterios'}
onClick={() => setFlag(flag === 'sinCriterios' ? '' : 'sinCriterios')}
icon={<Icons.ClipboardX className="w-3.5 h-3.5" />}
label="Sin criterios de evaluación"
value={salud.sinCriterios}
/>
<HealthChip
active={flag === 'sinContenidos'}
onClick={() => setFlag(flag === 'sinContenidos' ? '' : 'sinContenidos')}
icon={<Icons.ListX className="w-3.5 h-3.5" />}
label="Sin contenidos"
value={salud.sinContenidos}
/>
{(q || sem !== 'todos' || tipo !== 'todos' || flag) && (
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
Limpiar filtros
</Button>
)}
</div>
</div>
</div>
{/* LISTA */}
<div className="space-y-6">
{!groups.length && <div className="text-sm text-neutral-500 text-center py-16">Sin asignaturas</div>}
{groups.map(([key, items]) => (
<section key={String(key)} className="space-y-2">
{groupBy !== 'ninguno' && (
<div className="sticky top-[64px] -mx-6 px-6 py-1 bg-background/90 backdrop-blur border-l-4 border-primary/30 text-xs font-semibold text-neutral-600 z-10">
Semestre {key}
</div>
)}
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
</ul>
</section>
))}
</div>
</div>
)
}
/* ================== Card ================== */
function tipoMeta(tipo?: string | null) {
const t = (tipo ?? '').toLowerCase()
if (t.includes('oblig')) return { label: 'Obligatoria', Icon: Icons.BadgeCheck, cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' }
if (t.includes('opt')) return { label: 'Optativa', Icon: Icons.Wand2, cls: 'bg-amber-50 text-amber-800 border-amber-200' }
if (t.includes('taller')) return { label: 'Taller', Icon: Icons.Hammer, cls: 'bg-indigo-50 text-indigo-700 border-indigo-200' }
if (t.includes('lab')) return { label: 'Laboratorio', Icon: Icons.FlaskConical, cls: 'bg-sky-50 text-sky-700 border-sky-200' }
return { label: tipo ?? 'Genérica', Icon: Icons.BookOpen, cls: 'bg-neutral-100 text-neutral-700 border-neutral-200' }
}
function Chip({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] ${className}`}>{children}</span>
}
function AsignaturaCard({ a }: { a: Asignatura }) {
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all">
<div className="p-3">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80">
<meta.Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
{/* Menú rápido (placeholder extensible) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="mt-[-2px]"><Icons.MoreVertical className="w-4 h-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem asChild className="gap-2">
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
<Icons.FolderOpen className="w-4 h-4" /> Abrir
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="gap-2">
<Link to="/plan/$planId" params={{ planId: a.plan?.id ?? '' }}>
<Icons.ScrollText className="w-4 h-4" /> Ver plan
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{a.clave && <Chip className="bg-white/70"><Icons.KeyRound className="h-3 w-3" /> {a.clave}</Chip>}
<Chip className={meta.cls}><meta.Icon className="h-3 w-3" /> {meta.label}</Chip>
{a.creditos != null && <Chip className="bg-white/70"><Icons.Coins className="h-3 w-3" /> {a.creditos} créditos</Chip>}
{(horasT + horasP) > 0 && <Chip className="bg-white/70"><Icons.Clock className="h-3 w-3" /> H T/P: {horasT}/{horasP}</Chip>}
<Chip className="bg-white/70"><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? '—'}</Chip>
</div>
{/* Contexto del plan/carrera/facultad */}
{a.plan && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-neutral-600">
<span className="inline-flex items-center gap-1">
<Icons.ScrollText className="w-3.5 h-3.5" /> {a.plan.nombre}
</span>
{a.plan.carrera && (
<span className="inline-flex items-center gap-1">
<Icons.GraduationCap className="w-3.5 h-3.5" /> {a.plan.carrera.nombre}
</span>
)}
{a.plan.carrera?.facultad && (
<span className="inline-flex items-center gap-1">
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
</span>
)}
</div>
)}
{/* Objetivo resumido + CTA */}
<div className="mt-2 flex items-center justify-between">
<p className="text-xs text-neutral-700 line-clamp-2">{a.objetivos ?? '—'}</p>
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: a.id }}
className="ml-3 inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs hover:bg-neutral-50"
>
Ver <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
</div>
</div>
</li>
)
}
/* ================== UI helpers ================== */
function HealthChip({
active, onClick, icon, label, value,
}: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; value: number }) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-1.5 text-xs ring-1 transition-colors
${active
? 'bg-amber-50 text-amber-800 ring-amber-300'
: 'bg-white/70 text-neutral-700 ring-neutral-200 hover:bg-neutral-50'}`}
>
{icon} {label}
<span className={`ml-1 inline-flex h-5 min-w-[1.5rem] items-center justify-center rounded-full px-1 text-[11px]
${active ? 'bg-amber-100 text-amber-900' : 'bg-neutral-100 text-neutral-800'}`}>
{value}
</span>
</button>
)
}
/* ================== Skeleton ================== */
function Pulse({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-xl ${className}`} />
}
function PageSkeleton() {
return (
<div className="p-6 space-y-6">
<Pulse className="h-36" />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => <Pulse key={i} className="h-32" />)}
</div>
</div>
)
}

View File

@@ -1,18 +1,35 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
// routes/_authenticated/asignaturas/$planId.tsx
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useEffect, useMemo, useRef, useState } from "react"
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
/* ================== Tipos ================== */
type Asignatura = {
id: string
nombre: string
clave: string | null
tipo: string | null
semestre: number | null
creditos: number | null
horas_teoricas: number | null
horas_practicas: number | null
objetivos: string | null
contenidos: Record<string, Record<string, string>> | null
bibliografia: string[] | null
criterios_evaluacion: string | null
}
type ModalData = {
@@ -21,8 +38,9 @@ type ModalData = {
asignaturas: Asignatura[]
}
/* ================== Ruta (modal) ================== */
export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({
component: ModalComponent,
component: Page,
loader: async ({ params }): Promise<ModalData> => {
const planId = params.planId
@@ -35,40 +53,105 @@ export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({
const { data: asignaturas, error: aErr } = await supabase
.from("asignaturas")
.select("id, nombre, semestre, creditos, horas_teoricas, horas_practicas")
.select(`
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
objetivos, contenidos, bibliografia, criterios_evaluacion
`)
.eq("plan_id", planId)
.order("semestre", { ascending: true })
.order("nombre", { ascending: true })
if (aErr) throw aErr
return {
planId,
planNombre: plan.nombre,
asignaturas: (asignaturas ?? []) as Asignatura[],
}
return { planId, planNombre: plan.nombre, asignaturas: (asignaturas ?? []) as Asignatura[] }
},
})
function ModalComponent() {
/* ================== Página ================== */
function Page() {
const { planId, planNombre, asignaturas } = Route.useLoaderData() as ModalData
const router = useRouter()
const [q, setQ] = useState("")
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return asignaturas
return asignaturas.filter(a =>
[a.nombre, a.semestre, a.creditos]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
)
}, [q, asignaturas])
// ---- estado UI
const [query, setQuery] = useState("")
const [sem, setSem] = useState<string>("todos")
const [tipo, setTipo] = useState<string>("todos")
const [orden, setOrden] = useState<"nombre" | "semestre" | "creditos">("semestre")
const [vista, setVista] = useState<"cards" | "tabla">("cards")
// Agrupar por semestre
const groups = useMemo(() => {
// ---- atajos
const searchRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const meta = e.ctrlKey || e.metaKey
if (meta && e.key.toLowerCase() === "k") {
e.preventDefault()
searchRef.current?.focus()
}
if (e.key === "Escape") {
router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [router, planId])
// ---- semestres y tipos disponibles
const semestres = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(String(a.semestre ?? "—")))
return Array.from(s).sort((a, b) => (a === "—" ? 1 : 0) - (b === "—" ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? "—"))
return Array.from(s).sort()
}, [asignaturas])
// ---- KPIs
const kpis = useMemo(() => {
const total = asignaturas.length
const creditos = asignaturas.reduce((acc, a) => acc + (a.creditos ?? 0), 0)
const ht = asignaturas.reduce((acc, a) => acc + (a.horas_teoricas ?? 0), 0)
const hp = asignaturas.reduce((acc, a) => acc + (a.horas_practicas ?? 0), 0)
const porTipo: Record<string, number> = {}
asignaturas.forEach(a => {
const key = (a.tipo ?? "—").toLowerCase()
porTipo[key] = (porTipo[key] ?? 0) + 1
})
return { total, creditos, ht, hp, porTipo }
}, [asignaturas])
// ---- filtro + orden
const filtradas = useMemo(() => {
const t = query.trim().toLowerCase()
const list = asignaturas.filter(a => {
const matchTexto =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === "todos" || String(a.semestre ?? "—") === sem
const tipoOK = tipo === "todos" || (a.tipo ?? "—") === tipo
return matchTexto && semOK && tipoOK
})
const sortList = [...list].sort((A, B) => {
if (orden === "nombre") return A.nombre.localeCompare(B.nombre)
if (orden === "creditos") return (B.creditos ?? 0) - (A.creditos ?? 0)
// semestre
const a = A.semestre ?? 999
const b = B.semestre ?? 999
if (a === b) return A.nombre.localeCompare(B.nombre)
return a - b
})
return sortList
}, [asignaturas, query, sem, tipo, orden])
// ---- agrupación por semestre (para la vista de cards)
const grupos = useMemo(() => {
const m = new Map<number | string, Asignatura[]>()
for (const a of filtered) {
for (const a of filtradas) {
const k = a.semestre ?? "—"
if (!m.has(k)) m.set(k, [])
m.get(k)!.push(a)
@@ -78,65 +161,318 @@ function ModalComponent() {
if (b === "—") return -1
return Number(a) - Number(b)
})
}, [filtered])
}, [filtradas])
// ---- helpers
const limpiar = () => { setQuery(""); setSem("todos"); setTipo("todos"); setOrden("semestre") }
return (
<Dialog
open
onOpenChange={() =>
router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })
}
onOpenChange={() => router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })}
>
<DialogContent className="w-[min(92vw,900px)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.BookOpen className="w-5 h-5" />
Asignaturas · <span className="font-normal">{planNombre}</span>
</DialogTitle>
</DialogHeader>
<DialogContent className="p-0 min-w-[94vw] h-[min(94vh,920px)] sm:rounded-2xl overflow-hidden flex flex-col">
{/* HERO ===================================================== */}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-transparent" />
<div className="relative px-5 pt-5 pb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-neutral-500">
<Icons.ScrollText className="h-4 w-4" />
Plan
</div>
<DialogHeader className="p-0">
<DialogTitle className="truncate text-xl sm:text-2xl">{planNombre}</DialogTitle>
</DialogHeader>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} />
<KpiChip icon={Icons.Coins} label="Créditos" value={kpis.creditos} />
<KpiChip icon={Icons.Clock} label="Horas T/P" value={`${kpis.ht}/${kpis.hp}`} />
{Object.entries(kpis.porTipo).slice(0, 3).map(([t, n]) => (
<Badge key={t} variant="secondary" className="text-[10px]">
{t} · {n}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2 pb-3">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre, semestre…"
className="w-full"
/>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/plan/$planId" params={{ planId }}>Ir al plan</Link>
</Button>
</div>
</div>
{/* TOOLBAR sticky ========================================= */}
<div className="sticky top-0 z-10 border-y bg-white/90 backdrop-blur">
<div className="px-5 py-3 grid gap-2 items-center
sm:grid-cols-[1fr,140px,160px,160px,auto]">
<div className="relative">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar (⌘/Ctrl K)…"
className="pl-8"
/>
</div>
<Select value={sem} onValueChange={setSem}>
<SelectTrigger className="w-full"><SelectValue placeholder="Semestre" /></SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
</SelectContent>
</Select>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger className="w-full"><SelectValue placeholder="Tipo" /></SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="todos">Todos</SelectItem>
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
<Select value={orden} onValueChange={(v) => setOrden(v as any)}>
<SelectTrigger className="w-full"><SelectValue placeholder="Ordenar por" /></SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Semestre</SelectItem>
<SelectItem value="nombre">Nombre</SelectItem>
<SelectItem value="creditos">Créditos</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center justify-end gap-2">
<ViewToggle value={vista} onChange={setVista} />
{(query || sem !== "todos" || tipo !== "todos" || orden !== "semestre") && (
<Button variant="ghost" size="sm" onClick={limpiar}>
<Icons.Eraser className="h-4 w-4 mr-1" /> Limpiar
</Button>
)}
</div>
</div>
</div>
</div>
<div className="max-h-[65vh] overflow-auto pr-1">
{groups.length === 0 && (
<div className="text-sm text-neutral-500 py-8 text-center">Sin asignaturas</div>
{/* CONTENIDO scrolleable ==================================== */}
<div className="flex-1 overflow-auto px-5 py-5">
{filtradas.length === 0 ? (
<EmptyState />
) : vista === "tabla" ? (
<Tabla asignaturas={filtradas} />
) : (
<div className="space-y-8">
{grupos.map(([sem, items]) => (
<section key={String(sem)} className="space-y-3">
<h3 className="text-xs font-semibold text-neutral-600">Semestre {sem}</h3>
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
</ul>
</section>
))}
</div>
)}
<div className="space-y-5">
{groups.map(([sem, items]) => (
<div key={String(sem)}>
<div className="mb-2 text-xs font-semibold text-neutral-500">
Semestre {sem}
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{items.map(a => (
<li key={a.id} className="rounded-xl border p-3 bg-white/70 dark:bg-neutral-900/60">
<div className="font-medium truncate">{a.nombre}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
{a.creditos != null && (
<Badge variant="outline" className="bg-white/60">Créditos: {a.creditos}</Badge>
)}
{(a.horas_teoricas ?? 0) + (a.horas_practicas ?? 0) > 0 && (
<Badge variant="secondary" className="bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100">
Hrs T/P: {a.horas_teoricas ?? 0}/{a.horas_practicas ?? 0}
</Badge>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}
/* ================== UI bits ================== */
function KpiChip({ icon: Icon, label, value }:{ icon: any; label: string; value: number | string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border bg-white/60 px-2 py-0.5 text-[11px]">
<Icon className="h-3.5 w-3.5" /> {label}: <span className="font-medium">{value}</span>
</span>
)
}
function ViewToggle({ value, onChange }:{ value:"cards"|"tabla"; onChange:(v:"cards"|"tabla")=>void }) {
return (
<div className="inline-flex rounded-lg border bg-white overflow-hidden">
<button
className={`px-2.5 py-1.5 text-xs flex items-center gap-1 ${value==="cards" ? "bg-neutral-100" : ""}`}
onClick={() => onChange("cards")}
title="Tarjetas"
>
<Icons.LayoutGrid className="h-4 w-4" /> Cards
</button>
<button
className={`px-2.5 py-1.5 text-xs flex items-center gap-1 border-l ${value==="tabla" ? "bg-neutral-100" : ""}`}
onClick={() => onChange("tabla")}
title="Tabla compacta"
>
<Icons.Table2 className="h-4 w-4" /> Tabla
</button>
</div>
)
}
function EmptyState() {
return (
<div className="grid place-items-center h-[48vh]">
<div className="text-center max-w-sm">
<div className="mx-auto w-12 h-12 rounded-2xl grid place-items-center bg-neutral-100">
<Icons.Inbox className="h-6 w-6 text-neutral-500" />
</div>
<h4 className="mt-3 font-semibold">Sin resultados</h4>
<p className="text-sm text-neutral-600">Ajusta los filtros o la búsqueda.</p>
</div>
</div>
)
}
/* ================== Card ================== */
function tipoMeta(tipo?: string | null) {
const t = (tipo ?? "").toLowerCase()
if (t.includes("oblig")) return { label: "Obligatoria", color: "emerald" }
if (t.includes("opt")) return { label: "Optativa", color: "amber" }
if (t.includes("taller")) return { label: "Taller", color: "indigo" }
if (t.includes("lab")) return { label: "Laboratorio", color: "sky" }
return { label: tipo ?? "Genérica", color: "neutral" }
}
function AsignaturaCard({ a }: { a: Asignatura }) {
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 shadow-sm hover:shadow-md transition-all">
{/* franja lateral por tipo */}
<span
aria-hidden
className={`absolute left-0 top-0 h-full w-1 bg-${meta.color}-500/80`}
/>
<div className="p-3 pl-4">
<div className="flex items-start gap-3">
<div className={`mt-0.5 h-8 w-8 rounded-xl grid place-items-center border bg-white/80`}>
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{a.clave && <Chip><Icons.KeyRound className="h-3 w-3" /> {a.clave}</Chip>}
<Chip className={`bg-${meta.color}-50 text-${meta.color}-800 border-${meta.color}-200`}>
<Icons.BadgeCheck className="h-3 w-3" /> {meta.label}
</Chip>
{a.creditos != null && <Chip><Icons.Coins className="h-3 w-3" /> {a.creditos} créditos</Chip>}
{(horasT + horasP) > 0 && <Chip><Icons.Clock className="h-3 w-3" /> H T/P: {horasT}/{horasP}</Chip>}
<Chip><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? "—"}</Chip>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="mt-[-2px]">
<Icons.MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[190px]">
<DropdownMenuItem className="gap-2" asChild>
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
<Icons.FileText className="h-4 w-4" /> Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Pencil className="h-4 w-4" /> Editar
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Sparkles className="h-4 w-4" /> Ajustar con IA
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* objetivo (clamp) */}
{a.objetivos && (
<p className="mt-2 text-xs text-neutral-700 line-clamp-2">{a.objetivos}</p>
)}
{/* CTA */}
<div className="mt-2 flex justify-end">
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: a.id }}
className="inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs hover:bg-neutral-50"
>
Ver ficha <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
</li>
)
}
function Chip({ children, className = "" }:{ children: React.ReactNode; className?: string }) {
return (
<span className={`inline-flex items-center gap-1 rounded-full border bg-white/70 px-2 py-0.5 text-[11px] ${className}`}>
{children}
</span>
)
}
/* ================== Tabla compacta ================== */
function Tabla({ asignaturas }: { asignaturas: Asignatura[] }) {
return (
<div className="rounded-2xl border bg-white/70 overflow-auto">
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white/90 backdrop-blur text-[12px]">
<tr className="[&>th]:px-3 [&>th]:py-2 text-left text-neutral-500">
<th>Nombre</th>
<th className="whitespace-nowrap">Clave</th>
<th>Tipo</th>
<th>Sem.</th>
<th>Créditos</th>
<th className="whitespace-nowrap">H T/P</th>
<th></th>
</tr>
</thead>
<tbody className="[&>tr:nth-child(odd)]:bg-neutral-50/40">
{asignaturas.map(a => (
<tr key={a.id} className="align-top">
<td className="px-3 py-2">
<div className="font-medium truncate max-w-[36ch]" title={a.nombre}>{a.nombre}</div>
{a.objetivos && <div className="text-xs text-neutral-600 line-clamp-1 max-w-[56ch]">{a.objetivos}</div>}
</td>
<td className="px-3 py-2 text-neutral-700">{a.clave ?? "—"}</td>
<td className="px-3 py-2">{a.tipo ?? "—"}</td>
<td className="px-3 py-2 tabular-nums">{a.semestre ?? "—"}</td>
<td className="px-3 py-2 tabular-nums">{a.creditos ?? "—"}</td>
<td className="px-3 py-2 tabular-nums">
{(a.horas_teoricas ?? 0)}/{(a.horas_practicas ?? 0)}
</td>
<td className="px-3 py-2">
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Icons.MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem asChild className="gap-2">
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
<Icons.FileText className="h-4 w-4" /> Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Pencil className="h-4 w-4" /> Editar
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Sparkles className="h-4 w-4" /> Ajustar con IA
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -1,9 +1,402 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { supabase, useSupabaseAuth } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
/* ========= Tipos ========= */
type Plan = {
id: string
nombre: string
fecha_creacion: string | null
objetivo_general: string | null
perfil_ingreso: string | null
perfil_egreso: string | null
sistema_evaluacion: string | null
total_creditos: number | null
}
type Asignatura = {
id: string
nombre: string
fecha_creacion: string | null
contenidos: any | null
criterios_evaluacion: string | null
bibliografia: any | null
}
type LoaderData = {
kpis: { facultades: number; carreras: number; planes: number; asignaturas: number }
calidadPlanesPct: number
saludAsignaturas: { sinBibliografia: number; sinCriterios: number; sinContenidos: number }
recientes: Array<{ tipo: 'plan' | 'asignatura'; id: string; nombre: string; fecha: string | null }>
}
/* ========= Loader ========= */
export const Route = createFileRoute('/_authenticated/dashboard')({
component: RouteComponent,
pendingComponent: DashboardSkeleton,
loader: async (): Promise<LoaderData> => {
// KPI counts
const [{ count: facCount }, { count: carCount }, { data: planesRaw }, { data: asignRaw }] =
await Promise.all([
supabase.from('facultades').select('*', { count: 'exact', head: true }),
supabase.from('carreras').select('*', { count: 'exact', head: true }),
supabase
.from('plan_estudios')
.select(
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
),
supabase
.from('asignaturas')
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia')
])
const planes = (planesRaw ?? []) as Plan[]
const asignaturas = (asignRaw ?? []) as Asignatura[]
// Calidad de planes
const needed: (keyof Plan)[] = [
'objetivo_general',
'perfil_ingreso',
'perfil_egreso',
'sistema_evaluacion',
'total_creditos'
]
const completos = planes.filter(p =>
needed.every(k => p[k] !== null && String(p[k] ?? '').toString().trim() !== '')
).length
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
// Salud de asignaturas
const sinBibliografia = asignaturas.filter(
a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
).length
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion?.trim()).length
const sinContenidos = asignaturas.filter(
a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
).length
// Actividad reciente (últimos 8 ítems)
const recientes = [
...planes.map(p => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })),
...asignaturas.map(a => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion }))
]
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
.slice(0, 8)
return {
kpis: {
facultades: facCount ?? 0,
carreras: carCount ?? 0,
planes: planes.length,
asignaturas: asignaturas.length
},
calidadPlanesPct,
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
recientes
}
}
})
function RouteComponent() {
return <div>Hello "/_authenticated/dashboard"!</div>
/* ========= Helpers visuales ========= */
function gradient(bg = '#2563eb') {
return {
background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)`
} as React.CSSProperties
}
function hex(color?: string | null, fallback = '#2563eb') {
return color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : fallback
}
function Ring({ pct, color }: { pct: number; color: string }) {
const R = 42
const C = 2 * Math.PI * R
const off = C * (1 - Math.min(Math.max(pct, 0), 100) / 100)
return (
<div className="flex items-center gap-4">
<svg width="112" height="112" viewBox="0 0 112 112">
<circle cx="56" cy="56" r={R} fill="none" stroke="#e5e7eb" strokeWidth="12" />
<circle
cx="56"
cy="56"
r={R}
fill="none"
stroke={color}
strokeWidth="12"
strokeDasharray={C}
strokeDashoffset={off}
strokeLinecap="round"
transform="rotate(-90 56 56)"
/>
</svg>
<div>
<div className="text-3xl font-bold tabular-nums">{pct}%</div>
<div className="text-sm text-neutral-600">Planes con información clave completa</div>
</div>
</div>
)
}
function Tile({
to,
label,
value,
Icon
}: {
to: string
label: string
value: number | string
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
}) {
return (
<Link
to={to}
className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all"
>
<div>
<div className="text-sm text-neutral-500">{label}</div>
<div className="text-3xl font-bold tabular-nums">{value}</div>
</div>
<div className="p-3 rounded-xl bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200">
<Icon className="w-7 h-7" />
</div>
</Link>
)
}
/* ========= Página ========= */
function RouteComponent() {
const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
const router = useRouter()
const primary = hex(auth.claims?.facultad_color, '#1d4ed8') // si guardan color de facultad en claims
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
const isAdmin = !!auth.claims?.claims_admin
const role = auth.claims?.role as
| 'lci'
| 'vicerrectoria'
| 'secretario_academico'
| 'jefe_carrera'
| 'planeacion'
| undefined
// Mensaje contextual
const roleHint = useMemo(() => {
switch (role) {
case 'vicerrectoria':
return 'Panorama académico, calidad y actividad reciente.'
case 'secretario_academico':
return 'Enfócate en tu facultad: salud de asignaturas y avance de planes.'
case 'jefe_carrera':
return 'Accede rápido a planes y asignaturas de tu carrera.'
case 'planeacion':
return 'Monitorea consistencia de planes y evidencias de evaluación.'
default:
return 'Atajos para crear, revisar y mejorar contenido.'
}
}, [role])
return (
<div className="p-6 space-y-8">
{/* Header con saludo y búsqueda global */}
<div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={gradient(primary)}>
<div
className="absolute inset-0 opacity-25"
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }}
/>
<div className="relative p-6 md:p-8 flex flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl md:text-3xl font-bold leading-tight">Hola, {name}</h1>
<p className="opacity-95">{roleHint}</p>
</div>
<div className="flex items-center gap-2">
{role && <Badge variant="secondary" className="bg-white/20 text-white border-white/30">{role}</Badge>}
{isAdmin && (
<Badge className="bg-white/20 text-white border-white/30 flex items-center gap-1">
<Icons.ShieldCheck className="w-3.5 h-3.5" /> admin
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 w-full">
<Input
placeholder="Buscar planes, asignaturas o personas… (Enter)"
className="bg-white/90 text-neutral-800 placeholder:text-neutral-400"
onKeyDown={e => {
if (e.key === 'Enter') {
const q = (e.target as HTMLInputElement).value.trim()
if (!q) return
router.navigate({ to: '/planes', search: { q } })
}
}}
/>
<Button
variant="secondary"
className="bg-white/20 text-white hover:bg-white/30 border-white/30"
onClick={() => router.invalidate()}
title="Actualizar"
>
<Icons.RefreshCcw className="w-4 h-4" />
</Button>
</div>
{/* Atajos rápidos (según rol) */}
<div className="flex flex-wrap gap-2">
<Link
to="/planes"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
>
<Icons.ScrollText className="w-4 h-4" /> Nuevo plan
</Link>
<Link
to="/asignaturas"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
<Icons.BookOpen className="w-4 h-4" /> Nueva asignatura
</Link>
{isAdmin && (
<Link
to="/usuarios"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
>
<Icons.UserPlus className="w-4 h-4" /> Invitar usuario
</Link>
)}
</div>
</div>
</div>
{/* KPIs principales */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Tile to="/_authenticated/facultades" label="Facultades" value={kpis.facultades} Icon={Icons.Building2} />
<Tile to="/_authenticated/carreras" label="Carreras" value={kpis.carreras} Icon={Icons.GraduationCap} />
<Tile to="/_authenticated/planes" label="Planes de estudio" value={kpis.planes} Icon={Icons.ScrollText} />
<Tile to="/_authenticated/asignaturas" label="Asignaturas" value={kpis.asignaturas} Icon={Icons.BookOpen} />
</div>
{/* Calidad + Salud */}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.CheckCircle2 className="w-5 h-5" /> Calidad de planes
</CardTitle>
</CardHeader>
<CardContent>
<Ring pct={calidadPlanesPct} color={hex(auth.claims?.facultad_color, '#2563eb')} />
<p className="mt-3 text-sm text-neutral-600">
Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.HeartPulse className="w-5 h-5" /> Salud de asignaturas
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<HealthRow
to="/_authenticated/asignaturas?f=sinBibliografia"
label="Sin bibliografía"
value={saludAsignaturas.sinBibliografia}
/>
<HealthRow
to="/_authenticated/asignaturas?f=sinCriterios"
label="Sin criterios de evaluación"
value={saludAsignaturas.sinCriterios}
/>
<HealthRow
to="/_authenticated/asignaturas?f=sinContenidos"
label="Sin contenidos"
value={saludAsignaturas.sinContenidos}
/>
</CardContent>
</Card>
</div>
{/* Actividad reciente */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Activity className="w-5 h-5" /> Actividad reciente
</CardTitle>
</CardHeader>
<CardContent>
{recientes.length === 0 && (
<div className="text-sm text-neutral-500">Sin actividad registrada.</div>
)}
<ul className="divide-y">
{recientes.map(r => (
<li key={`${r.tipo}-${r.id}`} className="py-2 flex items-center justify-between gap-3">
<Link
to={r.tipo === 'plan' ? '/plan/$planId' : '/asignatura/$asignaturaId'}
params={r.tipo === 'plan' ? { planId: r.id } : { asignaturaId: r.id }}
className="truncate inline-flex items-center gap-2 hover:underline"
title={r.nombre}
>
{r.tipo === 'plan' ? (
<Icons.ScrollText className="w-4 h-4 text-neutral-500" />
) : (
<Icons.BookOpen className="w-4 h-4 text-neutral-500" />
)}
<span className="truncate">{r.nombre}</span>
</Link>
<span className="text-xs text-neutral-500">
{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)
}
/* ========= Subcomponentes ========= */
function HealthRow({ label, value, to }: { label: string; value: number; to: string }) {
const warn = value > 0
return (
<Link
to={to}
className={`flex items-center justify-between rounded-xl px-4 py-3 ring-1 ${warn
? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100'
: 'ring-neutral-200 hover:bg-neutral-50'
} transition-colors`}
>
<span className="text-sm">{label}</span>
<span className="text-lg font-semibold tabular-nums">{value}</span>
</Link>
)
}
/* ========= Skeleton (cuando carga) ========= */
function Pulse({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-xl ${className}`} />
}
function DashboardSkeleton() {
return (
<div className="p-6 space-y-8">
<div className="rounded-3xl p-8">
<Pulse className="h-24" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Pulse key={i} className="h-28" />
))}
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Pulse className="h-64 lg:col-span-2" />
<Pulse className="h-64" />
</div>
<Pulse className="h-72" />
</div>
)
}

View File

@@ -129,8 +129,7 @@ function ProgressRing({ pct, color }: { pct: number, color: string }) {
return (
<div className="flex items-center gap-4">
<svg width="112" height="112" viewBox="0 0 112 112" className="drop-shadow">
<circle cx="56" cy="56" r={r} fill="none" stroke={color} strokeWidth="12" />
<circle cx="56" cy="56" r={r} fill="none" stroke="currentColor" strokeWidth="12"
<circle cx="56" cy="56" r={r} fill="none" stroke={color} strokeWidth="12"
strokeDasharray={c} strokeDashoffset={offset} strokeLinecap="round"
transform="rotate(-90 56 56)" />
</svg>

View File

@@ -216,13 +216,7 @@ function RouteComponent() {
{plan.estado}
</Badge>
)}
<Link
to="/asignaturas/$planId"
params={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
</Link>
<div className='flex gap-2'>
<EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} />

View File

@@ -8,6 +8,12 @@ import { Badge } from "@/components/ui/badge"
import * as Icons from "lucide-react"
import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
export type PlanDeEstudios = {
id: string; nombre: string; nivel: string | null; duracion: string | null;
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
@@ -64,7 +70,7 @@ function InfoChip({
return (
<span
title={label}
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none bg-white/70 text-neutral-800"
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none"
style={style}
>
{icon}
@@ -77,6 +83,7 @@ function InfoChip({
function RouteComponent() {
const auth = useSupabaseAuth()
const [q, setQ] = useState("")
const [openCreate, setOpenCreate] = useState(false)
const data = Route.useLoaderData() as PlanRow[]
const router = useRouter()
@@ -105,9 +112,10 @@ function RouteComponent() {
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<RefreshCcw className="h-4 w-4" />
</Button>
<Button>
<Button onClick={() => setOpenCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
</Button>
</div>
</CardHeader>
@@ -184,6 +192,193 @@ function RouteComponent() {
)}
</CardContent>
</Card>
<CreatePlanDialog
open={openCreate}
onOpenChange={setOpenCreate}
onCreated={(id) => {
setOpenCreate(false)
router.invalidate()
router.navigate({ to: "/plan/$planId", params: { planId: id } })
}}
/>
</div>
)
}
function CreatePlanDialog({
open, onOpenChange, onCreated,
}: {
open: boolean
onOpenChange: (v: boolean) => void
onCreated: (newId: string) => void
}) {
const auth = useSupabaseAuth()
const role = auth.claims?.role
const defaultFac = auth.claims?.facultad_id ?? ""
const defaultCar = auth.claims?.carrera_id ?? ""
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState<{
nombre: string
nivel: string
duracion: string
total_creditos: number | null
facultad_id: string
carrera_id: string
objetivo_general?: string
}>({
nombre: "",
nivel: "",
duracion: "",
total_creditos: null,
facultad_id: defaultFac,
carrera_id: defaultCar,
objetivo_general: "",
})
// Reglas por rol:
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
const lockCarrera = role === "jefe_carrera"
const needsFacultad = role === "secretario_academico" || role === "jefe_carrera" || role === "vicerrectoria" || role === "lci"
const needsCarrera = role !== "planeacion" // en general todos crean sobre una carrera
async function createPlan() {
setError(null)
if (!form.nombre.trim()) return setError("El nombre es obligatorio.")
if (needsCarrera && !form.carrera_id) return setError("Selecciona una carrera.")
setSaving(true)
const { data, error } = await supabase
.from("plan_estudios")
.insert({
nombre: form.nombre.trim(),
nivel: form.nivel || null,
duracion: form.duracion || null,
total_creditos: form.total_creditos ?? null,
objetivo_general: form.objetivo_general || null,
carrera_id: form.carrera_id,
estado: "activo",
})
.select("id")
.single()
setSaving(false)
if (error) {
setError(error.message)
return
}
onCreated(data!.id)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,720px)]">
<DialogHeader>
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<Label>Nombre *</Label>
<Input
value={form.nombre}
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
placeholder="Ej. Licenciatura en Ciberseguridad"
/>
</div>
<div className="space-y-1">
<Label>Nivel</Label>
<Input
value={form.nivel}
onChange={(e) => setForm(s => ({ ...s, nivel: e.target.value }))}
placeholder="Licenciatura / Maestría…"
/>
</div>
<div className="space-y-1">
<Label>Duración</Label>
<Input
value={form.duracion}
onChange={(e) => setForm(s => ({ ...s, duracion: e.target.value }))}
placeholder="9 semestres / 3 años…"
/>
</div>
<div className="space-y-1">
<Label>Créditos totales</Label>
<Input
inputMode="numeric"
value={form.total_creditos ?? ""}
onChange={(e) => {
const v = e.target.value.trim()
setForm(s => ({ ...s, total_creditos: v === "" ? null : Number(v) || 0 }))
}}
placeholder="270"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label>Objetivo general</Label>
<Textarea
value={form.objetivo_general ?? ""}
onChange={(e) => setForm(s => ({ ...s, objetivo_general: e.target.value }))}
placeholder="Describe el objetivo general del plan…"
className="min-h-[90px]"
/>
</div>
{/* Procedencia */}
{needsFacultad && (
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={form.facultad_id}
onChange={(id) =>
setForm(s => ({
...s,
facultad_id: id,
carrera_id: lockCarrera ? s.carrera_id : "", // limpia carrera si no está bloqueada
}))
}
disabled={lockFacultad}
/>
{lockFacultad && (
<p className="text-[11px] text-neutral-500">Fijado por tu rol.</p>
)}
</div>
)}
{needsCarrera && (
<div className="space-y-1">
<Label>Carrera *</Label>
<CarreraCombobox
facultadId={form.facultad_id}
value={form.carrera_id}
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
disabled={lockCarrera || !form.facultad_id}
placeholder={form.facultad_id ? "Selecciona carrera…" : "Selecciona una facultad primero"}
/>
{lockCarrera && (
<p className="text-[11px] text-neutral-500">Fijada por tu rol.</p>
)}
</div>
)}
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button className="w-full sm:w-auto" onClick={createPlan} disabled={saving}>
{saving ? "Creando…" : "Crear plan"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -13,10 +13,12 @@ import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import {
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, CheckCircle2, XCircle,
Cpu, Building2, ScrollText, GraduationCap, GanttChart
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff
} from "lucide-react"
import { SupabaseClient } from "@supabase/supabase-js"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { toast } from "sonner"
type AdminUser = {
id: string
@@ -106,6 +108,70 @@ function RouteComponent() {
carrera_id?: string | null;
}>({})
const [createOpen, setCreateOpen] = useState(false)
const [createSaving, setCreateSaving] = useState(false)
const [showPwd, setShowPwd] = useState(false)
const [createForm, setCreateForm] = useState<{
email: string
password: string
role?: Role
claims_admin?: boolean
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string
facultad_id?: string | null
carrera_id?: string | null
}>({ email: "", password: "" })
function genPassword() {
// 14 chars pseudo-aleatoria
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
.map(n => n.toString(36)).join("")
return s.slice(0, 14)
}
async function createUserNow() {
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
try {
setCreateSaving(true)
const admin = new SupabaseClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
)
const password = createForm.password?.trim() || genPassword()
const { error } = await admin.auth.admin.createUser({
email: createForm.email.trim(),
password,
email_confirm: false,
user_metadata: {
nombre: createForm.nombre ?? "",
apellidos: createForm.apellidos ?? "",
title: createForm.title ?? "",
clave: createForm.clave ?? "",
avatar: createForm.avatar ?? ""
},
app_metadata: {
role: createForm.role,
claims_admin: !!createForm.claims_admin,
facultad_id: createForm.facultad_id ?? null,
carrera_id: createForm.carrera_id ?? null
}
})
if (error) throw error
toast.success("Usuario creado")
setCreateOpen(false)
setCreateForm({ email: "", password: "" })
router.invalidate()
} catch (e: any) {
console.error(e)
toast.error(e?.message || "No se pudo crear el usuario")
} finally {
setCreateSaving(false)
}
}
if (!auth.claims?.claims_admin) {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
}
@@ -176,6 +242,11 @@ function RouteComponent() {
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
<RefreshCcw className="w-4 h-4" />
</Button>
{/* NUEVO: abrir modal de alta */}
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
</Button>
</div>
</CardHeader>
@@ -395,6 +466,145 @@ function RouteComponent() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal: Nuevo usuario */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
<DialogHeader><DialogTitle>Nuevo usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<Label>Correo</Label>
<Input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm(s => ({ ...s, email: e.target.value }))}
placeholder="usuario@lasalle.mx"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label>Contraseña temporal</Label>
<div className="flex gap-2">
<Input
type={showPwd ? "text" : "password"}
value={createForm.password}
onChange={(e) => setCreateForm(s => ({ ...s, password: e.target.value }))}
placeholder="Se generará si la dejas vacía"
/>
<Button type="button" variant="outline" onClick={() => setCreateForm(s => ({ ...s, password: genPassword() }))}>
Generar
</Button>
<Button type="button" variant="outline" onClick={() => setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
</div>
<div className="space-y-1">
<Label>Nombre</Label>
<Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Apellidos</Label>
<Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Título</Label>
<Input value={createForm.title ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, title: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Clave</Label>
<Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, clave: e.target.value }))} />
</div>
<div className="space-y-1 md:col-span-2">
<Label>Avatar (URL)</Label>
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
</div>
{/* Rol */}
<div className="space-y-1 md:col-span-2">
<Label>Rol</Label>
<Select
value={createForm.role ?? ""}
onValueChange={(v) => {
setCreateForm(s => {
const role = v as Role
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
if (role === "secretario_academico") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
return { ...s, role, facultad_id: null, carrera_id: null }
})
}}
>
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
<SelectContent className="max-h-72">
{ROLES.map(code => {
const M = ROLE_META[code]; const I = M.Icon
return (
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
<span className="inline-flex items-center gap-2"><I className="w-4 h-4" /> {M.label}</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
{/* SECRETARIO: Facultad */}
{createForm.role === "secretario_academico" && (
<div className="md:col-span-2 space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={createForm.facultad_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
/>
</div>
)}
{/* JEFE_CARRERA: Facultad + Carrera */}
{createForm.role === "jefe_carrera" && (
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={createForm.facultad_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
/>
</div>
<div className="space-y-1">
<Label>Carrera</Label>
<CarreraCombobox
facultadId={createForm.facultad_id ?? ""}
value={createForm.carrera_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, carrera_id: id }))}
disabled={!createForm.facultad_id}
/>
</div>
</div>
)}
<div className="space-y-1 md:col-span-2">
<Label>Permisos</Label>
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="true">Administrador</SelectItem>
<SelectItem value="false">Usuario</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={createUserNow} disabled={!createForm.email || createSaving}>
{createSaving ? "Creando…" : "Crear usuario"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div >
)
}

View File

@@ -9,7 +9,7 @@ import { Mail, Lock, Eye, EyeOff, Loader2, Shield } from "lucide-react"
export const Route = createFileRoute("/login")({
validateSearch: (search) => ({
redirect: (search.redirect as string) || "/planes",
redirect: (search.redirect as string) || "/dashboard",
}),
beforeLoad: ({ context, search }) => {
if (context.auth.isAuthenticated) {