-
-
-
-
+ return (
+
+
+
+
+
+
-
-
-
{a.nombre}
- {/* Menú rápido (placeholder extensible) */}
-
-
-
-
-
-
-
- Abrir
-
-
-
-
- Ver plan
-
-
-
-
-
+
+
+
{a.nombre}
-
- {a.clave && {a.clave} }
- {meta.label}
- {a.creditos != null && {a.creditos} créditos }
- {(horasT + horasP) > 0 && H T/P: {horasT}/{horasP} }
- Semestre {a.semestre ?? '—'}
-
-
- {/* Contexto del plan/carrera/facultad */}
- {a.plan && (
-
-
- {a.plan.nombre}
-
- {a.plan.carrera && (
-
- {a.plan.carrera.nombre}
-
- )}
- {a.plan.carrera?.facultad && (
-
- {a.plan.carrera.facultad.nombre}
-
- )}
-
- )}
-
- {/* Objetivo resumido + CTA */}
-
-
{a.objetivos ?? '—'}
-
- Ver
-
-
-
-
+
+
+
+
+
+
+
+ Abrir
+
+
+
+
+ Ver plan
+
+
+ {/* NEW */}
+
+ Clonar…
+
+
+ Añadir al carrito
+
+
+
-
- )
+
+
+ {a.clave && {a.clave} }
+ {meta.label}
+ {a.creditos != null && {a.creditos} créditos }
+ {(horasT + horasP) > 0 && H T/P: {horasT}/{horasP} }
+ Semestre {a.semestre ?? '—'}
+
+
+ {a.plan && (
+
+
+ Plan: {a.plan.nombre}
+
+ {a.plan.carrera && (
+
+ Carrera: {a.plan.carrera.nombre}
+
+ )}
+ {a.plan.carrera?.facultad && (
+
+ {a.plan.carrera.facultad.nombre}
+
+ )}
+
+ )}
+
+
+
{a.objetivos ?? '—'}
+
+ Ver
+
+
+
+
+
+
+ )
}
/* ================== UI helpers ================== */
function HealthChip({
- active, onClick, icon, label, value,
+ active, onClick, icon, label, value,
}: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; value: number }) {
- return (
-
- {icon} {label}
-
+ {icon} {label}
+
- {value}
-
-
- )
+ {value}
+
+
+ )
}
/* ================== Skeleton ================== */
function Pulse({ className = '' }: { className?: string }) {
- return
+ return
}
function PageSkeleton() {
- return (
-
-
-
- {Array.from({ length: 9 }).map((_, i) =>
)}
-
-
- )
+ return (
+
+
+
+ {Array.from({ length: 9 }).map((_, i) =>
)}
+
+
+ )
}
diff --git a/src/routes/_authenticated/facultad/$facultadId.tsx b/src/routes/_authenticated/facultad/$facultadId.tsx
index 43cf6c1..6cf3f6f 100644
--- a/src/routes/_authenticated/facultad/$facultadId.tsx
+++ b/src/routes/_authenticated/facultad/$facultadId.tsx
@@ -5,7 +5,6 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { supabase } from '@/auth/supabase'
import { useMemo } from 'react'
-import { useTheme } from '@/components/theme-provider'
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
type Plan = {
diff --git a/src/routes/_authenticated/facultades.tsx b/src/routes/_authenticated/facultades.tsx
index fdcff34..57f5105 100644
--- a/src/routes/_authenticated/facultades.tsx
+++ b/src/routes/_authenticated/facultades.tsx
@@ -1,7 +1,13 @@
-import { createFileRoute, Link } from '@tanstack/react-router'
+import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { supabase } from '@/auth/supabase'
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
+import { toast } from 'sonner'
type Facultad = {
id: string
@@ -17,7 +23,6 @@ export const Route = createFileRoute('/_authenticated/facultades')({
.from('facultades')
.select('id, nombre, icon, color')
.order('nombre')
-
if (error) {
console.error(error)
return { facultades: [] as Facultad[] }
@@ -26,55 +31,267 @@ export const Route = createFileRoute('/_authenticated/facultades')({
},
})
+/* ----------- Paleta curada ----------- */
+const PALETTE: { name: string; hex: `#${string}` }[] = [
+ { name: 'Indigo', hex: '#4F46E5' },
+ { name: 'Blue', hex: '#2563EB' },
+ { name: 'Sky', hex: '#0EA5E9' },
+ { name: 'Teal', hex: '#14B8A6' },
+ { name: 'Emerald', hex: '#10B981' },
+ { name: 'Lime', hex: '#84CC16' },
+ { name: 'Amber', hex: '#F59E0B' },
+ { name: 'Orange', hex: '#F97316' },
+ { name: 'Red', hex: '#EF4444' },
+ { name: 'Rose', hex: '#F43F5E' },
+ { name: 'Violet', hex: '#7C3AED' },
+ { name: 'Purple', hex: '#9333EA' },
+ { name: 'Fuchsia', hex: '#C026D3' },
+ { name: 'Slate', hex: '#334155' },
+ { name: 'Zinc', hex: '#3F3F46' },
+ { name: 'Neutral', hex: '#404040' },
+]
+
+/* Un set corto y útil de íconos Lucide */
+const ICON_CHOICES = [
+ 'Building2', 'Building', 'School', 'University', 'Landmark', 'Library', 'Layers',
+ 'Atom', 'FlaskConical', 'Microscope', 'Cpu', 'Hammer', 'Palette', 'Shapes', 'BookOpen', 'GraduationCap'
+] as const
+type IconName = typeof ICON_CHOICES[number]
+
function gradientFrom(color?: string | null) {
- const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb' // azul por defecto
- // degradado elegante con transparencia
+ const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
}
function RouteComponent() {
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
+ const router = useRouter()
+
+ const [createOpen, setCreateOpen] = useState(false)
+ const [editOpen, setEditOpen] = useState(false)
+ const [saving, setSaving] = useState(false)
+
+ const [form, setForm] = useState<{ nombre: string; icon: IconName; color: `#${string}` }>(
+ { nombre: '', icon: 'Building2', color: '#2563EB' }
+ )
+ const [editing, setEditing] = useState
(null)
+
+ function openCreate() {
+ setForm({ nombre: '', icon: 'Building2', color: '#2563EB' })
+ setCreateOpen(true)
+ }
+ function openEdit(f: Facultad) {
+ setEditing(f)
+ setForm({
+ nombre: f.nombre,
+ icon: (ICON_CHOICES.includes(f.icon as IconName) ? f.icon : 'Building2') as IconName,
+ color: ((f.color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(f.color)) ? (f.color as `#${string}`) : '#2563EB')
+ })
+ setEditOpen(true)
+ }
+
+ async function doCreate() {
+ if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
+ setSaving(true)
+ const { error } = await supabase.from('facultades')
+ .insert({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
+ setSaving(false)
+ if (error) { console.error(error); toast.error('No se pudo crear'); return }
+ toast.success('Facultad creada ✨')
+ setCreateOpen(false)
+ router.invalidate()
+ }
+
+ async function doEdit() {
+ if (!editing) return
+ if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
+ setSaving(true)
+ const { error } = await supabase.from('facultades')
+ .update({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
+ .eq('id', editing.id)
+ setSaving(false)
+ if (error) { console.error(error); toast.error('No se pudo guardar'); return }
+ toast.success('Cambios guardados ✅')
+ setEditOpen(false)
+ setEditing(null)
+ router.invalidate()
+ }
return (
-
+
+ {/* Header */}
+
+
+ Facultades
+
+
+ router.invalidate()}>
+ Recargar
+
+
+ Nueva facultad
+
+
+
+
+ {/* Grid */}
{facultades.map((fac) => {
- const LucideIcon = (Icons as any)[fac.icon] || Icons.Building
- const bg = useMemo(() => ({ background: gradientFrom(fac.color) }), [fac.color])
+ const LucideIcon = (Icons as any)[fac.icon] || Icons.Building2
+ const bg = { background: gradientFrom(fac.color) } // ← sin useMemo aquí
return (
-
- {/* capa brillo */}
-
-
- {/* contenido */}
-
-
-
-
- {fac.nombre}
-
-
+
+
- {/* borde dinámico al hover */}
-
- {/* animación sutil */}
-
-
+
+ openEdit(fac)}
+ >
+
+
+
+
)
})}
+
+
+ {/* Dialog Crear */}
+
+
+
+ Nueva facultad
+
+
+
+
+
+ setCreateOpen(false)}>Cancelar
+ {saving ? 'Guardando…' : 'Crear'}
+
+
+
+
+ {/* Dialog Editar */}
+
{ setEditOpen(o); if (!o) setEditing(null) }}>
+
+
+ Editar facultad
+
+
+
+
+
+ { setEditOpen(false); setEditing(null) }}>Cancelar
+ {saving ? 'Guardando…' : 'Guardar'}
+
+
+
+
+ )
+}
+
+/* ----------- Subcomponentes ----------- */
+function FormFields({
+ form, setForm
+}: {
+ form: { nombre: string; icon: IconName; color: `#${string}` }
+ setForm: React.Dispatch
>
+}) {
+ const PreviewIcon = (Icons as any)[form.icon] || Icons.Building2
+ const bg = useMemo(() => ({ background: gradientFrom(form.color) }), [form.color])
+
+ return (
+
+ {/* Preview */}
+
+
+ {/* Nombre */}
+
+ Nombre
+ setForm(s => ({ ...s, nombre: e.target.value }))}
+ placeholder="Facultad de Ingeniería"
+ />
+
+
+ {/* Icono */}
+
+ Ícono
+ setForm(s => ({ ...s, icon: v as IconName }))}>
+
+
+ {ICON_CHOICES.map(k => {
+ const Ico = (Icons as any)[k]
+ return (
+
+ {k}
+
+ )
+ })}
+
+
+
+
+ {/* Color (paleta curada) */}
+
+ Color
+ setForm(s => ({ ...s, color: hex }))}
+ />
+
+
+ )
+}
+
+function ColorGrid({ value, onChange }: { value: `#${string}`; onChange: (hex: `#${string}`) => void }) {
+ return (
+
+ {PALETTE.map(c => (
+ onChange(c.hex)}
+ className={`relative h-9 rounded-xl ring-1 ring-black/10 transition
+ ${value === c.hex ? 'outline outline-2 outline-offset-2 outline-black/70' : 'hover:scale-[1.03]'}`}
+ style={{ background: c.hex }}
+ title={c.name}
+ aria-label={c.name}
+ >
+ {value === c.hex && (
+
+ )}
+
+ ))}
)
}
diff --git a/src/routes/_authenticated/plan/$planId.tsx b/src/routes/_authenticated/plan/$planId.tsx
index 5bb9420..9614473 100644
--- a/src/routes/_authenticated/plan/$planId.tsx
+++ b/src/routes/_authenticated/plan/$planId.tsx
@@ -250,6 +250,7 @@ function RouteComponent() {
+ {/* ===== Asignaturas (preview cards) ===== */}
Asignaturas ({asignaturasCount})
@@ -258,32 +259,29 @@ function RouteComponent() {
router.invalidate()} />
- Ver todas
+ Ver en página de Asignaturas
-
-
- {asignaturasPreview.length === 0 && (
+
+ {asignaturasPreview.length === 0 ? (
Sin asignaturas
+ ) : (
+
+ {asignaturasPreview.map((a) => (
+
+ ))}
+
)}
- {asignaturasPreview.map(a => (
-
- {a.semestre ? `S${a.semestre} · ` : ''}{a.nombre}
-
- ))}
+
)
}
@@ -404,9 +402,9 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
async function apply() {
setLoading(true)
- await fetch('https://genesis-engine.apps.lci.ulsa.mx/ajustar/plan', {
+ await fetch('https://genesis-engine.apps.lci.ulsa.mx/api/mejorar/plan', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt, plan }),
+ body: JSON.stringify({ prompt, plan_id: plan.id }),
}).catch(() => { })
setLoading(false)
setOpen(false)
@@ -629,3 +627,146 @@ function AddAsignaturaButton({
>
)
}
+
+
+function AsignaturaPreviewCard({
+ asignatura,
+}: {
+ asignatura: { id: string; nombre: string; semestre: number | null; creditos: number | null }
+}) {
+ const [extra, setExtra] = useState<{
+ tipo: string | null
+ horas_teoricas: number | null
+ horas_practicas: number | null
+ contenidos: Record> | null
+ } | null>(null)
+
+ // Carga perezosa de info extra para enriquecer la tarjeta (8 items máx: ok)
+ useEffect(() => {
+ let ignore = false
+ ; (async () => {
+ const { data } = await supabase
+ .from("asignaturas")
+ .select("tipo, horas_teoricas, horas_practicas, contenidos")
+ .eq("id", asignatura.id)
+ .maybeSingle()
+ if (!ignore) setExtra((data as any) ?? null)
+ })()
+ return () => {
+ ignore = true
+ }
+ }, [asignatura.id])
+
+ const horasT = extra?.horas_teoricas ?? null
+ const horasP = extra?.horas_practicas ?? null
+ const horasTot = (horasT ?? 0) + (horasP ?? 0)
+
+ // Conteo rápido de unidades/temas si existen
+ const resumenContenidos = useMemo(() => {
+ const c = extra?.contenidos
+ if (!c) return { unidades: 0, temas: 0 }
+ const unidades = Object.keys(c).length
+ const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
+ return { unidades, temas }
+ }, [extra?.contenidos])
+
+ // estilo por tipo
+ const tipo = (extra?.tipo ?? "").toLowerCase()
+ const tipoChip =
+ tipo.includes("oblig")
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200"
+ : tipo.includes("opt")
+ ? "bg-amber-50 text-amber-800 border-amber-200"
+ : tipo.includes("taller")
+ ? "bg-indigo-50 text-indigo-700 border-indigo-200"
+ : tipo.includes("lab")
+ ? "bg-sky-50 text-sky-700 border-sky-200"
+ : "bg-neutral-100 text-neutral-700 border-neutral-200"
+
+ return (
+
+ {/* header */}
+
+
+
+
+
+
+ {asignatura.nombre}
+
+
+ {asignatura.semestre != null && (
+
+ S{asignatura.semestre}
+
+ )}
+ {asignatura.creditos != null && (
+
+ {asignatura.creditos} cr
+
+ )}
+ {extra?.tipo && (
+
+ {extra.tipo}
+
+ )}
+
+
+
+
+ {/* cuerpo */}
+
+
+
+
+
+
+ {/* footer */}
+
+
+ {horasT != null || horasP != null ? (
+ <>H T/P: {horasT ?? "—"}/{horasP ?? "—"}>
+ ) : (
+ Resumen listo
+ )}
+
+
+
+ Ver detalle
+
+
+
+ {/* glow sutil en hover */}
+
+
+ )
+}
+
+function SmallStat({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: React.ComponentType>
+ label: string
+ value: string | number
+}) {
+ return (
+
+
+ {label}
+
+
{value}
+
+ )
+}
diff --git a/src/routes/_authenticated/usuarios.tsx b/src/routes/_authenticated/usuarios.tsx
index 93ecb87..2d8b96d 100644
--- a/src/routes/_authenticated/usuarios.tsx
+++ b/src/routes/_authenticated/usuarios.tsx
@@ -7,604 +7,705 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
- Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
} from "@/components/ui/dialog"
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, Plus, Eye, EyeOff
+ RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail,
+ Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff,
+ Ban as BanIcon, Check
} 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
- email: string | null
- created_at: string
- last_sign_in_at: string | null
- user_metadata: any
- app_metadata: any
+ id: string
+ email: string | null
+ created_at: string
+ last_sign_in_at: string | null
+ user_metadata: any
+ app_metadata: any
+ banned_until?: string | null // NEW: lo usamos en UI
}
-const ROLES = ["lci", "vicerrectoria", "secretario_academico", "jefe_carrera", "planeacion"] as const
+// NEW: constantes auxiliares
+const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
+
+// NEW: agrega director_facultad; mantenemos planeacion por compat
+const ROLES = [
+ "lci",
+ "vicerrectoria",
+ "director_facultad", // NEW
+ "secretario_academico",
+ "jefe_carrera",
+ "planeacion",
+] as const
export type Role = typeof ROLES[number]
const ROLE_META: Record>
- className: string
+ label: string
+ Icon: React.ComponentType>
+ className: string
}> = {
- lci: {
- label: "Laboratorio de Cómputo de Ingeniería",
- Icon: Cpu,
- className: "bg-neutral-900 text-white"
- },
- vicerrectoria: {
- label: "Vicerrectoría Académica",
- Icon: Building2,
- className: "bg-indigo-600 text-white"
- },
- secretario_academico: {
- label: "Secretario Académico",
- Icon: ScrollText,
- className: "bg-emerald-600 text-white"
- },
- jefe_carrera: {
- label: "Jefe de Carrera",
- Icon: GraduationCap,
- className: "bg-orange-600 text-white"
- },
- planeacion: {
- label: "Planeación Curricular",
- Icon: GanttChart,
- className: "bg-sky-600 text-white"
- }
+ lci: {
+ label: "Laboratorio de Cómputo de Ingeniería",
+ Icon: Cpu,
+ className: "bg-neutral-900 text-white"
+ },
+ vicerrectoria: {
+ label: "Vicerrectoría Académica",
+ Icon: Building2,
+ className: "bg-indigo-600 text-white"
+ },
+ director_facultad: { // NEW
+ label: "Director(a) de Facultad",
+ Icon: Building2,
+ className: "bg-purple-600 text-white"
+ },
+ secretario_academico: {
+ label: "Secretario Académico",
+ Icon: ScrollText,
+ className: "bg-emerald-600 text-white"
+ },
+ jefe_carrera: {
+ label: "Jefe de Carrera",
+ Icon: GraduationCap,
+ className: "bg-orange-600 text-white"
+ },
+ planeacion: {
+ label: "Planeación Curricular",
+ Icon: GanttChart,
+ className: "bg-sky-600 text-white"
+ }
}
function RolePill({ role }: { role: Role }) {
- const meta = ROLE_META[role]
- if (!meta) return null
- const { Icon, className, label } = meta
- return (
-
-
- {label}
-
- )
+ const meta = ROLE_META[role]
+ if (!meta) return null
+ const { Icon, className, label } = meta
+ return (
+
+
+ {label}
+
+ )
}
export const Route = createFileRoute("/_authenticated/usuarios")({
- component: RouteComponent,
- loader: async () => {
- // ⚠️ Asumes service role en cliente (mejor mover a Edge Function en producción)
- const supabsaeAdmin = new SupabaseClient(
- import.meta.env.VITE_SUPABASE_URL,
- import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
- )
- const { data: data_users } = await supabsaeAdmin.auth.admin.listUsers()
- return { data: data_users.users as AdminUser[] }
- }
+ component: RouteComponent,
+ loader: async () => {
+ // ⚠️ Dev only: service role en cliente
+ const supabsaeAdmin = new SupabaseClient(
+ import.meta.env.VITE_SUPABASE_URL,
+ import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
+ )
+ const { data: data_users } = await supabsaeAdmin.auth.admin.listUsers()
+ return { data: data_users.users as AdminUser[] }
+ }
})
function RouteComponent() {
- const auth = useSupabaseAuth()
- const router = useRouter()
- const { data } = Route.useLoaderData()
- const [q, setQ] = useState("")
- const [editing, setEditing] = useState(null)
- const [saving, setSaving] = useState(false)
- // state del formulario
- const [form, setForm] = useState<{
- role?: Role;
- claims_admin?: boolean;
- nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string;
- facultad_id?: string | null;
- carrera_id?: string | null;
- }>({})
+ const auth = useSupabaseAuth()
+ const router = useRouter()
+ const { data } = Route.useLoaderData()
+ const [q, setQ] = useState("")
+ const [editing, setEditing] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const [form, setForm] = useState<{
+ role?: Role;
+ claims_admin?: boolean;
+ nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string;
+ facultad_id?: string | null;
+ 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: "" })
+ 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)
+ function genPassword() {
+ const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
+ .map(n => n.toString(36)).join("")
+ return s.slice(0, 14)
+ }
+
+
+ // NEW: helpers nombramientos
+ async function upsertNombramiento(opts: {
+ user_id: string,
+ puesto: "director_facultad" | "secretario_academico" | "jefe_carrera",
+ facultad_id?: string | null,
+ carrera_id?: string | null
+ }) {
+ // cierra vigentes del mismo scope y puesto
+ if (opts.puesto === "jefe_carrera") {
+ if (!opts.carrera_id) throw new Error("Selecciona carrera")
+ await supabase.from("nombramientos")
+ .update({ hasta: new Date().toISOString().slice(0, 10) })
+ .eq("puesto", "jefe_carrera")
+ .eq("carrera_id", opts.carrera_id)
+ .is("hasta", null)
+ } else {
+ if (!opts.facultad_id) throw new Error("Selecciona facultad")
+ await supabase.from("nombramientos")
+ .update({ hasta: new Date().toISOString().slice(0, 10) })
+ .eq("puesto", opts.puesto)
+ .eq("facultad_id", opts.facultad_id)
+ .is("hasta", null)
}
+ // inserta vigente
+ const { error } = await supabase.from("nombramientos").insert({
+ user_id: opts.user_id,
+ puesto: opts.puesto,
+ facultad_id: opts.facultad_id ?? null,
+ carrera_id: opts.carrera_id ?? null,
+ desde: new Date().toISOString().slice(0, 10),
+ hasta: null
+ })
+ if (error) throw error
+ }
- 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
- )
+ // NEW: ban/unban directo (deja que el trigger “rebalance” haga lo suyo)
+ async function toggleBan(u: AdminUser) {
+ try {
+ const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
+ const payload = banned
+ ? { banned_until: null }
+ : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
+ const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
+ if (error) throw error
+ toast.success(banned ? "Usuario desbaneado" : "Usuario baneado")
+ router.invalidate()
+ } catch (e: any) {
+ console.error(e)
+ toast.error(e?.message || "Error al cambiar estado de baneo")
+ }
+ }
- const password = createForm.password?.trim() || genPassword()
+ async function createUserNow() {
+ if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
+ try {
+ const adminClient = new SupabaseClient(
+ import.meta.env.VITE_SUPABASE_URL,
+ import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
+ )
- const { error } = await admin.auth.admin.createUser({
- email: createForm.email.trim(),
- password,
- email_confirm: true,
- 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)
+ setCreateSaving(true)
+ const password = createForm.password?.trim() || genPassword()
+ const { error, data } = await adminClient.auth.admin.createUser({
+ email: createForm.email.trim(),
+ password,
+ email_confirm: true,
+ 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
+
+ // NEW: si es rol jerárquico => crea nombramiento
+ const uid = data.user?.id
+ if (uid && createForm.role && (SCOPED_ROLES as readonly string[]).includes(createForm.role)) {
+ if (createForm.role === "director_facultad") {
+ if (!createForm.facultad_id) throw new Error("Selecciona facultad")
+ await upsertNombramiento({ user_id: uid, puesto: "director_facultad", facultad_id: createForm.facultad_id })
+ } else if (createForm.role === "secretario_academico") {
+ if (!createForm.facultad_id) throw new Error("Selecciona facultad")
+ await upsertNombramiento({ user_id: uid, puesto: "secretario_academico", facultad_id: createForm.facultad_id })
+ } else if (createForm.role === "jefe_carrera") {
+ if (!createForm.facultad_id || !createForm.carrera_id) throw new Error("Selecciona facultad y carrera")
+ await upsertNombramiento({
+ user_id: uid, puesto: "jefe_carrera",
+ facultad_id: createForm.facultad_id, carrera_id: createForm.carrera_id
+ })
+ }
+ }
+
+ 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 No tienes permisos para administrar usuarios.
+ }
+
+ const filtered = useMemo(() => {
+ const t = q.trim().toLowerCase()
+ if (!t) return data
+ return data.filter(u => {
+ const role: Role | undefined = u.app_metadata?.role
+ const label = role ? ROLE_META[role]?.label : ""
+ return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
+ .filter(Boolean)
+ .some(v => String(v).toLowerCase().includes(t))
+ })
+ }, [q, data])
+
+ function openEdit(u: AdminUser) {
+ setEditing(u)
+ setForm({
+ role: u.app_metadata?.role,
+ claims_admin: !!u.app_metadata?.claims_admin,
+ nombre: u.user_metadata?.nombre ?? "",
+ apellidos: u.user_metadata?.apellidos ?? "",
+ title: u.user_metadata?.title ?? "",
+ clave: u.user_metadata?.clave ?? "",
+ avatar: u.user_metadata?.avatar ?? "",
+ facultad_id: u.app_metadata?.facultad_id ?? null,
+ carrera_id: u.app_metadata?.carrera_id ?? null,
+ })
+ }
+
+ // NEW: validación de scope por rol antes de guardar
+ function validateScopeForSave(): string | null {
+ if (!editing) return "Sin usuario"
+ if (form.role === "director_facultad" || form.role === "secretario_academico") {
+ if (!form.facultad_id) return "Selecciona una facultad"
+ }
+ if (form.role === "jefe_carrera") {
+ if (!form.facultad_id || !form.carrera_id) return "Selecciona facultad y carrera"
+ }
+ return null
+ }
+
+ async function save() {
+ if (!editing) return
+ const scopeErr = validateScopeForSave()
+ if (scopeErr) { toast.error(scopeErr); return }
+
+ setSaving(true)
+ // 1) Actualiza metadatos via Edge Function existente (mantengo tu flujo)
+ const error = true;
+ if (error) {
+ setSaving(false)
+ console.error(error)
+ toast.error("No se pudo guardar")
+ return
}
-
- if (!auth.claims?.claims_admin) {
- return No tienes permisos para administrar usuarios.
+ try {
+ // 2) NEW: si es rol jerárquico => upsert nombramiento (dispara exclusividad + ban via triggers)
+ if (form.role && (SCOPED_ROLES as readonly string[]).includes(form.role)) {
+ if (form.role === "director_facultad") {
+ await upsertNombramiento({ user_id: editing.id, puesto: "director_facultad", facultad_id: form.facultad_id! })
+ } else if (form.role === "secretario_academico") {
+ await upsertNombramiento({ user_id: editing.id, puesto: "secretario_academico", facultad_id: form.facultad_id! })
+ } else if (form.role === "jefe_carrera") {
+ await upsertNombramiento({
+ user_id: editing.id, puesto: "jefe_carrera",
+ facultad_id: form.facultad_id!, carrera_id: form.carrera_id!
+ })
+ }
+ }
+ toast.success("Cambios guardados")
+ } catch (e: any) {
+ console.error(e)
+ toast.error(e?.message || "Error al registrar nombramiento")
+ } finally {
+ setSaving(false)
+ router.invalidate(); setEditing(null)
}
+ }
- const filtered = useMemo(() => {
- const t = q.trim().toLowerCase()
- if (!t) return data
- return data.filter(u => {
- const role: Role | undefined = u.app_metadata?.role
- const label = role ? ROLE_META[role]?.label : ""
- return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
- .filter(Boolean)
- .some(v => String(v).toLowerCase().includes(t))
- })
- }, [q, data])
+ return (
+
+
+
+ Usuarios
+
+
setQ(e.target.value)}
+ className="w-full"
+ />
+
router.invalidate()}>
+
+
+
setCreateOpen(true)} className="whitespace-nowrap">
+ Nuevo usuario
+
+
+
- function openEdit(u: AdminUser) {
- setEditing(u)
- setForm({
- role: u.app_metadata?.role,
- claims_admin: !!u.app_metadata?.claims_admin,
- nombre: u.user_metadata?.nombre ?? "",
- apellidos: u.user_metadata?.apellidos ?? "",
- title: u.user_metadata?.title ?? "",
- clave: u.user_metadata?.clave ?? "",
- avatar: u.user_metadata?.avatar ?? "",
- facultad_id: u.app_metadata?.facultad_id ?? null,
- carrera_id: u.app_metadata?.carrera_id ?? null,
- })
- }
-
- async function save() {
- if (!editing) return
- setSaving(true)
- const { error } = await supabase.functions.invoke("admin-update-user", {
- body: {
- id: editing.id,
- app_metadata: {
- role: form.role,
- claims_admin: form.claims_admin,
- facultad_id: form.facultad_id ?? null,
- carrera_id: form.carrera_id ?? null,
- },
- user_metadata: {
- nombre: form.nombre, apellidos: form.apellidos, title: form.title,
- clave: form.clave, avatar: form.avatar
- }
- }
- })
- setSaving(false)
- if (error) { console.error(error); return }
- router.invalidate(); setEditing(null)
- }
-
-
- return (
-
-
-
- Usuarios
-
-
setQ(e.target.value)}
- className="w-full"
- />
-
router.invalidate()}>
-
-
-
- {/* NUEVO: abrir modal de alta */}
-
setCreateOpen(true)} className="whitespace-nowrap">
- Nuevo usuario
-
-
-
-
-
-
- {filtered.map(u => {
- const m = u.user_metadata || {}
- const a = u.app_metadata || {}
- const roleCode: Role | undefined = a.role
- return (
-
-
-
-
-
- {/* Fila superior: nombre + chips + botón (desktop) */}
-
-
-
- {m.title ? `${m.title} ` : ""}
- {m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
-
-
-
- {roleCode && } {/* usa el pill responsivo */}
- {a.claims_admin ? (
-
- Administrador
-
- ) : (
-
- Usuario
-
- )}
-
-
-
- {/* Desktop: botón con texto */}
-
openEdit(u)}
- >
- Editar
-
-
-
- {/* Fila inferior: metadatos (wrapping) */}
-
-
- {u.email ?? "—"}
-
-
- Creado: {new Date(u.created_at).toLocaleDateString()}
-
-
- Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
-
- {m.email_verified ? (
-
- Verificado
-
- ) : (
-
- No verificado
-
- )}
-
-
-
- {/* Mobile: icon-only */}
-
openEdit(u)}
- aria-label="Editar"
- >
-
-
-
-
-
- )
- })}
- {!filtered.length && (
-
Sin usuarios
- )}
-
-
-
-
- {/* Dialog de edición */}
-
{ if (!o) setEditing(null) }}>
-
- Editar usuario
-
-
-
Nombre
-
setForm(s => ({ ...s, nombre: e.target.value }))} />
+
+
+ {filtered.map(u => {
+ const m = u.user_metadata || {}
+ const a = u.app_metadata || {}
+ const roleCode: Role | undefined = a.role
+ const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now() // NEW
+ return (
+
+
+
+
+
+
+
+ {m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
+
+
+ {roleCode && }
+ {a.claims_admin ? (
+
+ Admin
+
+ ) : (
+
+ Usuario
+
+ )}
+ {/* NEW: estado ban */}
+
+ {banned ? : } {banned ? "Baneado" : "Activo"}
+
+
-
- Apellidos
- setForm(s => ({ ...s, apellidos: e.target.value }))} />
-
-
- Título
- setForm(s => ({ ...s, title: e.target.value }))} />
-
-
- Clave
- setForm(s => ({ ...s, clave: e.target.value }))} />
-
-
-
Avatar (URL)
-
setForm(s => ({ ...s, avatar: e.target.value }))} />
+
+ {/* NEW: toggle ban/unban */}
+
toggleBan(u)}
+ title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
+ className="hidden sm:inline-flex"
+ >
+
+ {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
+
+
openEdit(u)}
+ >
+ Editar
+
+
-
- Rol
- {
- setForm(s => {
- const role = v as Role
- // limpiar/aplicar campos según rol
- if (role === "jefe_carrera") {
- return { ...s, role, /* conserva si ya venían */ 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 }
- })
- }}
- >
- {/* Hace que el popper herede ancho del trigger y no se salga */}
-
-
-
-
- {ROLES.map(code => {
- const meta = ROLE_META[code]; const Icon = meta.Icon
- return (
-
-
- {meta.label}
-
-
- )
- })}
-
-
-
-
-
- {/* Solo SECRETARIO: facultad */}
- {form.role === "secretario_academico" && (
-
-
Facultad
-
setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
- />
- Este rol solo requiere Facultad .
-
- )}
-
- {/* JEFE DE CARRERA: ambos */}
- {form.role === "jefe_carrera" && (
- < div className="grid gap-4 sm:grid-cols-2"> {/* 👈 asegura wrap en XS */}
-
- Facultad
- setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
- />
-
-
- Carrera
- setForm(s => ({ ...s, carrera_id: id }))}
- disabled={!form.facultad_id}
- />
-
-
-
- )}
-
-
-
- Permisos
- setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
-
-
- Administrador
- Usuario
-
-
-
-
-
- setEditing(null)}>Cancelar
- {saving ? "Guardando…" : "Guardar"}
-
-
-
-
- {/* Modal: Nuevo usuario */}
-
-
- Nuevo usuario
-
-
-
- Correo
- setCreateForm(s => ({ ...s, email: e.target.value }))}
- placeholder="usuario@lasalle.mx"
- />
-
-
-
-
Contraseña temporal
-
- setCreateForm(s => ({ ...s, password: e.target.value }))}
- placeholder="Se generará si la dejas vacía"
- />
- setCreateForm(s => ({ ...s, password: genPassword() }))}>
- Generar
-
- setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
- {showPwd ? : }
-
-
-
Pídeles cambiarla al iniciar sesión.
-
-
-
- Nombre
- setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
-
-
- Apellidos
- setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
-
-
- Título
- setCreateForm(s => ({ ...s, title: e.target.value }))} />
-
-
- Clave
- setCreateForm(s => ({ ...s, clave: e.target.value }))} />
-
-
- Avatar (URL)
- setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
-
-
- {/* Rol */}
-
- Rol
- {
- 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 }
- })
- }}
- >
-
-
- {ROLES.map(code => {
- const M = ROLE_META[code]; const I = M.Icon
- return (
-
- {M.label}
-
- )
- })}
-
-
-
-
- {/* SECRETARIO: Facultad */}
- {createForm.role === "secretario_academico" && (
-
- Facultad
- setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
- />
-
- )}
-
- {/* JEFE_CARRERA: Facultad + Carrera */}
- {createForm.role === "jefe_carrera" && (
-
-
- Facultad
- setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
- />
-
-
- Carrera
- setCreateForm(s => ({ ...s, carrera_id: id }))}
- disabled={!createForm.facultad_id}
- />
-
-
- )}
-
-
- Permisos
- setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
-
-
- Administrador
- Usuario
-
-
-
+
+
+ {u.email ?? "—"}
+
+
+ Creado: {new Date(u.created_at).toLocaleDateString()}
+
+
+ Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
+
+
-
- setCreateOpen(false)}>Cancelar
-
- {createSaving ? "Creando…" : "Crear usuario"}
-
-
-
-
+ {/* Mobile actions */}
+
+
toggleBan(u)} aria-label="Ban/Unban">
+
+
+
openEdit(u)} aria-label="Editar">
+
+
+
+
+
+ )
+ })}
+ {!filtered.length && (
+
Sin usuarios
+ )}
+
+
+
-
- )
+ {/* Dialog de edición */}
+
{ if (!o) setEditing(null) }}>
+
+ Editar usuario
+
+
+ Nombre
+ setForm(s => ({ ...s, nombre: e.target.value }))} />
+
+
+ Apellidos
+ setForm(s => ({ ...s, apellidos: e.target.value }))} />
+
+
+ Título
+ setForm(s => ({ ...s, title: e.target.value }))} />
+
+
+ Clave
+ setForm(s => ({ ...s, clave: e.target.value }))} />
+
+
+ Avatar (URL)
+ setForm(s => ({ ...s, avatar: e.target.value }))} />
+
+
+
+ Rol
+ {
+ setForm(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" || role === "director_facultad") {
+ return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
+ }
+ return { ...s, role, facultad_id: null, carrera_id: null }
+ })
+ }}
+ >
+
+
+
+
+ {ROLES.map(code => {
+ const meta = ROLE_META[code]; const Icon = meta.Icon
+ return (
+
+
+ {meta.label}
+
+
+ )
+ })}
+
+
+
+
+ {/* DIRECTOR/SECRETARIO: facultad */}
+ {(form.role === "secretario_academico" || form.role === "director_facultad") && (
+
+
Facultad
+
setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
+ />
+ Este rol requiere Facultad .
+
+ )}
+
+ {/* JEFE DE CARRERA: ambos */}
+ {form.role === "jefe_carrera" && (
+
+
+ Facultad
+ setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
+ />
+
+
+ Carrera
+ setForm(s => ({ ...s, carrera_id: id }))}
+ disabled={!form.facultad_id}
+ />
+
+
+ )}
+
+
+ Permisos
+ setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
+
+
+ Administrador
+ Usuario
+
+
+
+
+
+ setEditing(null)}>Cancelar
+ {saving ? "Guardando…" : "Guardar"}
+
+
+
+
+ {/* Modal: Nuevo usuario */}
+
+
+ Nuevo usuario
+
+
+
+ Correo
+ setCreateForm(s => ({ ...s, email: e.target.value }))}
+ placeholder="usuario@lasalle.mx"
+ />
+
+
+
+
Contraseña temporal
+
+ setCreateForm(s => ({ ...s, password: e.target.value }))}
+ placeholder="Se generará si la dejas vacía"
+ />
+ setCreateForm(s => ({ ...s, password: genPassword() }))}>
+ Generar
+
+ setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
+ {showPwd ? : }
+
+
+
Pídeles cambiarla al iniciar sesión.
+
+
+
+ Nombre
+ setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
+
+
+ Apellidos
+ setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
+
+
+ Título
+ setCreateForm(s => ({ ...s, title: e.target.value }))} />
+
+
+ Clave
+ setCreateForm(s => ({ ...s, clave: e.target.value }))} />
+
+
+ Avatar (URL)
+ setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
+
+
+
+ Rol
+ {
+ 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" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
+ return { ...s, role, facultad_id: null, carrera_id: null }
+ })
+ }}
+ >
+
+
+ {ROLES.map(code => {
+ const M = ROLE_META[code]; const I = M.Icon
+ return (
+
+ {M.label}
+
+ )
+ })}
+
+
+
+
+ {(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && (
+
+ Facultad
+ setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
+ />
+
+ )}
+
+ {createForm.role === "jefe_carrera" && (
+
+
+ Facultad
+ setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
+ />
+
+
+ Carrera
+ setCreateForm(s => ({ ...s, carrera_id: id }))}
+ disabled={!createForm.facultad_id}
+ />
+
+
+ )}
+
+
+ Permisos
+ setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
+
+
+ Administrador
+ Usuario
+
+
+
+
+
+
+ setCreateOpen(false)}>Cancelar
+
+ {createSaving ? "Creando…" : "Crear usuario"}
+
+
+
+
+
+
+ )
}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 58563ea..6223b4f 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,6 +1,5 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Button } from "@/components/ui/button"
-import { Card, CardContent } from "@/components/ui/card"
import { ArrowRight } from "lucide-react"
import '../App.css'
@@ -10,12 +9,12 @@ export const Route = createFileRoute('/')({
function App() {
return (
-
+
{/* Navbar */}
Génesis
-
+
Iniciar sesión
@@ -24,10 +23,10 @@ function App() {
{/* Hero */}
- Bienvenido a Génesis
+ Bienvenido a Génesis
-
- El sistema académico diseñado para transformar la gestión universitaria 🚀.
+
+ El sistema académico diseñado para transformar la gestión universitaria.
Seguro, moderno y hecho para crecer contigo.
@@ -37,30 +36,14 @@ function App() {
Comenzar
-
+
Conoce más
- {/* Highlights */}
-
- {[
- { title: "Gestión Académica", desc: "Administra planes de estudio, carreras y facultades con total control." },
- { title: "Tecnología Moderna", desc: "Construido con React, Supabase y prácticas seguras de última generación." },
- { title: "Escalable", desc: "Diseñado para crecer junto con tu institución." }
- ].map((item, i) => (
-
-
- {item.title}
- {item.desc}
-
-
- ))}
-
-
{/* Footer */}
-
+
© {new Date().getFullYear()} Génesis — Universidad La Salle