Refactor App component styles and remove highlights section
- Updated background gradient colors for a lighter theme. - Changed button styles to improve visibility. - Removed the highlights section to streamline the layout. - Adjusted text colors for better contrast and readability.
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Create TanStack App - tanstack-router</title>
|
<title>Genesis - La Salle</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"short_name": "TanStack App",
|
"short_name": "Genesis",
|
||||||
"name": "Create TanStack App Sample",
|
"name": "Genesis - La Salle",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createClient, type User } from '@supabase/supabase-js'
|
import { createClient, type User, type Session } from '@supabase/supabase-js'
|
||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
export const supabase = createClient(
|
export const supabase = createClient(
|
||||||
@@ -15,54 +15,51 @@ export interface SupabaseAuthState {
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Role =
|
||||||
|
| 'lci'
|
||||||
|
| 'vicerrectoria'
|
||||||
|
| 'director_facultad' // 👈 NEW
|
||||||
|
| 'secretario_academico'
|
||||||
|
| 'jefe_carrera'
|
||||||
|
| 'planeacion'
|
||||||
|
|
||||||
type UserClaims = {
|
type UserClaims = {
|
||||||
claims_admin: boolean,
|
claims_admin: boolean
|
||||||
clave: string,
|
clave: string
|
||||||
nombre: string,
|
nombre: string
|
||||||
apellidos: string,
|
apellidos: string
|
||||||
title: string,
|
title: string
|
||||||
avatar: string | null,
|
avatar: string | null
|
||||||
carrera_id?: string,
|
carrera_id?: string | null
|
||||||
facultad_id?: string,
|
facultad_id?: string | null
|
||||||
role: 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion',
|
facultad_color?: string | null // 🎨 NEW
|
||||||
|
role: Role
|
||||||
}
|
}
|
||||||
|
|
||||||
const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(
|
const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefined)
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
export function SupabaseAuthProvider({
|
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [claims, setClaims] = useState<UserClaims | null>(null)
|
const [claims, setClaims] = useState<UserClaims | null>(null)
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get initial session
|
// Carga inicial
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||||
setUser(session?.user ?? null)
|
const u = session?.user ?? null
|
||||||
setClaims({
|
setUser(u)
|
||||||
...(session?.user?.app_metadata as Partial<UserClaims> ?? {}),
|
setIsAuthenticated(!!u)
|
||||||
...(session?.user?.user_metadata as Partial<UserClaims> ?? {}),
|
setClaims(await buildClaims(session))
|
||||||
} as UserClaims | null)
|
|
||||||
setIsAuthenticated(!!session?.user)
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for auth changes
|
// Suscripción a cambios de sesión
|
||||||
const {
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||||
data: { subscription },
|
const u = session?.user ?? null
|
||||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
setUser(u)
|
||||||
setUser(session?.user ?? null)
|
setIsAuthenticated(!!u)
|
||||||
setClaims({
|
setClaims(await buildClaims(session))
|
||||||
...(session?.user?.app_metadata as Partial<UserClaims> ?? {}),
|
|
||||||
...(session?.user?.user_metadata as Partial<UserClaims> ?? {}),
|
|
||||||
} as UserClaims | null)
|
|
||||||
setIsAuthenticated(!!session?.user)
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,10 +67,7 @@ export function SupabaseAuthProvider({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||||
email,
|
|
||||||
password,
|
|
||||||
})
|
|
||||||
if (error?.code === 'invalid_credentials') throw new Error('Credenciales inválidas')
|
if (error?.code === 'invalid_credentials') throw new Error('Credenciales inválidas')
|
||||||
else if (error) throw error
|
else if (error) throw error
|
||||||
}
|
}
|
||||||
@@ -86,14 +80,7 @@ export function SupabaseAuthProvider({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SupabaseAuthContext.Provider
|
<SupabaseAuthContext.Provider
|
||||||
value={{
|
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
|
||||||
isAuthenticated,
|
|
||||||
user,
|
|
||||||
claims,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
isLoading,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SupabaseAuthContext.Provider>
|
</SupabaseAuthContext.Provider>
|
||||||
@@ -107,3 +94,54 @@ export function useSupabaseAuth() {
|
|||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== *
|
||||||
|
* Helpers
|
||||||
|
* ===================== */
|
||||||
|
|
||||||
|
// Unifica extracción de metadatos y resuelve facultad_color si hay facultad_id
|
||||||
|
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
|
||||||
|
const u = session?.user
|
||||||
|
if (!u) return null
|
||||||
|
|
||||||
|
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
|
||||||
|
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
|
||||||
|
|
||||||
|
// Mezcla segura: app_metadata > user_metadata (para campos de claims)
|
||||||
|
const base: Partial<UserClaims> = {
|
||||||
|
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin),
|
||||||
|
role: (app.role as Role | undefined) ?? ('lci' as Role),
|
||||||
|
facultad_id: app.facultad_id ?? meta.facultad_id ?? null,
|
||||||
|
carrera_id: app.carrera_id ?? meta.carrera_id ?? null,
|
||||||
|
clave: (meta.clave as string) ?? '',
|
||||||
|
nombre: (meta.nombre as string) ?? '',
|
||||||
|
apellidos: (meta.apellidos as string) ?? '',
|
||||||
|
title: (meta.title as string) ?? '',
|
||||||
|
avatar: (meta.avatar as string) ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let facultad_color: string | null = null
|
||||||
|
if (base.facultad_id) {
|
||||||
|
// Lee color desde public.facultades
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('facultades')
|
||||||
|
.select('color')
|
||||||
|
.eq('id', base.facultad_id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (!error && data) facultad_color = (data as any)?.color ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
claims_admin: !!base.claims_admin,
|
||||||
|
role: (base.role ?? 'lci') as Role,
|
||||||
|
clave: base.clave ?? '',
|
||||||
|
nombre: base.nombre ?? '',
|
||||||
|
apellidos: base.apellidos ?? '',
|
||||||
|
title: base.title ?? '',
|
||||||
|
avatar: base.avatar ?? null,
|
||||||
|
facultad_id: (base.facultad_id as string | null) ?? null,
|
||||||
|
carrera_id: (base.carrera_id as string | null) ?? null,
|
||||||
|
facultad_color, // 🎨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,82 +1,109 @@
|
|||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase } from "@/auth/supabase"
|
||||||
|
|
||||||
/* color helpers */
|
/* ---------- helpers de color ---------- */
|
||||||
function hexToRgb(hex?: string | null): [number, number, number] {
|
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||||
if (!hex) return [37, 99, 235]
|
if (!hex) return [37, 99, 235]
|
||||||
const h = hex.replace("#", ""); const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
|
const h = hex.replace("#", "")
|
||||||
const n = parseInt(v, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
|
||||||
|
const n = parseInt(v, 16)
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||||
}
|
}
|
||||||
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
|
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
|
||||||
|
|
||||||
/* texto con clamp */
|
/* ---------- texto expandible (acepta string o string[]) ---------- */
|
||||||
function ExpandableText({ text, mono = false }: { text?: string | null; mono?: boolean }) {
|
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
if (!text) return <span className="text-neutral-400">—</span>
|
if (!text || (Array.isArray(text) && text.length === 0)) {
|
||||||
|
return <span className="text-neutral-400">—</span>
|
||||||
|
}
|
||||||
|
const content = Array.isArray(text) ? text.join("\n• ") : text
|
||||||
|
const rendered = Array.isArray(text) ? `• ${content}` : content
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${mono ? 'font-mono whitespace-pre-wrap' : ''} text-sm ${open ? '' : 'line-clamp-10'}`}>{text}</div>
|
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>
|
||||||
{text.length > 220 && (
|
{rendered}
|
||||||
|
</div>
|
||||||
|
{String(rendered).length > 220 && (
|
||||||
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
||||||
{open ? 'Ver menos' : 'Ver más'}
|
{open ? "Ver menos" : "Ver más"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* panel con estilo */
|
/* ---------- panel con aurora mesh ---------- */
|
||||||
function SectionPanel({
|
function SectionPanel({
|
||||||
title, icon: Icon, color, children,
|
title, icon: Icon, color, children, id,
|
||||||
}: { title: string; icon: any; color?: string | null; children: React.ReactNode }) {
|
}: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
||||||
const rgb = hexToRgb(color)
|
const rgb = hexToRgb(color)
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl border backdrop-blur shadow-sm overflow-hidden">
|
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
|
||||||
<div className="px-4 py-3 flex items-center gap-2"
|
{/* aurora mesh sutil */}
|
||||||
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}>
|
<div className="pointer-events-none absolute inset-0 -z-0">
|
||||||
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2"
|
<div
|
||||||
style={{ borderColor: rgba(rgb, .25), background: "rgba(255,255,255,.75)" }}>
|
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
|
||||||
|
style={{ background: `radial-gradient(circle, ${rgba(rgb, .20)}, transparent 60%)` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
|
||||||
|
style={{ background: `radial-gradient(circle, ${rgba(rgb, .14)}, transparent 60%)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
|
||||||
|
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
|
||||||
|
style={{ borderColor: rgba(rgb, .25) }}
|
||||||
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<h3 className="font-semibold">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">{children}</div>
|
<div className="relative z-10 p-5">{children}</div>
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- TABS + EDIT DIALOG ---------- */
|
/* ---------- Secciones integradas (sin tabs) ---------- */
|
||||||
type PlanTextFields = {
|
type PlanTextFields = {
|
||||||
objetivo_general?: string | null; sistema_evaluacion?: string | null;
|
objetivo_general?: string | string[] | null
|
||||||
perfil_ingreso?: string | null; perfil_egreso?: string | null;
|
sistema_evaluacion?: string | string[] | null
|
||||||
competencias_genericas?: string | null; competencias_especificas?: string | null;
|
perfil_ingreso?: string | string[] | null
|
||||||
indicadores_desempeno?: string | null; pertinencia?: string | null; prompt?: string | null;
|
perfil_egreso?: string | string[] | null
|
||||||
|
competencias_genericas?: string | string[] | null
|
||||||
|
competencias_especificas?: string | string[] | null
|
||||||
|
indicadores_desempeno?: string | string[] | null
|
||||||
|
pertinencia?: string | string[] | null
|
||||||
|
prompt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AcademicSections({
|
export function AcademicSections({
|
||||||
planId, plan, color,
|
planId, plan, color,
|
||||||
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
|
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
|
||||||
// estado local editable
|
|
||||||
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
|
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
|
||||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||||
const [draft, setDraft] = useState("")
|
const [draft, setDraft] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const sections = useMemo(() => [
|
const sections = useMemo(() => [
|
||||||
{ id: "obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
||||||
{ id: "eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
||||||
{ id: "ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
||||||
{ id: "egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
||||||
{ id: "cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
||||||
{ id: "ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
||||||
{ id: "ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
||||||
{ id: "pert", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
||||||
{ id: "prompt", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
||||||
], [])
|
], [])
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -96,50 +123,55 @@ export function AcademicSections({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue={sections[0].id} className="w-full">
|
|
||||||
{/* nav sticky con píldoras scrollables */}
|
|
||||||
<div className="sticky top-0 z-10 backdrop-blur border-b">
|
|
||||||
<TabsList className="w-full flex gap-2 p-3 scrollbar-none">
|
|
||||||
{sections.map(s => (
|
|
||||||
<TabsTrigger key={s.id} value={s.id}
|
|
||||||
className="data-[state=active]:bg-primary data-[state=active]:text-white p-3 text-xs">
|
|
||||||
{s.title}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* contenido */}
|
{/* Todas las tarjetas visibles */}
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
{sections.map(s => {
|
{sections.map(s => {
|
||||||
const text = local[s.key] ?? null
|
const text = local[s.key] ?? null
|
||||||
return (
|
return (
|
||||||
<TabsContent key={s.id} value={s.id} className="mt-4">
|
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||||
<SectionPanel title={s.title} icon={s.icon} color={color}>
|
|
||||||
<ExpandableText text={text} mono={s.mono} />
|
<ExpandableText text={text} mono={s.mono} />
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={!text}
|
<Button
|
||||||
onClick={() => text && navigator.clipboard.writeText(text)}>Copiar</Button>
|
variant="outline"
|
||||||
<Button variant="ghost" size="sm"
|
size="sm"
|
||||||
onClick={() => { setEditing({ key: s.key, title: s.title }); setDraft(text ?? "") }}>
|
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||||
|
onClick={() => {
|
||||||
|
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
|
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copiar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
|
setEditing({ key: s.key, title: s.title })
|
||||||
|
setDraft(current)
|
||||||
|
}}
|
||||||
|
>
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
</TabsContent>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Tabs>
|
</div>
|
||||||
|
|
||||||
{/* Dialog de edición */}
|
{/* Diálogo de edición */}
|
||||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
className={`min-h-[260px] ${editing?.key === 'prompt' ? 'font-mono' : ''}`}
|
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
||||||
placeholder="Escribe aquí…"
|
placeholder="Escribe aquí…"
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const Route = createRootRouteWithContext<AuthContext>()({
|
|||||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanstackDevtools
|
{/* <TanstackDevtools
|
||||||
config={{
|
config={{
|
||||||
position: 'bottom-left',
|
position: 'bottom-left',
|
||||||
}}
|
}}
|
||||||
@@ -24,7 +24,7 @@ export const Route = createRootRouteWithContext<AuthContext>()({
|
|||||||
render: <TanStackRouterDevtoolsPanel />,
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/> */}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -229,39 +229,37 @@ function Page() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NUEVO: botón de edición */}
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<EditContenidosButton
|
||||||
|
asignaturaId={a.id}
|
||||||
|
value={a.contenidos as any}
|
||||||
|
onSaved={(contenidos) => setA(s => ({ ...s, contenidos }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* …tu render flexible existente… */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// --- helpers de normalización ---
|
// helpers de normalización (como ya los tienes)
|
||||||
const titleOf = (u: any): string => {
|
const titleOf = (u: any): string => {
|
||||||
// Si viene como arreglo [{titulo, seccion, subtemas}], u.temas tendrá pares [ 'titulo' , '…' ]
|
|
||||||
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
||||||
if (typeof t === "string" && t.trim()) return t
|
if (typeof t === "string" && t.trim()) return t
|
||||||
// Fallback: si la clave de la unidad es numérica, usa "Unidad N" o el título ya calculado
|
|
||||||
return /^\s*\d+/.test(String(u.key))
|
return /^\s*\d+/.test(String(u.key))
|
||||||
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key ? u.key : 1}`)
|
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key ? u.key : 1}`)
|
||||||
: (u.title || String(u.key))
|
: (u.title || String(u.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
const temasOf = (u: any): string[] => {
|
const temasOf = (u: any): string[] => {
|
||||||
const pairs: any[] = Array.isArray(u.temas) ? u.temas : []
|
const pairs: any[] = Array.isArray(u.temas) ? u.temas : []
|
||||||
// 1) Estructura con subtemas
|
|
||||||
const sub = pairs.find(([k]) => String(k).toLowerCase() === "subtemas")?.[1]
|
const sub = pairs.find(([k]) => String(k).toLowerCase() === "subtemas")?.[1]
|
||||||
if (Array.isArray(sub)) {
|
if (Array.isArray(sub)) return sub.map(String)
|
||||||
// subtemas: ["t1", "t2", ...]
|
|
||||||
return sub.map(String)
|
|
||||||
}
|
|
||||||
if (sub && typeof sub === "object") {
|
if (sub && typeof sub === "object") {
|
||||||
// subtemas: { "1": "t1", "2": "t2", ... }
|
return Object.entries(sub).sort(([a], [b]) => Number(a) - Number(b)).map(([, v]) => String(v))
|
||||||
return Object.entries(sub)
|
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
|
||||||
.map(([, v]) => String(v))
|
|
||||||
}
|
}
|
||||||
// 2) Estructura plana numerada { "1": "t1", "2": "t2", ... }
|
|
||||||
const numerados = pairs
|
const numerados = pairs
|
||||||
.filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
.filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
.map(([, v]) => String(v))
|
.map(([, v]) => String(v))
|
||||||
if (numerados.length) return numerados
|
if (numerados.length) return numerados
|
||||||
// 3) Fallback: toma valores string excepto metadatos
|
|
||||||
return pairs
|
return pairs
|
||||||
.filter(([k, v]) => !["titulo", "seccion"].includes(String(k).toLowerCase()) && typeof v === "string")
|
.filter(([k, v]) => !["titulo", "seccion"].includes(String(k).toLowerCase()) && typeof v === "string")
|
||||||
.map(([, v]) => String(v))
|
.map(([, v]) => String(v))
|
||||||
@@ -300,11 +298,8 @@ function Page() {
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!visible.length && (
|
{!visible.length && (
|
||||||
<div className="text-sm text-neutral-500 py-6 text-center">
|
<div className="text-sm text-neutral-500 py-6 text-center">No hay temas que coincidan.</div>
|
||||||
No hay temas que coincidan.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
)
|
||||||
@@ -314,8 +309,16 @@ function Page() {
|
|||||||
|
|
||||||
|
|
||||||
{/* Bibliografía */}
|
{/* Bibliografía */}
|
||||||
{a.bibliografia && a.bibliografia.length > 0 && (
|
|
||||||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<EditBibliografiaButton
|
||||||
|
asignaturaId={a.id}
|
||||||
|
value={a.bibliografia ?? []}
|
||||||
|
onSaved={(bibliografia) => setA(s => ({ ...s, bibliografia }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.bibliografia && a.bibliografia.length > 0 ? (
|
||||||
<ul className="space-y-2 text-sm text-neutral-800">
|
<ul className="space-y-2 text-sm text-neutral-800">
|
||||||
{a.bibliografia.map((ref, i) => (
|
{a.bibliografia.map((ref, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 leading-relaxed">
|
<li key={i} className="flex items-start gap-2 leading-relaxed">
|
||||||
@@ -324,8 +327,11 @@ function Page() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Section>
|
) : (
|
||||||
|
<div className="text-sm text-neutral-500">Sin bibliografía.</div>
|
||||||
)}
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
|
||||||
{/* Evaluación */}
|
{/* Evaluación */}
|
||||||
{a.criterios_evaluacion && (
|
{a.criterios_evaluacion && (
|
||||||
@@ -557,3 +563,213 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function EditContenidosButton({
|
||||||
|
asignaturaId,
|
||||||
|
value,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
asignaturaId: string
|
||||||
|
value: any
|
||||||
|
onSaved: (contenidos: any) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
type UnitDraft = { title: string; temas: string[] }
|
||||||
|
const [units, setUnits] = useState<UnitDraft[]>([])
|
||||||
|
|
||||||
|
// Normaliza el JSON (acepta estructuras flexibles)
|
||||||
|
function normalize(v: any): UnitDraft[] {
|
||||||
|
try {
|
||||||
|
const entries = Object.entries(v ?? {})
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([key, val]) => {
|
||||||
|
const obj = val as any
|
||||||
|
// soporta: { titulo, subtemas:{ "1":"t1" } } | { "1":"t1" } | ["t1","t2"]
|
||||||
|
const title = (typeof obj?.titulo === 'string' && obj.titulo.trim()) ? obj.titulo.trim() : `Unidad ${key}`
|
||||||
|
let temas: string[] = []
|
||||||
|
if (Array.isArray(obj?.subtemas)) temas = obj.subtemas.map(String)
|
||||||
|
else if (obj?.subtemas && typeof obj.subtemas === 'object') {
|
||||||
|
temas = Object.entries(obj.subtemas).sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t))
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
temas = obj.map(String)
|
||||||
|
} else if (obj && typeof obj === 'object') {
|
||||||
|
const nums = Object.entries(obj).filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
||||||
|
if (nums.length) temas = nums.sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t))
|
||||||
|
}
|
||||||
|
return { title, temas }
|
||||||
|
})
|
||||||
|
return entries.length ? entries : [{ title: 'Unidad 1', temas: [] }]
|
||||||
|
} catch {
|
||||||
|
return [{ title: 'Unidad 1', temas: [] }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construye un JSON estable para guardar
|
||||||
|
function buildPayload(us: UnitDraft[]) {
|
||||||
|
const out: any = {}
|
||||||
|
us.forEach((u, idx) => {
|
||||||
|
const k = String(idx + 1)
|
||||||
|
const sub: any = {}
|
||||||
|
u.temas.filter(t => t.trim()).forEach((t, i) => { sub[String(i + 1)] = t.trim() })
|
||||||
|
out[k] = { titulo: u.title.trim() || `Unidad ${k}`, subtemas: sub }
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor() {
|
||||||
|
setUnits(normalize(value))
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
const contenidos = buildPayload(units)
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.update({ contenidos })
|
||||||
|
.eq('id', asignaturaId)
|
||||||
|
.select()
|
||||||
|
.maybeSingle()
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { alert(error.message || 'No se pudo guardar'); return }
|
||||||
|
onSaved((data as any)?.contenidos ?? contenidos)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||||
|
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar contenidos
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar contenidos</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Títulos de unidad y temas (un tema por línea). Se guardará en un formato consistente con <code>titulo</code> y <code>subtemas</code>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[60vh] overflow-auto pr-1">
|
||||||
|
{units.map((u, i) => (
|
||||||
|
<div key={i} className="rounded-2xl border p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-medium text-sm">Unidad {i + 1}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Eliminar unidad"
|
||||||
|
onClick={() => setUnits(prev => prev.filter((_, idx) => idx !== i))}
|
||||||
|
>
|
||||||
|
<Icons.Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Título</Label>
|
||||||
|
<Input
|
||||||
|
value={u.title}
|
||||||
|
onChange={(e) => setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, title: e.target.value } : uu))}
|
||||||
|
placeholder={`Unidad ${i + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Temas (uno por línea)</Label>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[120px]"
|
||||||
|
value={u.temas.join('\n')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const lines = e.target.value.split('\n')
|
||||||
|
setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, temas: lines } : uu))
|
||||||
|
}}
|
||||||
|
placeholder={`Tema 1\nTema 2\n…`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setUnits(prev => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])}
|
||||||
|
>
|
||||||
|
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditBibliografiaButton({
|
||||||
|
asignaturaId,
|
||||||
|
value,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
asignaturaId: string
|
||||||
|
value: string[]
|
||||||
|
onSaved: (refs: string[]) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
|
||||||
|
function openEditor() {
|
||||||
|
setText((value ?? []).join('\n'))
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
const refs = text.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.update({ bibliografia: refs })
|
||||||
|
.eq('id', asignaturaId)
|
||||||
|
.select()
|
||||||
|
.maybeSingle()
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { alert(error.message || 'No se pudo guardar'); return }
|
||||||
|
onSaved((data as any)?.bibliografia ?? refs)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||||
|
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar bibliografía
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar bibliografía</DialogTitle>
|
||||||
|
<DialogDescription>Escribe una referencia por línea.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[260px]"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||||
|
// NEW
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
/* ================== Tipos ================== */
|
/* ================== Tipos ================== */
|
||||||
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
||||||
@@ -27,17 +31,19 @@ type Asignatura = {
|
|||||||
criterios_evaluacion: string | null
|
criterios_evaluacion: string | null
|
||||||
fecha_creacion: string | null
|
fecha_creacion: string | null
|
||||||
plan: PlanMini | null
|
plan: PlanMini | null
|
||||||
|
// NEW: plan_id base (lo traemos en el SELECT)
|
||||||
|
plan_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoaderData = {
|
type LoaderData = {
|
||||||
asignaturas: Asignatura[]
|
asignaturas: Asignatura[]
|
||||||
|
planes: PlanMini[] // NEW: para elegir destino
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================== Ruta ================== */
|
/* ================== Ruta ================== */
|
||||||
export const Route = createFileRoute('/_authenticated/asignaturas')({
|
export const Route = createFileRoute('/_authenticated/asignaturas')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
pendingComponent: PageSkeleton,
|
pendingComponent: PageSkeleton,
|
||||||
// Podemos filtrar por planId/carreraId/facultadId desde la URL si se envían
|
|
||||||
validateSearch: (search: Record<string, unknown>) => {
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
return {
|
return {
|
||||||
q: (search.q as string) ?? '',
|
q: (search.q as string) ?? '',
|
||||||
@@ -48,7 +54,6 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
loader: async (ctx): Promise<LoaderData> => {
|
loader: async (ctx): Promise<LoaderData> => {
|
||||||
// TanStack: el search vive en ctx.location.search
|
|
||||||
const search = (ctx.location?.search ?? {}) as {
|
const search = (ctx.location?.search ?? {}) as {
|
||||||
q?: string
|
q?: string
|
||||||
planId?: string
|
planId?: string
|
||||||
@@ -58,8 +63,6 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { planId, carreraId, facultadId } = search
|
const { planId, carreraId, facultadId } = search
|
||||||
|
|
||||||
// Resolver alcance por IDs opcionales (para filtrar antes de traer asignaturas)
|
|
||||||
let planIds: string[] | null = null
|
let planIds: string[] | null = null
|
||||||
|
|
||||||
if (planId) {
|
if (planId) {
|
||||||
@@ -78,27 +81,22 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
|
|||||||
.eq('facultad_id', facultadId)
|
.eq('facultad_id', facultadId)
|
||||||
if (carErr) throw carErr
|
if (carErr) throw carErr
|
||||||
const cIds = (carreras ?? []).map(c => c.id)
|
const cIds = (carreras ?? []).map(c => c.id)
|
||||||
|
|
||||||
if (!cIds.length) {
|
if (!cIds.length) {
|
||||||
// No hay carreras en la facultad ⇒ no hay asignaturas
|
return { asignaturas: [], planes: [] }
|
||||||
return { asignaturas: [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: planesFac, error: plaErr } = await supabase
|
const { data: planesFac, error: plaErr } = await supabase
|
||||||
.from('plan_estudios')
|
.from('plan_estudios')
|
||||||
.select('id')
|
.select('id, nombre, carrera:carreras(id, nombre, facultad:facultades(id, nombre, color, icon))')
|
||||||
.in('carrera_id', cIds)
|
.in('carrera_id', cIds)
|
||||||
if (plaErr) throw plaErr
|
if (plaErr) throw plaErr
|
||||||
|
|
||||||
planIds = (planesFac ?? []).map(p => p.id)
|
planIds = (planesFac ?? []).map(p => p.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si sabemos que no habrá resultados, evitamos pegarle a Supabase
|
|
||||||
if (planIds && planIds.length === 0) {
|
if (planIds && planIds.length === 0) {
|
||||||
return { asignaturas: [] }
|
return { asignaturas: [], planes: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traer asignaturas + contexto de plan/carrera/facultad
|
// Traer asignaturas
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -114,33 +112,68 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
|
|||||||
`)
|
`)
|
||||||
.order('semestre', { ascending: true })
|
.order('semestre', { ascending: true })
|
||||||
.order('nombre', { ascending: true })
|
.order('nombre', { ascending: true })
|
||||||
|
if (planIds) query = query.in('plan_id', planIds)
|
||||||
if (planIds) {
|
|
||||||
query = query.in('plan_id', planIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error: aErr } = await query
|
const { data, error: aErr } = await query
|
||||||
if (aErr) throw aErr
|
if (aErr) throw aErr
|
||||||
|
|
||||||
return { asignaturas: (data ?? []) as unknown as Asignatura[] }
|
// Traer planes (para selector destino)
|
||||||
},
|
const { data: planesAll, error: pErr } = await supabase
|
||||||
|
.from('plan_estudios')
|
||||||
|
.select(`
|
||||||
|
id, nombre,
|
||||||
|
carrera:carreras(
|
||||||
|
id, nombre,
|
||||||
|
facultad:facultades(id, nombre, color, icon)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('nombre', { ascending: true })
|
||||||
|
if (pErr) throw pErr
|
||||||
|
|
||||||
|
return {
|
||||||
|
asignaturas: (data ?? []) as unknown as Asignatura[],
|
||||||
|
planes: (planesAll ?? []) as unknown as PlanMini[],
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ================== Página ================== */
|
/* ================== Página ================== */
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { asignaturas } = Route.useLoaderData() as LoaderData
|
const { asignaturas, planes } = Route.useLoaderData() as LoaderData
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' }
|
const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' }
|
||||||
|
|
||||||
// Estado de filtros locales (arrancan con la URL)
|
// Filtros
|
||||||
const [q, setQ] = useState(search.q ?? '')
|
const [q, setQ] = useState(search.q ?? '')
|
||||||
const [sem, setSem] = useState<string>('todos')
|
const [sem, setSem] = useState<string>('todos')
|
||||||
const [tipo, setTipo] = useState<string>('todos')
|
const [tipo, setTipo] = useState<string>('todos')
|
||||||
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
||||||
|
|
||||||
// Valores de selects
|
// NEW: Clonado individual
|
||||||
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
|
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
||||||
|
const [cloneForm, setCloneForm] = useState<{
|
||||||
|
nombre?: string
|
||||||
|
clave?: string | null
|
||||||
|
tipo?: string | null
|
||||||
|
semestre?: number | null
|
||||||
|
creditos?: number | null
|
||||||
|
horas_teoricas?: number | null
|
||||||
|
horas_practicas?: number | null
|
||||||
|
plan_destino_id?: string | ''
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
// NEW: Carrito
|
||||||
|
const [cart, setCart] = useState<Asignatura[]>([])
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
|
const [bulk, setBulk] = useState<{
|
||||||
|
plan_destino_id?: string | ''
|
||||||
|
semestre?: string
|
||||||
|
creditos?: string
|
||||||
|
tipo?: string
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
// Valores selects
|
||||||
const semestres = useMemo(() => {
|
const semestres = useMemo(() => {
|
||||||
const s = new Set<string>()
|
const s = new Set<string>()
|
||||||
asignaturas.forEach(a => s.add(String(a.semestre ?? '—')))
|
asignaturas.forEach(a => s.add(String(a.semestre ?? '—')))
|
||||||
@@ -153,13 +186,13 @@ function RouteComponent() {
|
|||||||
return Array.from(s).sort()
|
return Array.from(s).sort()
|
||||||
}, [asignaturas])
|
}, [asignaturas])
|
||||||
|
|
||||||
// Salud (contadores)
|
// Salud
|
||||||
const salud = useMemo(() => {
|
const salud = useMemo(() => {
|
||||||
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
|
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
|
||||||
for (const a of asignaturas) {
|
for (const a of asignaturas) {
|
||||||
if (!a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)) sinBibliografia++
|
if (!a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)) sinBibliografia++
|
||||||
if (!a.criterios_evaluacion || !a.criterios_evaluacion.trim()) sinCriterios++
|
if (!a.criterios_evaluacion || !a.criterios_evaluacion.trim()) sinCriterios++
|
||||||
if (!a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)) sinContenidos++
|
if (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0) sinContenidos++
|
||||||
}
|
}
|
||||||
return { sinBibliografia, sinCriterios, sinContenidos }
|
return { sinBibliografia, sinCriterios, sinContenidos }
|
||||||
}, [asignaturas])
|
}, [asignaturas])
|
||||||
@@ -206,6 +239,97 @@ function RouteComponent() {
|
|||||||
// Helpers
|
// Helpers
|
||||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
|
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
|
||||||
|
|
||||||
|
// NEW: util para clonar 1 asignatura
|
||||||
|
async function cloneOne(src: Asignatura, overrides: {
|
||||||
|
plan_destino_id: string
|
||||||
|
nombre?: string
|
||||||
|
clave?: string | null
|
||||||
|
tipo?: string | null
|
||||||
|
semestre?: number | null
|
||||||
|
creditos?: number | null
|
||||||
|
horas_teoricas?: number | null
|
||||||
|
horas_practicas?: number | null
|
||||||
|
}) {
|
||||||
|
if (!overrides.plan_destino_id) throw new Error('Selecciona un plan destino')
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
plan_id: overrides.plan_destino_id,
|
||||||
|
nombre: overrides.nombre ?? src.nombre,
|
||||||
|
clave: overrides.clave ?? src.clave,
|
||||||
|
tipo: overrides.tipo ?? src.tipo,
|
||||||
|
semestre: overrides.semestre ?? src.semestre,
|
||||||
|
creditos: overrides.creditos ?? src.creditos,
|
||||||
|
horas_teoricas: overrides.horas_teoricas ?? src.horas_teoricas,
|
||||||
|
horas_practicas: overrides.horas_practicas ?? src.horas_practicas,
|
||||||
|
objetivos: src.objetivos,
|
||||||
|
contenidos: src.contenidos,
|
||||||
|
bibliografia: src.bibliografia,
|
||||||
|
criterios_evaluacion: src.criterios_evaluacion,
|
||||||
|
estado: 'activo',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from('asignaturas').insert(payload)
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: abrir modal clon individual
|
||||||
|
function openClone(a: Asignatura) {
|
||||||
|
setCloneTarget(a)
|
||||||
|
setCloneForm({
|
||||||
|
nombre: a.nombre,
|
||||||
|
clave: a.clave,
|
||||||
|
tipo: a.tipo ?? '',
|
||||||
|
semestre: a.semestre ?? undefined,
|
||||||
|
creditos: a.creditos ?? undefined,
|
||||||
|
horas_teoricas: a.horas_teoricas ?? undefined,
|
||||||
|
horas_practicas: a.horas_practicas ?? undefined,
|
||||||
|
plan_destino_id: a.plan?.id ?? '',
|
||||||
|
})
|
||||||
|
setCloneOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: acciones carrito
|
||||||
|
function addToCart(a: Asignatura) {
|
||||||
|
setCart(prev => prev.find(x => x.id === a.id) ? prev : [...prev, a])
|
||||||
|
toast.success('Asignatura añadida al carrito')
|
||||||
|
}
|
||||||
|
function removeFromCart(id: string) {
|
||||||
|
setCart(prev => prev.filter(x => x.id !== id))
|
||||||
|
}
|
||||||
|
function clearCart() {
|
||||||
|
setCart([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: clonado en lote
|
||||||
|
async function cloneBulk() {
|
||||||
|
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
|
||||||
|
if (!cart.length) { toast.error('Carrito vacío'); return }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const common: Partial<Asignatura> = {
|
||||||
|
tipo: bulk.tipo && bulk.tipo !== '—' ? bulk.tipo : undefined,
|
||||||
|
semestre: bulk.semestre ? Number(bulk.semestre) : undefined,
|
||||||
|
creditos: bulk.creditos ? Number(bulk.creditos) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of cart) {
|
||||||
|
await cloneOne(a, {
|
||||||
|
plan_destino_id: bulk.plan_destino_id!,
|
||||||
|
tipo: common.tipo ?? undefined,
|
||||||
|
semestre: common.semestre ?? undefined,
|
||||||
|
creditos: common.creditos ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success(`Clonadas ${cart.length} asignaturas`)
|
||||||
|
setBulkOpen(false)
|
||||||
|
clearCart()
|
||||||
|
router.invalidate()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error(e?.message || 'No se pudieron clonar')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
@@ -217,7 +341,6 @@ function RouteComponent() {
|
|||||||
Asignaturas
|
Asignaturas
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Crear nueva — puedes cambiar el destino si ya tienes ruta específica */}
|
|
||||||
<Link
|
<Link
|
||||||
to="/planes"
|
to="/planes"
|
||||||
className="hidden sm:inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
className="hidden sm:inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||||
@@ -225,6 +348,20 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<Icons.Plus className="w-4 h-4" /> Nueva asignatura
|
<Icons.Plus className="w-4 h-4" /> Nueva asignatura
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* NEW: botón carrito */}
|
||||||
|
<Button
|
||||||
|
variant={cart.length ? 'default' : 'outline'}
|
||||||
|
onClick={() => setBulkOpen(true)}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Icons.ShoppingCart className="w-4 h-4 mr-2" />
|
||||||
|
Carrito
|
||||||
|
{cart.length > 0 && (
|
||||||
|
<span className="ml-2 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-white/90 text-[11px] text-neutral-900 px-1">
|
||||||
|
{cart.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||||
<Icons.RefreshCcw className="w-4 h-4" />
|
<Icons.RefreshCcw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -262,7 +399,7 @@ function RouteComponent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chips de salud (toggle) */}
|
{/* Chips de salud */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<HealthChip
|
<HealthChip
|
||||||
active={flag === 'sinBibliografia'}
|
active={flag === 'sinBibliografia'}
|
||||||
@@ -306,11 +443,194 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
|
{items.map(a => <AsignaturaCard key={a.id} a={a} onClone={() => openClone(a)} onAddToCart={() => addToCart(a)} />)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NEW: Modal clonado individual */}
|
||||||
|
<Dialog open={cloneOpen} onOpenChange={setCloneOpen}>
|
||||||
|
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clonar asignatura</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{cloneTarget && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="text-xs text-neutral-600">
|
||||||
|
Origen: <strong>{cloneTarget.nombre}</strong> {cloneTarget.plan?.nombre ? `· ${cloneTarget.plan?.nombre}` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={cloneForm.nombre ?? ''} onChange={e => setCloneForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Clave</Label>
|
||||||
|
<Input value={cloneForm.clave ?? ''} onChange={e => setCloneForm(s => ({ ...s, clave: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Input value={cloneForm.tipo ?? ''} onChange={e => setCloneForm(s => ({ ...s, tipo: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Créditos</Label>
|
||||||
|
<Input type="number" value={cloneForm.creditos ?? ''} onChange={e => setCloneForm(s => ({ ...s, creditos: e.target.value ? Number(e.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Semestre</Label>
|
||||||
|
<Input type="number" value={cloneForm.semestre ?? ''} onChange={e => setCloneForm(s => ({ ...s, semestre: e.target.value ? Number(e.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>Horas T</Label>
|
||||||
|
<Input type="number" value={cloneForm.horas_teoricas ?? ''} onChange={e => setCloneForm(s => ({ ...s, horas_teoricas: e.target.value ? Number(e.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Horas P</Label>
|
||||||
|
<Input type="number" value={cloneForm.horas_practicas ?? ''} onChange={e => setCloneForm(s => ({ ...s, horas_practicas: e.target.value ? Number(e.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label>Plan destino</Label>
|
||||||
|
<Select value={cloneForm.plan_destino_id ?? ''} onValueChange={(v) => setCloneForm(s => ({ ...s, plan_destino_id: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Selecciona plan de estudios" /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{planes.map(p => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.nombre} {p.carrera ? `· ${p.carrera.nombre}` : ''} {p.carrera?.facultad ? `· ${p.carrera.facultad.nombre}` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCloneOpen(false)}>Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!cloneTarget) return
|
||||||
|
try {
|
||||||
|
await cloneOne(cloneTarget, {
|
||||||
|
plan_destino_id: (cloneForm.plan_destino_id as string) || '',
|
||||||
|
nombre: cloneForm.nombre,
|
||||||
|
clave: cloneForm.clave ?? null,
|
||||||
|
tipo: cloneForm.tipo ?? null,
|
||||||
|
semestre: (cloneForm.semestre as number) ?? null,
|
||||||
|
creditos: (cloneForm.creditos as number) ?? null,
|
||||||
|
horas_teoricas: (cloneForm.horas_teoricas as number) ?? null,
|
||||||
|
horas_practicas: (cloneForm.horas_practicas as number) ?? null,
|
||||||
|
})
|
||||||
|
toast.success('Asignatura clonada')
|
||||||
|
setCloneOpen(false)
|
||||||
|
router.invalidate()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error(e?.message || 'No se pudo clonar')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clonar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* NEW: Modal carrito */}
|
||||||
|
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||||
|
<DialogContent className="w-[min(92vw,840px)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Carrito de asignaturas ({cart.length})</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{cart.length === 0 ? (
|
||||||
|
<div className="text-sm text-neutral-500">No has añadido asignaturas. Usa el menú “…” de cada tarjeta.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border">
|
||||||
|
<div className="grid grid-cols-12 px-3 py-2 text-[11px] font-medium text-neutral-600">
|
||||||
|
<div className="col-span-5">Asignatura</div>
|
||||||
|
<div className="col-span-2">Créditos</div>
|
||||||
|
<div className="col-span-2">Semestre</div>
|
||||||
|
<div className="col-span-2">Tipo</div>
|
||||||
|
<div className="col-span-1 text-right">—</div>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{cart.map(a => (
|
||||||
|
<li key={a.id} className="grid grid-cols-12 items-center px-3 py-2 text-sm">
|
||||||
|
<div className="col-span-5">
|
||||||
|
<div className="font-medium">{a.nombre}</div>
|
||||||
|
<div className="text-[11px] text-neutral-500">{a.plan?.nombre} · {a.plan?.carrera?.nombre}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">{a.creditos ?? '—'}</div>
|
||||||
|
<div className="col-span-2">{a.semestre ?? '—'}</div>
|
||||||
|
<div className="col-span-2">{a.tipo ?? '—'}</div>
|
||||||
|
<div className="col-span-1 text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => removeFromCart(a.id)}><Icons.X className="w-4 h-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>Plan de estudios destino</Label>
|
||||||
|
<Select value={bulk.plan_destino_id ?? ''} onValueChange={(v) => setBulk(s => ({ ...s, plan_destino_id: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Selecciona plan" /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{planes.map(p => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.nombre} {p.carrera ? `· ${p.carrera.nombre}` : ''} {p.carrera?.facultad ? `· ${p.carrera.facultad.nombre}` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Créditos (común)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="—"
|
||||||
|
value={bulk.creditos ?? ''}
|
||||||
|
onChange={e => setBulk(s => ({ ...s, creditos: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Semestre (común)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="—"
|
||||||
|
value={bulk.semestre ?? ''}
|
||||||
|
onChange={e => setBulk(s => ({ ...s, semestre: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tipo (común)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Obligatoria / Optativa…"
|
||||||
|
value={bulk.tipo ?? ''}
|
||||||
|
onChange={e => setBulk(s => ({ ...s, tipo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" onClick={clearCart}><Icons.Trash2 className="w-4 h-4 mr-1" /> Vaciar carrito</Button>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button variant="outline" onClick={() => setBulkOpen(false)}>Cerrar</Button>
|
||||||
|
<Button onClick={cloneBulk}><Icons.CopyPlus className="w-4 h-4 mr-1" /> Clonar en lote</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,7 +648,7 @@ function Chip({ children, className = '' }: { children: React.ReactNode; classNa
|
|||||||
return <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] ${className}`}>{children}</span>
|
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 }) {
|
function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: () => void; onAddToCart: () => void }) {
|
||||||
const horasT = a.horas_teoricas ?? 0
|
const horasT = a.horas_teoricas ?? 0
|
||||||
const horasP = a.horas_practicas ?? 0
|
const horasP = a.horas_practicas ?? 0
|
||||||
const meta = tipoMeta(a.tipo)
|
const meta = tipoMeta(a.tipo)
|
||||||
@@ -345,12 +665,12 @@ function AsignaturaCard({ a }: { a: Asignatura }) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
||||||
{/* Menú rápido (placeholder extensible) */}
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="mt-[-2px]"><Icons.MoreVertical className="w-4 h-4" /></Button>
|
<Button variant="ghost" size="icon" className="mt-[-2px]"><Icons.MoreVertical className="w-4 h-4" /></Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||||
<DropdownMenuItem asChild className="gap-2">
|
<DropdownMenuItem asChild className="gap-2">
|
||||||
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
|
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
|
||||||
<Icons.FolderOpen className="w-4 h-4" /> Abrir
|
<Icons.FolderOpen className="w-4 h-4" /> Abrir
|
||||||
@@ -361,6 +681,13 @@ function AsignaturaCard({ a }: { a: Asignatura }) {
|
|||||||
<Icons.ScrollText className="w-4 h-4" /> Ver plan
|
<Icons.ScrollText className="w-4 h-4" /> Ver plan
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{/* NEW */}
|
||||||
|
<DropdownMenuItem className="gap-2" onClick={onClone}>
|
||||||
|
<Icons.Copy className="w-4 h-4" /> Clonar…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="gap-2" onClick={onAddToCart}>
|
||||||
|
<Icons.ShoppingCart className="w-4 h-4" /> Añadir al carrito
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,15 +700,14 @@ function AsignaturaCard({ a }: { a: Asignatura }) {
|
|||||||
<Chip className="bg-white/70"><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? '—'}</Chip>
|
<Chip className="bg-white/70"><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? '—'}</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contexto del plan/carrera/facultad */}
|
|
||||||
{a.plan && (
|
{a.plan && (
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-neutral-600">
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-neutral-600">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Icons.ScrollText className="w-3.5 h-3.5" /> {a.plan.nombre}
|
<Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre}
|
||||||
</span>
|
</span>
|
||||||
{a.plan.carrera && (
|
{a.plan.carrera && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Icons.GraduationCap className="w-3.5 h-3.5" /> {a.plan.carrera.nombre}
|
<Icons.GraduationCap className="w-3.5 h-3.5" /> <strong>Carrera:</strong> {a.plan.carrera.nombre}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{a.plan.carrera?.facultad && (
|
{a.plan.carrera?.facultad && (
|
||||||
@@ -392,7 +718,6 @@ function AsignaturaCard({ a }: { a: Asignatura }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Objetivo resumido + CTA */}
|
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<p className="text-xs text-neutral-700 line-clamp-2">{a.objetivos ?? '—'}</p>
|
<p className="text-xs text-neutral-700 line-clamp-2">{a.objetivos ?? '—'}</p>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createFileRoute, Link } from '@tanstack/react-router'
|
|||||||
import * as Icons from 'lucide-react'
|
import * as Icons from 'lucide-react'
|
||||||
import { supabase } from '@/auth/supabase'
|
import { supabase } from '@/auth/supabase'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
|
||||||
|
|
||||||
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
|
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
|
||||||
type Plan = {
|
type Plan = {
|
||||||
|
|||||||
@@ -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 * as Icons from 'lucide-react'
|
||||||
import { supabase } from '@/auth/supabase'
|
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 = {
|
type Facultad = {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,7 +23,6 @@ export const Route = createFileRoute('/_authenticated/facultades')({
|
|||||||
.from('facultades')
|
.from('facultades')
|
||||||
.select('id, nombre, icon, color')
|
.select('id, nombre, icon, color')
|
||||||
.order('nombre')
|
.order('nombre')
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return { facultades: [] as Facultad[] }
|
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) {
|
function gradientFrom(color?: string | null) {
|
||||||
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb' // azul por defecto
|
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
|
||||||
// degradado elegante con transparencia
|
|
||||||
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
|
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
|
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<Facultad | null>(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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Icons.Building2 className="w-5 h-5" /> Facultades
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => router.invalidate()}>
|
||||||
|
<Icons.RefreshCcw className="w-4 h-4 mr-2" /> Recargar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Icons.Plus className="w-4 h-4 mr-2" /> Nueva facultad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{facultades.map((fac) => {
|
{facultades.map((fac) => {
|
||||||
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building
|
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building2
|
||||||
const bg = useMemo(() => ({ background: gradientFrom(fac.color) }), [fac.color])
|
const bg = { background: gradientFrom(fac.color) } // ← sin useMemo aquí
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={fac.id} className="group relative rounded-3xl overflow-hidden shadow-xl border">
|
||||||
<Link
|
<Link
|
||||||
key={fac.id}
|
|
||||||
to="/facultad/$facultadId"
|
to="/facultad/$facultadId"
|
||||||
params={{ facultadId: fac.id }}
|
params={{ facultadId: fac.id }}
|
||||||
aria-label={`Administrar ${fac.nombre}`}
|
aria-label={`Administrar ${fac.nombre}`}
|
||||||
className="group relative block rounded-3xl overflow-hidden shadow-xl focus:outline-none focus-visible:ring-4 ring-white/60"
|
className="block focus:outline-none focus-visible:ring-4 ring-white/60"
|
||||||
style={bg}
|
style={bg}
|
||||||
>
|
>
|
||||||
{/* capa brillo */}
|
<div
|
||||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity" style={{
|
className="absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity"
|
||||||
background: 'radial-gradient(1200px 400px at 20% -20%, rgba(255,255,255,.45), transparent 60%)'
|
style={{ background: 'radial-gradient(1200px 400px at 20% -20%, rgba(255,255,255,.45), transparent 60%)' }}
|
||||||
}} />
|
/>
|
||||||
|
|
||||||
{/* contenido */}
|
|
||||||
<div className="relative h-56 sm:h-64 lg:h-72 p-6 flex flex-col justify-between text-white">
|
<div className="relative h-56 sm:h-64 lg:h-72 p-6 flex flex-col justify-between text-white">
|
||||||
<LucideIcon className="w-20 h-20 md:w-24 md:h-24 drop-shadow-md" />
|
<LucideIcon className="w-20 h-20 md:w-24 md:h-24 drop-shadow-md" />
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
<h3 className="text-xl md:text-2xl font-bold drop-shadow-sm pr-2">
|
<h3 className="text-xl md:text-2xl font-bold drop-shadow-sm pr-2">{fac.nombre}</h3>
|
||||||
{fac.nombre}
|
|
||||||
</h3>
|
|
||||||
<Icons.ArrowRight className="w-6 h-6 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
<Icons.ArrowRight className="w-6 h-6 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* borde dinámico al hover */}
|
|
||||||
<div className="absolute inset-0 ring-0 group-hover:ring-4 group-active:ring-4 ring-white/40 transition-[ring-width]" />
|
|
||||||
{/* animación sutil */}
|
|
||||||
<div className="absolute inset-0 scale-100 group-hover:scale-[1.02] group-active:scale-[0.99] transition-transform duration-300" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
className="backdrop-blur bg-white/80 hover:bg-white"
|
||||||
|
onClick={() => openEdit(fac)}
|
||||||
|
>
|
||||||
|
<Icons.Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Dialog Crear */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nueva facultad</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<FormFields form={form} setForm={setForm} />
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={doCreate} disabled={saving}>{saving ? 'Guardando…' : 'Crear'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog Editar */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={(o) => { setEditOpen(o); if (!o) setEditing(null) }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar facultad</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<FormFields form={form} setForm={setForm} />
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setEditOpen(false); setEditing(null) }}>Cancelar</Button>
|
||||||
|
<Button onClick={doEdit} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------- Subcomponentes ----------- */
|
||||||
|
function FormFields({
|
||||||
|
form, setForm
|
||||||
|
}: {
|
||||||
|
form: { nombre: string; icon: IconName; color: `#${string}` }
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<{ nombre: string; icon: IconName; color: `#${string}` }>>
|
||||||
|
}) {
|
||||||
|
const PreviewIcon = (Icons as any)[form.icon] || Icons.Building2
|
||||||
|
const bg = useMemo(() => ({ background: gradientFrom(form.color) }), [form.color])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-2xl overflow-hidden border">
|
||||||
|
<div className="h-36 p-4 flex items-end justify-between text-white" style={bg}>
|
||||||
|
<PreviewIcon className="w-14 h-14 drop-shadow-md" />
|
||||||
|
<span className="text-xs bg-white/20 px-2 py-1 rounded">Vista previa</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nombre */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input
|
||||||
|
value={form.nombre}
|
||||||
|
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
|
||||||
|
placeholder="Facultad de Ingeniería"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icono */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Ícono</Label>
|
||||||
|
<Select value={form.icon} onValueChange={(v) => setForm(s => ({ ...s, icon: v as IconName }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Selecciona ícono" /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{ICON_CHOICES.map(k => {
|
||||||
|
const Ico = (Icons as any)[k]
|
||||||
|
return (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
<span className="inline-flex items-center gap-2"><Ico className="w-4 h-4" /> {k}</span>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color (paleta curada) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Color</Label>
|
||||||
|
<ColorGrid
|
||||||
|
value={form.color}
|
||||||
|
onChange={(hex) => setForm(s => ({ ...s, color: hex }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorGrid({ value, onChange }: { value: `#${string}`; onChange: (hex: `#${string}`) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-8 gap-2">
|
||||||
|
{PALETTE.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.hex}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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 && (
|
||||||
|
<Icons.Check className="absolute right-1.5 bottom-1.5 w-4 h-4 text-white drop-shadow" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ function RouteComponent() {
|
|||||||
<div className="academics">
|
<div className="academics">
|
||||||
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
|
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* ===== Asignaturas (preview cards) ===== */}
|
||||||
<Card className="border shadow-sm">
|
<Card className="border shadow-sm">
|
||||||
<CardHeader className="flex items-center justify-between gap-2">
|
<CardHeader className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
||||||
@@ -258,32 +259,29 @@ function RouteComponent() {
|
|||||||
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
|
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
|
||||||
<Link
|
<Link
|
||||||
to="/asignaturas/$planId"
|
to="/asignaturas/$planId"
|
||||||
search={{ q: "", planId: plan.id, carreraId: '', f: '', facultadId: '' }}
|
search={{ q: "", planId: plan.id, carreraId: "", f: "", facultadId: "" }}
|
||||||
params={{ planId: plan.id }}
|
params={{ planId: plan.id }}
|
||||||
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||||
|
title="Ver todas las asignaturas"
|
||||||
>
|
>
|
||||||
<Icons.BookOpen className="w-4 h-4" /> Ver todas
|
<Icons.BookOpen className="w-4 h-4" /> Ver en página de Asignaturas
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
{asignaturasPreview.length === 0 ? (
|
||||||
{asignaturasPreview.length === 0 && (
|
|
||||||
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
||||||
)}
|
) : (
|
||||||
{asignaturasPreview.map(a => (
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Link
|
{asignaturasPreview.map((a) => (
|
||||||
to="/asignatura/$asignaturaId"
|
<AsignaturaPreviewCard key={a.id} asignatura={a} />
|
||||||
params={{ asignaturaId: a.id }}
|
|
||||||
className="rounded-full border px-3 py-1 text-xs bg-white/70 hover:bg-white transition"
|
|
||||||
title={a.nombre}
|
|
||||||
>
|
|
||||||
{a.semestre ? `S${a.semestre} · ` : ''}{a.nombre}
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -404,9 +402,9 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
|
|||||||
|
|
||||||
async function apply() {
|
async function apply() {
|
||||||
setLoading(true)
|
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' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ prompt, plan }),
|
body: JSON.stringify({ prompt, plan_id: plan.id }),
|
||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setOpen(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<string, Record<string, string>> | 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 (
|
||||||
|
<article
|
||||||
|
className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5"
|
||||||
|
role="region"
|
||||||
|
aria-label={asignatura.nombre}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||||
|
<Icons.BookOpen className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium truncate" title={asignatura.nombre}>
|
||||||
|
{asignatura.nombre}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||||
|
{asignatura.semestre != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||||
|
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{asignatura.creditos != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||||
|
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{extra?.tipo && (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
|
||||||
|
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* cuerpo */}
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
|
||||||
|
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
|
||||||
|
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
|
||||||
|
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="text-[11px] text-neutral-500">
|
||||||
|
{horasT != null || horasP != null ? (
|
||||||
|
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-70">Resumen listo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/asignatura/$asignaturaId"
|
||||||
|
params={{ asignaturaId: asignatura.id }}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50"
|
||||||
|
title="Ver detalle"
|
||||||
|
>
|
||||||
|
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* glow sutil en hover */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallStat({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
|
||||||
|
<Icon className="h-3.5 w-3.5" /> {label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import {
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, CheckCircle2, XCircle,
|
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail,
|
||||||
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff
|
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff,
|
||||||
|
Ban as BanIcon, Check
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { SupabaseClient } from "@supabase/supabase-js"
|
import { SupabaseClient } from "@supabase/supabase-js"
|
||||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string | null
|
email: string | null
|
||||||
@@ -27,9 +27,21 @@ type AdminUser = {
|
|||||||
last_sign_in_at: string | null
|
last_sign_in_at: string | null
|
||||||
user_metadata: any
|
user_metadata: any
|
||||||
app_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]
|
export type Role = typeof ROLES[number]
|
||||||
|
|
||||||
const ROLE_META: Record<Role, {
|
const ROLE_META: Record<Role, {
|
||||||
@@ -47,6 +59,11 @@ const ROLE_META: Record<Role, {
|
|||||||
Icon: Building2,
|
Icon: Building2,
|
||||||
className: "bg-indigo-600 text-white"
|
className: "bg-indigo-600 text-white"
|
||||||
},
|
},
|
||||||
|
director_facultad: { // NEW
|
||||||
|
label: "Director(a) de Facultad",
|
||||||
|
Icon: Building2,
|
||||||
|
className: "bg-purple-600 text-white"
|
||||||
|
},
|
||||||
secretario_academico: {
|
secretario_academico: {
|
||||||
label: "Secretario Académico",
|
label: "Secretario Académico",
|
||||||
Icon: ScrollText,
|
Icon: ScrollText,
|
||||||
@@ -82,7 +99,7 @@ function RolePill({ role }: { role: Role }) {
|
|||||||
export const Route = createFileRoute("/_authenticated/usuarios")({
|
export const Route = createFileRoute("/_authenticated/usuarios")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
// ⚠️ Asumes service role en cliente (mejor mover a Edge Function en producción)
|
// ⚠️ Dev only: service role en cliente
|
||||||
const supabsaeAdmin = new SupabaseClient(
|
const supabsaeAdmin = new SupabaseClient(
|
||||||
import.meta.env.VITE_SUPABASE_URL,
|
import.meta.env.VITE_SUPABASE_URL,
|
||||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
|
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
|
||||||
@@ -99,7 +116,6 @@ function RouteComponent() {
|
|||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
// state del formulario
|
|
||||||
const [form, setForm] = useState<{
|
const [form, setForm] = useState<{
|
||||||
role?: Role;
|
role?: Role;
|
||||||
claims_admin?: boolean;
|
claims_admin?: boolean;
|
||||||
@@ -122,24 +138,75 @@ function RouteComponent() {
|
|||||||
}>({ email: "", password: "" })
|
}>({ email: "", password: "" })
|
||||||
|
|
||||||
function genPassword() {
|
function genPassword() {
|
||||||
// 14 chars pseudo-aleatoria
|
|
||||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
|
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
|
||||||
.map(n => n.toString(36)).join("")
|
.map(n => n.toString(36)).join("")
|
||||||
return s.slice(0, 14)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createUserNow() {
|
async function createUserNow() {
|
||||||
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
|
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
|
||||||
try {
|
try {
|
||||||
setCreateSaving(true)
|
const adminClient = new SupabaseClient(
|
||||||
const admin = new SupabaseClient(
|
|
||||||
import.meta.env.VITE_SUPABASE_URL,
|
import.meta.env.VITE_SUPABASE_URL,
|
||||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
setCreateSaving(true)
|
||||||
const password = createForm.password?.trim() || genPassword()
|
const password = createForm.password?.trim() || genPassword()
|
||||||
|
const { error, data } = await adminClient.auth.admin.createUser({
|
||||||
const { error } = await admin.auth.admin.createUser({
|
|
||||||
email: createForm.email.trim(),
|
email: createForm.email.trim(),
|
||||||
password,
|
password,
|
||||||
email_confirm: true,
|
email_confirm: true,
|
||||||
@@ -157,8 +224,26 @@ function RouteComponent() {
|
|||||||
carrera_id: createForm.carrera_id ?? null
|
carrera_id: createForm.carrera_id ?? null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) throw error
|
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")
|
toast.success("Usuario creado")
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setCreateForm({ email: "", password: "" })
|
setCreateForm({ email: "", password: "" })
|
||||||
@@ -171,7 +256,6 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!auth.claims?.claims_admin) {
|
if (!auth.claims?.claims_admin) {
|
||||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||||
}
|
}
|
||||||
@@ -203,29 +287,56 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
// NEW: validación de scope por rol antes de guardar
|
||||||
if (!editing) return
|
function validateScopeForSave(): string | null {
|
||||||
setSaving(true)
|
if (!editing) return "Sin usuario"
|
||||||
const { error } = await supabase.functions.invoke("admin-update-user", {
|
if (form.role === "director_facultad" || form.role === "secretario_academico") {
|
||||||
body: {
|
if (!form.facultad_id) return "Selecciona una facultad"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
if (form.role === "jefe_carrera") {
|
||||||
|
if (!form.facultad_id || !form.carrera_id) return "Selecciona facultad y carrera"
|
||||||
}
|
}
|
||||||
})
|
return null
|
||||||
setSaving(false)
|
|
||||||
if (error) { console.error(error); return }
|
|
||||||
router.invalidate(); setEditing(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
@@ -242,8 +353,6 @@ function RouteComponent() {
|
|||||||
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
||||||
<RefreshCcw className="w-4 h-4" />
|
<RefreshCcw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* NUEVO: abrir modal de alta */}
|
|
||||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
||||||
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
||||||
</Button>
|
</Button>
|
||||||
@@ -256,39 +365,50 @@ function RouteComponent() {
|
|||||||
const m = u.user_metadata || {}
|
const m = u.user_metadata || {}
|
||||||
const a = u.app_metadata || {}
|
const a = u.app_metadata || {}
|
||||||
const roleCode: Role | undefined = a.role
|
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 (
|
return (
|
||||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
|
<div key={u.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
|
||||||
<div className="flex items-start gap-3 sm:gap-4">
|
<div className="flex items-start gap-3 sm:gap-4">
|
||||||
<img
|
<img
|
||||||
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`}
|
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{/* Fila superior: nombre + chips + botón (desktop) */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{m.title ? `${m.title} ` : ""}
|
{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
||||||
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||||
{roleCode && <RolePill role={roleCode} />} {/* usa el pill responsivo */}
|
{roleCode && <RolePill role={roleCode} />}
|
||||||
{a.claims_admin ? (
|
{a.claims_admin ? (
|
||||||
<Badge className="gap-1" variant="secondary">
|
<Badge className="gap-1" variant="secondary">
|
||||||
<ShieldCheck className="w-3 h-3" /> Administrador
|
<ShieldCheck className="w-3 h-3" /> Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="gap-1" variant="outline">
|
<Badge className="gap-1" variant="outline">
|
||||||
<ShieldAlert className="w-3 h-3" /> Usuario
|
<ShieldAlert className="w-3 h-3" /> Usuario
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{/* NEW: estado ban */}
|
||||||
|
<Badge variant={banned ? "destructive" as any : "secondary"} className="gap-1">
|
||||||
|
{banned ? <BanIcon className="w-3 h-3" /> : <Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{/* Desktop: botón con texto */}
|
{/* NEW: toggle ban/unban */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleBan(u)}
|
||||||
|
title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
|
>
|
||||||
|
<BanIcon className="w-4 h-4 mr-1" />
|
||||||
|
{banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -298,8 +418,8 @@ function RouteComponent() {
|
|||||||
<Pencil className="w-4 h-4 mr-1" /> Editar
|
<Pencil className="w-4 h-4 mr-1" /> Editar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Fila inferior: metadatos (wrapping) */}
|
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Mail className="w-3 h-3" /> {u.email ?? "—"}
|
<Mail className="w-3 h-3" /> {u.email ?? "—"}
|
||||||
@@ -310,31 +430,20 @@ function RouteComponent() {
|
|||||||
<span className="hidden md:inline">
|
<span className="hidden md:inline">
|
||||||
Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
|
Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
|
||||||
</span>
|
</span>
|
||||||
{m.email_verified ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
|
||||||
<CheckCircle2 className="w-3 h-3" /> Verificado
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 text-neutral-500">
|
|
||||||
<XCircle className="w-3 h-3" /> No verificado
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: icon-only */}
|
{/* Mobile actions */}
|
||||||
<Button
|
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||||
variant="ghost"
|
<Button variant="outline" size="icon" onClick={() => toggleBan(u)} aria-label="Ban/Unban">
|
||||||
size="icon"
|
<BanIcon className="w-4 h-4" />
|
||||||
className="sm:hidden self-start shrink-0"
|
</Button>
|
||||||
onClick={() => openEdit(u)}
|
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar">
|
||||||
aria-label="Editar"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{!filtered.length && (
|
{!filtered.length && (
|
||||||
@@ -377,18 +486,16 @@ function RouteComponent() {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setForm(s => {
|
setForm(s => {
|
||||||
const role = v as Role
|
const role = v as Role
|
||||||
// limpiar/aplicar campos según rol
|
|
||||||
if (role === "jefe_carrera") {
|
if (role === "jefe_carrera") {
|
||||||
return { ...s, role, /* conserva si ya venían */ facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||||
}
|
}
|
||||||
if (role === "secretario_academico") {
|
if (role === "secretario_academico" || role === "director_facultad") {
|
||||||
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||||
}
|
}
|
||||||
return { ...s, role, facultad_id: null, carrera_id: null }
|
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Hace que el popper herede ancho del trigger y no se salga */}
|
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecciona un rol" />
|
<SelectValue placeholder="Selecciona un rol" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -409,25 +516,24 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Solo SECRETARIO: facultad */}
|
{/* DIRECTOR/SECRETARIO: facultad */}
|
||||||
{form.role === "secretario_academico" && (
|
{(form.role === "secretario_academico" || form.role === "director_facultad") && (
|
||||||
<div className="md:col-span-2 space-y-1">
|
<div className="md:col-span-2 space-y-1">
|
||||||
<Label>Facultad</Label>
|
<Label>Facultad</Label>
|
||||||
<FacultadCombobox
|
<FacultadCombobox
|
||||||
value={form.facultad_id ?? ""}
|
value={form.facultad_id ?? ""}
|
||||||
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-neutral-500">Este rol solo requiere <strong>Facultad</strong>.</p>
|
<p className="text-[11px] text-neutral-500">Este rol requiere <strong>Facultad</strong>.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* JEFE DE CARRERA: ambos */}
|
{/* JEFE DE CARRERA: ambos */}
|
||||||
{form.role === "jefe_carrera" && (
|
{form.role === "jefe_carrera" && (
|
||||||
< div className="grid gap-4 sm:grid-cols-2"> {/* 👈 asegura wrap en XS */}
|
<div className="grid gap-4 sm:grid-cols-2 md:col-span-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Facultad</Label>
|
<Label>Facultad</Label>
|
||||||
<FacultadCombobox
|
<FacultadCombobox
|
||||||
@@ -445,10 +551,8 @@ function RouteComponent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Permisos</Label>
|
<Label>Permisos</Label>
|
||||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
|
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
|
||||||
@@ -523,7 +627,6 @@ function RouteComponent() {
|
|||||||
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
|
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rol */}
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<Label>Rol</Label>
|
<Label>Rol</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -532,7 +635,7 @@ function RouteComponent() {
|
|||||||
setCreateForm(s => {
|
setCreateForm(s => {
|
||||||
const role = v as Role
|
const role = v as Role
|
||||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
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 }
|
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 }
|
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -551,8 +654,7 @@ function RouteComponent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SECRETARIO: Facultad */}
|
{(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && (
|
||||||
{createForm.role === "secretario_academico" && (
|
|
||||||
<div className="md:col-span-2 space-y-1">
|
<div className="md:col-span-2 space-y-1">
|
||||||
<Label>Facultad</Label>
|
<Label>Facultad</Label>
|
||||||
<FacultadCombobox
|
<FacultadCombobox
|
||||||
@@ -562,7 +664,6 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* JEFE_CARRERA: Facultad + Carrera */}
|
|
||||||
{createForm.role === "jefe_carrera" && (
|
{createForm.role === "jefe_carrera" && (
|
||||||
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
|
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { ArrowRight } from "lucide-react"
|
import { ArrowRight } from "lucide-react"
|
||||||
import '../App.css'
|
import '../App.css'
|
||||||
|
|
||||||
@@ -10,12 +9,12 @@ export const Route = createFileRoute('/')({
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-950 via-slate-900 to-slate-800 text-white">
|
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-150 via-slate-200 to-primary">
|
||||||
{/* Navbar */}
|
{/* Navbar */}
|
||||||
<header className="flex items-center justify-between px-10 py-6 border-b border-slate-700/50">
|
<header className="flex items-center justify-between px-10 py-6 border-b border-slate-700/50">
|
||||||
<h1 className="text-2xl font-mono tracking-tight">Génesis</h1>
|
<h1 className="text-2xl font-mono tracking-tight">Génesis</h1>
|
||||||
<Link to="/login" search={{ redirect: '/planes' }}>
|
<Link to="/login" search={{ redirect: '/planes' }}>
|
||||||
<Button variant="outline" className="text-white border-slate-500 hover:bg-slate-700/50">
|
<Button variant="outline" className="border-slate-500 hover:bg-slate-700/50">
|
||||||
Iniciar sesión
|
Iniciar sesión
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -24,10 +23,10 @@ function App() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<main className="flex-1 flex flex-col items-center justify-center text-center px-6">
|
<main className="flex-1 flex flex-col items-center justify-center text-center px-6">
|
||||||
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
|
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
|
||||||
Bienvenido a <span className="text-cyan-400">Génesis</span>
|
Bienvenido a <span className="text-primary">Génesis</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg md:text-xl text-slate-300 max-w-2xl mb-8">
|
<p className="text-lg md:text-xl max-w-2xl mb-8">
|
||||||
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.
|
Seguro, moderno y hecho para crecer contigo.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -37,30 +36,14 @@ function App() {
|
|||||||
Comenzar <ArrowRight className="ml-2 h-5 w-5" />
|
Comenzar <ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" variant="outline" className="rounded-2xl px-6 py-3 text-lg font-mono border-slate-600 text-slate-200 hover:bg-slate-700/50">
|
<Button size="lg" variant="outline" className="rounded-2xl px-6 py-3 text-lg font-mono border-slate-600 hover:bg-slate-100/50">
|
||||||
Conoce más
|
Conoce más
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Highlights */}
|
|
||||||
<section className="grid md:grid-cols-3 gap-6 px-10 py-16 bg-slate-900/60 backdrop-blur-md border-t border-slate-700/50">
|
|
||||||
{[
|
|
||||||
{ 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) => (
|
|
||||||
<Card key={i} className="bg-slate-800/60 border-slate-700 hover:border-cyan-500 transition-colors duration-300">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-mono text-xl mb-3 text-cyan-400">{item.title}</h3>
|
|
||||||
<p className="text-slate-300">{item.desc}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="py-6 text-center text-slate-400 text-sm border-t border-slate-700/50">
|
<footer className="py-6 text-center text-sm border-t border-slate-700/50">
|
||||||
© {new Date().getFullYear()} Génesis — Universidad La Salle
|
© {new Date().getFullYear()} Génesis — Universidad La Salle
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user