Refactor user management in usuarios.tsx: integrate react-query for data fetching and mutations, streamline role handling, and enhance user ban/unban functionality.
This commit is contained in:
@@ -1,185 +1,219 @@
|
||||
import * as Icons from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/* ---------- helpers de color ---------- */
|
||||
/* =====================================================
|
||||
Query keys & fetcher
|
||||
===================================================== */
|
||||
export const planKeys = {
|
||||
root: ["plan"] as const,
|
||||
byId: (id: string) => [...planKeys.root, id] as const,
|
||||
}
|
||||
|
||||
export type PlanTextFields = {
|
||||
objetivo_general?: string | string[] | null
|
||||
sistema_evaluacion?: string | string[] | null
|
||||
perfil_ingreso?: string | 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
|
||||
}
|
||||
|
||||
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select(
|
||||
`objetivo_general, sistema_evaluacion, perfil_ingreso, perfil_egreso,
|
||||
competencias_genericas, competencias_especificas, indicadores_desempeno,
|
||||
pertinencia, prompt`
|
||||
)
|
||||
.eq("id", planId)
|
||||
.single()
|
||||
if (error) throw error
|
||||
return (data ?? {}) as PlanTextFields
|
||||
}
|
||||
|
||||
export const planTextOptions = (planId: string) =>
|
||||
queryOptions({
|
||||
queryKey: planKeys.byId(planId),
|
||||
queryFn: () => fetchPlanText(planId),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
/* =====================================================
|
||||
Color helpers
|
||||
===================================================== */
|
||||
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||
if (!hex) return [37, 99, 235]
|
||||
const h = hex.replace("#", "")
|
||||
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
|
||||
const n = parseInt(v, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
if (!hex) return [37, 99, 235]
|
||||
const h = hex.replace("#", "")
|
||||
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
|
||||
const n = parseInt(v, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
|
||||
|
||||
/* ---------- texto expandible (acepta string o string[]) ---------- */
|
||||
/* =====================================================
|
||||
Expandable text
|
||||
===================================================== */
|
||||
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
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 (
|
||||
<div>
|
||||
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>
|
||||
{rendered}
|
||||
</div>
|
||||
{String(rendered).length > 220 && (
|
||||
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
||||
{open ? "Ver menos" : "Ver más"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
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 (
|
||||
<div>
|
||||
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div>
|
||||
{String(rendered).length > 220 && (
|
||||
<button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
||||
{open ? "Ver menos" : "Ver más"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- panel con aurora mesh ---------- */
|
||||
function SectionPanel({
|
||||
title, icon: Icon, color, children, id,
|
||||
}: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
||||
const rgb = hexToRgb(color)
|
||||
return (
|
||||
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
|
||||
{/* aurora mesh sutil */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-0">
|
||||
<div
|
||||
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>
|
||||
/* =====================================================
|
||||
Section panel
|
||||
===================================================== */
|
||||
function SectionPanel({ title, icon: Icon, color, children, id }: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
||||
const rgb = hexToRgb(color)
|
||||
return (
|
||||
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
|
||||
<div className="pointer-events-none absolute inset-0 -z-0">
|
||||
<div className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)` }} />
|
||||
<div className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.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, 0.1)}, transparent)` }}>
|
||||
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80" style={{ borderColor: rgba(rgb, 0.25) }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="relative z-10 p-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<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) }}
|
||||
/* =====================================================
|
||||
AcademicSections (con React Query)
|
||||
===================================================== */
|
||||
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
||||
const qc = useQueryClient()
|
||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||
|
||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
|
||||
// --- mutation con actualización optimista ---
|
||||
const updateField = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
|
||||
const payload: Record<string, any> = { [key]: value }
|
||||
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
|
||||
if (error) throw error
|
||||
return payload
|
||||
},
|
||||
onMutate: async ({ key, value }) => {
|
||||
await qc.cancelQueries({ queryKey: planKeys.byId(planId) })
|
||||
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId))
|
||||
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({ ...(old ?? {}), [key]: value }))
|
||||
return { prev }
|
||||
},
|
||||
onError: (e, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev)
|
||||
toast.error((e as any)?.message || "No se pudo guardar 😓")
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Guardado ✅")
|
||||
},
|
||||
onSettled: async () => {
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) })
|
||||
},
|
||||
})
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
||||
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
||||
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
||||
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
||||
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
||||
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
||||
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
||||
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
||||
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{sections.map((s) => {
|
||||
const text = plan[s.key] ?? null
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||
onClick={() => {
|
||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||
}}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="relative z-10 p-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Secciones integradas (sin tabs) ---------- */
|
||||
type PlanTextFields = {
|
||||
objetivo_general?: string | string[] | null
|
||||
sistema_evaluacion?: string | string[] | null
|
||||
perfil_ingreso?: string | 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({
|
||||
planId, plan, color,
|
||||
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
|
||||
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
|
||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const sections = useMemo(() => [
|
||||
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
||||
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
||||
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
||||
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
||||
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
||||
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
||||
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
||||
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
||||
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
||||
], [])
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
const payload: any = { [editing.key]: draft }
|
||||
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
|
||||
setSaving(false)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
alert("No se pudo guardar 😓")
|
||||
return
|
||||
}
|
||||
setLocal(prev => ({ ...prev, [editing.key]: draft }))
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{/* Todas las tarjetas visibles */}
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{sections.map(s => {
|
||||
const text = local[s.key] ?? null
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Diálogo de edición */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
||||
placeholder="Escribe aquí…"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Diálogo de edición */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
updateField.mutate({ key: editing.key, value: draft })
|
||||
setEditing(null)
|
||||
}}
|
||||
disabled={updateField.isPending}
|
||||
>
|
||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
13
src/main.tsx
13
src/main.tsx
@@ -1,6 +1,8 @@
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
@@ -9,6 +11,8 @@ import './styles.css'
|
||||
import reportWebVitals from './reportWebVitals.ts'
|
||||
import { SupabaseAuthProvider, useSupabaseAuth } from './auth/supabase.tsx'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
@@ -17,6 +21,7 @@ const router = createRouter({
|
||||
defaultPreloadStaleTime: 0,
|
||||
context: {
|
||||
auth: undefined!,
|
||||
queryClient
|
||||
},
|
||||
})
|
||||
|
||||
@@ -50,9 +55,13 @@ if (rootElement && !rootElement.innerHTML) {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<SupabaseAuthProvider>
|
||||
<InnerApp />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
<InnerApp />
|
||||
</QueryClientProvider>,
|
||||
</SupabaseAuthProvider>
|
||||
</StrictMode>,
|
||||
|
||||
</StrictMode >,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import type { SupabaseAuthState } from '@/auth/supabase'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
interface AuthContext {
|
||||
auth: SupabaseAuthState
|
||||
|
||||
interface Context {
|
||||
auth: SupabaseAuthState,
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<AuthContext>()({
|
||||
export const Route = createRootRouteWithContext<Context>()({
|
||||
component: () => (
|
||||
<>
|
||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -6,7 +7,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
// NEW
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
@@ -35,11 +35,99 @@ type Asignatura = {
|
||||
plan_id?: string | null
|
||||
}
|
||||
|
||||
type LoaderData = {
|
||||
asignaturas: Asignatura[]
|
||||
planes: PlanMini[] // NEW: para elegir destino
|
||||
type SearchState = {
|
||||
q: string
|
||||
planId: string
|
||||
carreraId: string
|
||||
facultadId: string
|
||||
f: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
|
||||
}
|
||||
|
||||
/* ================== Query Keys & Options ================== */
|
||||
const asignaturasKeys = {
|
||||
root: ['asignaturas'] as const,
|
||||
list: (search: SearchState) => [...asignaturasKeys.root, { search }] as const,
|
||||
}
|
||||
const planesKeys = {
|
||||
root: ['planes'] as const,
|
||||
all: () => [...planesKeys.root, 'all'] as const,
|
||||
}
|
||||
|
||||
async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId'|'carreraId'|'facultadId'>): Promise<string[] | null> {
|
||||
const { planId, carreraId, facultadId } = search
|
||||
if (planId) return [planId]
|
||||
if (carreraId) {
|
||||
const { data, error } = await supabase.from('plan_estudios').select('id').eq('carrera_id', carreraId)
|
||||
if (error) throw error
|
||||
return (data ?? []).map(p => p.id)
|
||||
}
|
||||
if (facultadId) {
|
||||
const { data: carreras, error: carErr } = await supabase.from('carreras').select('id').eq('facultad_id', facultadId)
|
||||
if (carErr) throw carErr
|
||||
const cIds = (carreras ?? []).map(c => c.id)
|
||||
if (!cIds.length) return []
|
||||
const { data: planesFac, error: plaErr } = await supabase
|
||||
.from('plan_estudios')
|
||||
.select('id')
|
||||
.in('carrera_id', cIds)
|
||||
if (plaErr) throw plaErr
|
||||
return (planesFac ?? []).map(p => p.id)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
|
||||
const planIds = await fetchPlanIdsByScope(search)
|
||||
if (planIds && planIds.length === 0) return []
|
||||
|
||||
let query = supabase
|
||||
.from('asignaturas')
|
||||
.select(`
|
||||
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
|
||||
objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id,
|
||||
plan:plan_estudios (
|
||||
id, nombre,
|
||||
carrera:carreras (
|
||||
id, nombre,
|
||||
facultad:facultades ( id, nombre, color, icon )
|
||||
)
|
||||
)
|
||||
`)
|
||||
.order('semestre', { ascending: true })
|
||||
.order('nombre', { ascending: true })
|
||||
if (planIds) query = query.in('plan_id', planIds)
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
return (data ?? []) as unknown as Asignatura[]
|
||||
}
|
||||
|
||||
async function fetchPlanes(): Promise<PlanMini[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('plan_estudios')
|
||||
.select(`
|
||||
id, nombre,
|
||||
carrera:carreras(
|
||||
id, nombre,
|
||||
facultad:facultades(id, nombre, color, icon)
|
||||
)
|
||||
`)
|
||||
.order('nombre', { ascending: true })
|
||||
if (error) throw error
|
||||
return (data ?? []) as unknown as PlanMini[]
|
||||
}
|
||||
|
||||
const asignaturasOptions = (search: SearchState) => queryOptions({
|
||||
queryKey: asignaturasKeys.list(search),
|
||||
queryFn: () => fetchAsignaturas(search),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const planesOptions = () => queryOptions({
|
||||
queryKey: planesKeys.all(),
|
||||
queryFn: fetchPlanes,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
/* ================== Ruta ================== */
|
||||
export const Route = createFileRoute('/_authenticated/asignaturas')({
|
||||
component: RouteComponent,
|
||||
@@ -53,94 +141,26 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
|
||||
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
|
||||
}
|
||||
},
|
||||
loader: async (ctx): Promise<LoaderData> => {
|
||||
const search = (ctx.location?.search ?? {}) as {
|
||||
q?: string
|
||||
planId?: string
|
||||
carreraId?: string
|
||||
facultadId?: string
|
||||
f?: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
|
||||
}
|
||||
|
||||
const { planId, carreraId, facultadId } = search
|
||||
let planIds: string[] | null = null
|
||||
|
||||
if (planId) {
|
||||
planIds = [planId]
|
||||
} else if (carreraId) {
|
||||
const { data: planesCar, error } = await supabase
|
||||
.from('plan_estudios')
|
||||
.select('id')
|
||||
.eq('carrera_id', carreraId)
|
||||
if (error) throw error
|
||||
planIds = (planesCar ?? []).map(p => p.id)
|
||||
} else if (facultadId) {
|
||||
const { data: carreras, error: carErr } = await supabase
|
||||
.from('carreras')
|
||||
.select('id')
|
||||
.eq('facultad_id', facultadId)
|
||||
if (carErr) throw carErr
|
||||
const cIds = (carreras ?? []).map(c => c.id)
|
||||
if (!cIds.length) {
|
||||
return { asignaturas: [], planes: [] }
|
||||
}
|
||||
const { data: planesFac, error: plaErr } = await supabase
|
||||
.from('plan_estudios')
|
||||
.select('id, nombre, carrera:carreras(id, nombre, facultad:facultades(id, nombre, color, icon))')
|
||||
.in('carrera_id', cIds)
|
||||
if (plaErr) throw plaErr
|
||||
planIds = (planesFac ?? []).map(p => p.id)
|
||||
}
|
||||
|
||||
if (planIds && planIds.length === 0) {
|
||||
return { asignaturas: [], planes: [] }
|
||||
}
|
||||
|
||||
// Traer asignaturas
|
||||
let query = supabase
|
||||
.from('asignaturas')
|
||||
.select(`
|
||||
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
|
||||
objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id,
|
||||
plan:plan_estudios (
|
||||
id, nombre,
|
||||
carrera:carreras (
|
||||
id, nombre,
|
||||
facultad:facultades ( id, nombre, color, icon )
|
||||
)
|
||||
)
|
||||
`)
|
||||
.order('semestre', { ascending: true })
|
||||
.order('nombre', { ascending: true })
|
||||
if (planIds) query = query.in('plan_id', planIds)
|
||||
const { data, error: aErr } = await query
|
||||
if (aErr) throw aErr
|
||||
|
||||
// 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[],
|
||||
}
|
||||
loader: async ({ context: { queryClient }, location }) => {
|
||||
const search = (location?.search ?? {}) as SearchState
|
||||
// Pre-hydrate ambas queries con QueryClient (sin llamadas "sueltas" aquí)
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(asignaturasOptions(search)),
|
||||
queryClient.ensureQueryData(planesOptions()),
|
||||
])
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
/* ================== Página ================== */
|
||||
function RouteComponent() {
|
||||
const { asignaturas, planes } = Route.useLoaderData() as LoaderData
|
||||
const router = useRouter()
|
||||
const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' }
|
||||
const qc = useQueryClient()
|
||||
const search = Route.useSearch() as SearchState
|
||||
|
||||
// Datos por TanStack Query (suspense-friendly)
|
||||
const { data: asignaturas } = useSuspenseQuery(asignaturasOptions(search))
|
||||
const { data: planes } = useSuspenseQuery(planesOptions())
|
||||
|
||||
// Filtros
|
||||
const [q, setQ] = useState(search.q ?? '')
|
||||
@@ -323,6 +343,11 @@ function RouteComponent() {
|
||||
toast.success(`Clonadas ${cart.length} asignaturas`)
|
||||
setBulkOpen(false)
|
||||
clearCart()
|
||||
// Invalida ambas queries y la ruta
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: asignaturasKeys.root }),
|
||||
qc.invalidateQueries({ queryKey: planesKeys.root }),
|
||||
])
|
||||
router.invalidate()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
@@ -362,7 +387,7 @@ function RouteComponent() {
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
<Button variant="outline" size="icon" onClick={() => { qc.invalidateQueries({ queryKey: asignaturasKeys.root }); router.invalidate() }} title="Recargar">
|
||||
<Icons.RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -541,6 +566,7 @@ function RouteComponent() {
|
||||
})
|
||||
toast.success('Asignatura clonada')
|
||||
setCloneOpen(false)
|
||||
await qc.invalidateQueries({ queryKey: asignaturasKeys.root })
|
||||
router.invalidate()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// routes/_authenticated/carreras.tsx
|
||||
// routes/_authenticated/carreras.tsx (refactor a TanStack Query v5)
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useSuspenseQuery, useQueryClient, queryOptions, useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import * as Icons from "lucide-react"
|
||||
|
||||
@@ -9,20 +10,14 @@ import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Accordion, AccordionItem, AccordionTrigger, AccordionContent,
|
||||
} from "@/components/ui/accordion"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
/* -------------------- Tipos -------------------- */
|
||||
type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
||||
type CarreraRow = {
|
||||
export type CarreraRow = {
|
||||
id: string
|
||||
nombre: string
|
||||
semestres: number
|
||||
@@ -30,39 +25,102 @@ type CarreraRow = {
|
||||
facultad_id: string | null
|
||||
facultades?: FacultadLite | null
|
||||
}
|
||||
type LoaderData = { carreras: CarreraRow[]; facultades: FacultadLite[] }
|
||||
|
||||
/* -------------------- Query Keys & Fetchers -------------------- */
|
||||
const carrerasKeys = {
|
||||
root: ["carreras"] as const,
|
||||
list: () => [...carrerasKeys.root, "list"] as const,
|
||||
}
|
||||
const facultadesKeys = {
|
||||
root: ["facultades"] as const,
|
||||
all: () => [...facultadesKeys.root, "all"] as const,
|
||||
}
|
||||
const criteriosKeys = {
|
||||
root: ["criterios_carrera"] as const,
|
||||
byCarrera: (id: string) => [...criteriosKeys.root, { carreraId: id }] as const,
|
||||
}
|
||||
|
||||
async function fetchCarreras(): Promise<CarreraRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("carreras")
|
||||
.select(
|
||||
`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`
|
||||
)
|
||||
.order("nombre", { ascending: true })
|
||||
if (error) throw error
|
||||
return (data ?? []) as unknown as CarreraRow[]
|
||||
}
|
||||
|
||||
async function fetchFacultades(): Promise<FacultadLite[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("facultades")
|
||||
.select("id, nombre, color, icon")
|
||||
.order("nombre", { ascending: true })
|
||||
if (error) throw error
|
||||
return (data ?? []) as FacultadLite[]
|
||||
}
|
||||
|
||||
export type CriterioRow = {
|
||||
id: number
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
tipo: string | null
|
||||
obligatorio: boolean | null
|
||||
referencia_documento: string | null
|
||||
fecha_creacion: string | null
|
||||
}
|
||||
|
||||
async function fetchCriterios(carreraId: string): Promise<CriterioRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("criterios_carrera")
|
||||
.select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion")
|
||||
.eq("carrera_id", carreraId)
|
||||
.order("fecha_creacion", { ascending: true })
|
||||
if (error) throw error
|
||||
return (data ?? []) as CriterioRow[]
|
||||
}
|
||||
|
||||
const carrerasOptions = () =>
|
||||
queryOptions({ queryKey: carrerasKeys.list(), queryFn: fetchCarreras, staleTime: 60_000 })
|
||||
|
||||
const facultadesOptions = () =>
|
||||
queryOptions({ queryKey: facultadesKeys.all(), queryFn: fetchFacultades, staleTime: 5 * 60_000 })
|
||||
|
||||
const criteriosOptions = (carreraId: string) =>
|
||||
queryOptions({ queryKey: criteriosKeys.byCarrera(carreraId), queryFn: () => fetchCriterios(carreraId) })
|
||||
|
||||
/* -------------------- Ruta -------------------- */
|
||||
export const Route = createFileRoute("/_authenticated/carreras")({
|
||||
component: RouteComponent,
|
||||
loader: async (): Promise<LoaderData> => {
|
||||
const [{ data: carreras, error: e1 }, { data: facultades, error: e2 }] = await Promise.all([
|
||||
supabase
|
||||
.from("carreras")
|
||||
.select(`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`)
|
||||
.order("nombre", { ascending: true }),
|
||||
supabase.from("facultades").select("id, nombre, color, icon").order("nombre", { ascending: true }),
|
||||
// Prefetch con TanStack Query (sin llamadas sueltas a Supabase fuera de QueryClient)
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(carrerasOptions()),
|
||||
queryClient.ensureQueryData(facultadesOptions()),
|
||||
])
|
||||
if (e1) throw e1
|
||||
if (e2) throw e2
|
||||
return {
|
||||
carreras: (carreras ?? []) as unknown as CarreraRow[],
|
||||
facultades: (facultades ?? []) as FacultadLite[],
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
/* -------------------- Helpers UI -------------------- */
|
||||
const tint = (hex?: string | null, a = .18) => {
|
||||
const tint = (hex?: string | null, a = 0.18) => {
|
||||
if (!hex) return `rgba(37,99,235,${a})`
|
||||
const h = hex.replace("#", "")
|
||||
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
|
||||
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
|
||||
const n = parseInt(v, 16)
|
||||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
|
||||
const r = (n >> 16) & 255,
|
||||
g = (n >> 8) & 255,
|
||||
b = n & 255
|
||||
return `rgba(${r},${g},${b},${a})`
|
||||
}
|
||||
const StatusPill = ({ active }: { active: boolean }) => (
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${active ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-neutral-100 text-neutral-700 border-neutral-200"}`}>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border ${
|
||||
active
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: "bg-neutral-100 text-neutral-700 border-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{active ? "Activa" : "Inactiva"}
|
||||
</span>
|
||||
)
|
||||
@@ -70,7 +128,10 @@ const StatusPill = ({ active }: { active: boolean }) => (
|
||||
/* -------------------- Página -------------------- */
|
||||
function RouteComponent() {
|
||||
const router = useRouter()
|
||||
const { carreras, facultades } = Route.useLoaderData() as LoaderData
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: carreras } = useSuspenseQuery(carrerasOptions())
|
||||
const { data: facultades } = useSuspenseQuery(facultadesOptions())
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [fac, setFac] = useState<string>("todas")
|
||||
@@ -87,8 +148,7 @@ function RouteComponent() {
|
||||
if (state === "activas" && !c.activo) return false
|
||||
if (state === "inactivas" && c.activo) return false
|
||||
if (!term) return true
|
||||
return [c.nombre, c.facultades?.nombre].filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(term))
|
||||
return [c.nombre, c.facultades?.nombre].filter(Boolean).some((v) => String(v).toLowerCase().includes(term))
|
||||
})
|
||||
}, [q, fac, state, carreras])
|
||||
|
||||
@@ -100,19 +160,18 @@ function RouteComponent() {
|
||||
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por nombre o facultad…"
|
||||
className="pl-8"
|
||||
/>
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre o facultad…" className="pl-8" />
|
||||
</div>
|
||||
|
||||
<Select value={fac} onValueChange={(v) => setFac(v)}>
|
||||
<SelectTrigger className="md:w-[220px]"><SelectValue placeholder="Facultad" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||||
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
|
||||
{facultades.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -125,7 +184,18 @@ function RouteComponent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: carrerasKeys.root }),
|
||||
qc.invalidateQueries({ queryKey: facultadesKeys.root }),
|
||||
])
|
||||
router.invalidate()
|
||||
}}
|
||||
title="Recargar"
|
||||
>
|
||||
<Icons.RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -137,10 +207,10 @@ function RouteComponent() {
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map(c => {
|
||||
{filtered.map((c) => {
|
||||
const fac = c.facultades
|
||||
const border = tint(fac?.color, .28)
|
||||
const chip = tint(fac?.color, .10)
|
||||
const border = tint(fac?.color, 0.28)
|
||||
const chip = tint(fac?.color, 0.1)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||
return (
|
||||
<article
|
||||
@@ -150,15 +220,12 @@ function RouteComponent() {
|
||||
>
|
||||
<div className="p-5 h-44 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2 bg-white/70"
|
||||
style={{ borderColor: border }}>
|
||||
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2 bg-white/70" style={{ borderColor: border }}>
|
||||
<IconComp className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{c.nombre}</div>
|
||||
<div className="text-xs text-neutral-600 truncate">
|
||||
{fac?.nombre ?? "—"} · {c.semestres} semestres
|
||||
</div>
|
||||
<div className="text-xs text-neutral-600 truncate">{fac?.nombre ?? "—"} · {c.semestres} semestres</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,9 +246,7 @@ function RouteComponent() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!filtered.length && (
|
||||
<div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>
|
||||
)}
|
||||
{!filtered.length && <div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -191,7 +256,10 @@ function RouteComponent() {
|
||||
onOpenChange={setCreateOpen}
|
||||
facultades={facultades}
|
||||
mode="create"
|
||||
onSaved={() => router.invalidate()}
|
||||
onSaved={async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
}}
|
||||
/>
|
||||
<CarreraFormDialog
|
||||
open={!!editCarrera}
|
||||
@@ -199,18 +267,34 @@ function RouteComponent() {
|
||||
facultades={facultades}
|
||||
mode="edit"
|
||||
carrera={editCarrera ?? undefined}
|
||||
onSaved={() => { setEditCarrera(null); router.invalidate() }}
|
||||
onSaved={async () => {
|
||||
setEditCarrera(null)
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Detalle + añadir criterio */}
|
||||
<CarreraDetailDialog carrera={detail} onOpenChange={setDetail} onChanged={() => router.invalidate()} />
|
||||
<CarreraDetailDialog
|
||||
carrera={detail}
|
||||
onOpenChange={setDetail}
|
||||
onChanged={async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
if (detail) await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(detail.id) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- Form crear/editar -------------------- */
|
||||
function CarreraFormDialog({
|
||||
open, onOpenChange, mode, carrera, facultades, onSaved,
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
carrera,
|
||||
facultades,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
@@ -240,7 +324,10 @@ function CarreraFormDialog({
|
||||
}, [mode, carrera, open])
|
||||
|
||||
async function save() {
|
||||
if (!nombre.trim()) { alert("Escribe un nombre"); return }
|
||||
if (!nombre.trim()) {
|
||||
alert("Escribe un nombre")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
nombre: nombre.trim(),
|
||||
@@ -249,13 +336,17 @@ function CarreraFormDialog({
|
||||
facultad_id: facultadId === "none" ? null : facultadId,
|
||||
}
|
||||
|
||||
const action = mode === "create"
|
||||
? supabase.from("carreras").insert([payload]).select("id").single()
|
||||
: supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single()
|
||||
const action =
|
||||
mode === "create"
|
||||
? supabase.from("carreras").insert([payload]).select("id").single()
|
||||
: supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single()
|
||||
|
||||
const { error } = await action
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message); return }
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
onSaved?.()
|
||||
}
|
||||
@@ -279,13 +370,7 @@ function CarreraFormDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Semestres</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={semestres}
|
||||
onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))}
|
||||
/>
|
||||
<Input type="number" min={1} max={20} value={semestres} onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Estado</Label>
|
||||
@@ -299,18 +384,28 @@ function CarreraFormDialog({
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<Select value={facultadId} onValueChange={(v) => setFacultadId(v as any)}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecciona una facultad (opcional)" /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una facultad (opcional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Sin facultad</SelectItem>
|
||||
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
|
||||
{facultades.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : (mode === "create" ? "Crear" : "Guardar")}</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Guardando…" : mode === "create" ? "Crear" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -327,41 +422,19 @@ function CarreraDetailDialog({
|
||||
onOpenChange: (c: CarreraRow | null) => void
|
||||
onChanged?: () => void
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [criterios, setCriterios] = useState<Array<{
|
||||
id: number
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
tipo: string | null
|
||||
obligatorio: boolean | null
|
||||
referencia_documento: string | null
|
||||
fecha_creacion: string | null
|
||||
}>>([])
|
||||
const carreraId = carrera?.id ?? ""
|
||||
const { data: criterios = [], isFetching } = useQuery({
|
||||
...criteriosOptions(carreraId || "noop"),
|
||||
enabled: !!carreraId,
|
||||
})
|
||||
const [q, setQ] = useState("")
|
||||
const [newCritOpen, setNewCritOpen] = useState(false)
|
||||
|
||||
async function load() {
|
||||
if (!carrera) return
|
||||
setLoading(true)
|
||||
const { data, error } = await supabase
|
||||
.from("criterios_carrera")
|
||||
.select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion")
|
||||
.eq("carrera_id", carrera.id)
|
||||
.order("fecha_creacion", { ascending: true })
|
||||
setLoading(false)
|
||||
if (error) { alert(error.message); return }
|
||||
setCriterios(data ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => { load() /* eslint-disable-next-line */ }, [carrera?.id])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
if (!t) return criterios
|
||||
return criterios.filter(c =>
|
||||
[c.nombre, c.descripcion, c.tipo, c.referencia_documento]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t)),
|
||||
return criterios.filter((c) =>
|
||||
[c.nombre, c.descripcion, c.tipo, c.referencia_documento].filter(Boolean).some((v) => String(v).toLowerCase().includes(t))
|
||||
)
|
||||
}, [q, criterios])
|
||||
|
||||
@@ -371,9 +444,10 @@ function CarreraDetailDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{carrera?.nombre}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "}
|
||||
{typeof carrera?.activo === "boolean" && (
|
||||
<Badge variant="outline" className="ml-2">{carrera?.activo ? "Activa" : "Inactiva"}</Badge>
|
||||
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres {typeof carrera?.activo === "boolean" && (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{carrera?.activo ? "Activa" : "Inactiva"}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -382,25 +456,18 @@ function CarreraDetailDialog({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar criterio por nombre, tipo o referencia…"
|
||||
className="pl-8"
|
||||
/>
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar criterio por nombre, tipo o referencia…" className="pl-8" />
|
||||
</div>
|
||||
<Button onClick={() => setNewCritOpen(true)}>
|
||||
<Icons.Plus className="h-4 w-4 mr-2" /> Añadir criterio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{isFetching ? (
|
||||
<div className="text-sm text-neutral-500">Cargando criterios…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-neutral-600">
|
||||
{filtered.length} criterio(s){q ? " (filtrado)" : ""}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-600">{filtered.length} criterio(s){q ? " (filtrado)" : ""}</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 py-6 text-center">No hay criterios</div>
|
||||
@@ -453,7 +520,7 @@ function CarreraDetailDialog({
|
||||
open={newCritOpen}
|
||||
onOpenChange={setNewCritOpen}
|
||||
carreraId={carrera?.id ?? ""}
|
||||
onSaved={() => { setNewCritOpen(false); load(); onChanged?.() }}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -462,13 +529,17 @@ function CarreraDetailDialog({
|
||||
|
||||
/* -------------------- Form crear criterio -------------------- */
|
||||
function CriterioFormDialog({
|
||||
open, onOpenChange, carreraId, onSaved,
|
||||
open,
|
||||
onOpenChange,
|
||||
carreraId,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
carreraId: string
|
||||
onSaved?: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [nombre, setNombre] = useState("")
|
||||
const [tipo, setTipo] = useState<string>("")
|
||||
@@ -478,24 +549,38 @@ function CriterioFormDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNombre(""); setTipo(""); setDescripcion(""); setObligatorio(true); setReferencia("")
|
||||
setNombre("")
|
||||
setTipo("")
|
||||
setDescripcion("")
|
||||
setObligatorio(true)
|
||||
setReferencia("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
if (!carreraId) return
|
||||
if (!nombre.trim()) { alert("Escribe un nombre"); return }
|
||||
if (!nombre.trim()) {
|
||||
alert("Escribe un nombre")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const { error } = await supabase.from("criterios_carrera").insert([{
|
||||
nombre: nombre.trim(),
|
||||
tipo: tipo || null,
|
||||
descripcion: descripcion || null,
|
||||
obligatorio,
|
||||
referencia_documento: referencia || null,
|
||||
carrera_id: carreraId,
|
||||
}])
|
||||
const { error } = await supabase.from("criterios_carrera").insert([
|
||||
{
|
||||
nombre: nombre.trim(),
|
||||
tipo: tipo || null,
|
||||
descripcion: descripcion || null,
|
||||
obligatorio,
|
||||
referencia_documento: referencia || null,
|
||||
carrera_id: carreraId,
|
||||
},
|
||||
])
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message); return }
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(carreraId) })
|
||||
onSaved?.()
|
||||
}
|
||||
|
||||
@@ -536,8 +621,12 @@ function CriterioFormDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Crear"}</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Guardando…" : "Crear"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { supabase, useSupabaseAuth } from '@/auth/supabase'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
@@ -18,6 +19,7 @@ type Plan = {
|
||||
sistema_evaluacion: string | null
|
||||
total_creditos: number | null
|
||||
}
|
||||
|
||||
type Asignatura = {
|
||||
id: string
|
||||
nombre: string
|
||||
@@ -34,78 +36,86 @@ type LoaderData = {
|
||||
recientes: Array<{ tipo: 'plan' | 'asignatura'; id: string; nombre: string; fecha: string | null }>
|
||||
}
|
||||
|
||||
/* ========= Loader ========= */
|
||||
/* ========= Query Key & Fetcher ========= */
|
||||
const dashboardKeys = {
|
||||
root: ['dashboard'] as const,
|
||||
summary: () => [...dashboardKeys.root, 'summary'] as const,
|
||||
}
|
||||
|
||||
async function fetchDashboard(): Promise<LoaderData> {
|
||||
const [facRes, carRes, planesRes, asigRes] = await Promise.all([
|
||||
supabase.from('facultades').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('carreras').select('*', { count: 'exact', head: true }),
|
||||
supabase
|
||||
.from('plan_estudios')
|
||||
.select(
|
||||
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
|
||||
),
|
||||
supabase
|
||||
.from('asignaturas')
|
||||
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia'),
|
||||
])
|
||||
|
||||
const planes = (planesRes.data ?? []) as Plan[]
|
||||
const asignaturas = (asigRes.data ?? []) as Asignatura[]
|
||||
|
||||
// Calidad de planes
|
||||
const needed: (keyof Plan)[] = [
|
||||
'objetivo_general',
|
||||
'perfil_ingreso',
|
||||
'perfil_egreso',
|
||||
'sistema_evaluacion',
|
||||
'total_creditos',
|
||||
]
|
||||
const completos = planes.filter((p) => needed.every((k) => p[k] !== null && String(p[k] ?? '').trim() !== '')).length
|
||||
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
|
||||
|
||||
// Salud de asignaturas
|
||||
const sinBibliografia = asignaturas.filter(
|
||||
(a) => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
|
||||
).length
|
||||
const sinCriterios = asignaturas.filter((a) => !a.criterios_evaluacion?.trim()).length
|
||||
const sinContenidos = asignaturas.filter(
|
||||
(a) => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
|
||||
).length
|
||||
|
||||
// Actividad reciente (últimos 8 ítems)
|
||||
const recientes = [
|
||||
...planes.map((p) => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })),
|
||||
...asignaturas.map((a) => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
|
||||
.slice(0, 8)
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
facultades: facRes.count ?? 0,
|
||||
carreras: carRes.count ?? 0,
|
||||
planes: planes.length,
|
||||
asignaturas: asignaturas.length,
|
||||
},
|
||||
calidadPlanesPct,
|
||||
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
||||
recientes,
|
||||
}
|
||||
}
|
||||
|
||||
const dashboardOptions = () =>
|
||||
queryOptions({ queryKey: dashboardKeys.summary(), queryFn: fetchDashboard, staleTime: 30_000 })
|
||||
|
||||
/* ========= Ruta ========= */
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: DashboardSkeleton,
|
||||
loader: async (): Promise<LoaderData> => {
|
||||
// KPI counts
|
||||
const [{ count: facCount }, { count: carCount }, { data: planesRaw }, { data: asignRaw }] =
|
||||
await Promise.all([
|
||||
supabase.from('facultades').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('carreras').select('*', { count: 'exact', head: true }),
|
||||
supabase
|
||||
.from('plan_estudios')
|
||||
.select(
|
||||
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
|
||||
),
|
||||
supabase
|
||||
.from('asignaturas')
|
||||
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia')
|
||||
])
|
||||
|
||||
const planes = (planesRaw ?? []) as Plan[]
|
||||
const asignaturas = (asignRaw ?? []) as Asignatura[]
|
||||
|
||||
// Calidad de planes
|
||||
const needed: (keyof Plan)[] = [
|
||||
'objetivo_general',
|
||||
'perfil_ingreso',
|
||||
'perfil_egreso',
|
||||
'sistema_evaluacion',
|
||||
'total_creditos'
|
||||
]
|
||||
const completos = planes.filter(p =>
|
||||
needed.every(k => p[k] !== null && String(p[k] ?? '').toString().trim() !== '')
|
||||
).length
|
||||
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
|
||||
|
||||
// Salud de asignaturas
|
||||
const sinBibliografia = asignaturas.filter(
|
||||
a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
|
||||
).length
|
||||
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion?.trim()).length
|
||||
const sinContenidos = asignaturas.filter(
|
||||
a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
|
||||
).length
|
||||
|
||||
// Actividad reciente (últimos 8 ítems)
|
||||
const recientes = [
|
||||
...planes.map(p => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })),
|
||||
...asignaturas.map(a => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion }))
|
||||
]
|
||||
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
|
||||
.slice(0, 8)
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
facultades: facCount ?? 0,
|
||||
carreras: carCount ?? 0,
|
||||
planes: planes.length,
|
||||
asignaturas: asignaturas.length
|
||||
},
|
||||
calidadPlanesPct,
|
||||
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
||||
recientes
|
||||
}
|
||||
}
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(dashboardOptions())
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
/* ========= Helpers visuales ========= */
|
||||
function gradient(bg = '#2563eb') {
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)`
|
||||
} as React.CSSProperties
|
||||
return { background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)` } as React.CSSProperties
|
||||
}
|
||||
function hex(color?: string | null, fallback = '#2563eb') {
|
||||
return color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : fallback
|
||||
@@ -139,22 +149,9 @@ function Ring({ pct, color }: { pct: number; color: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({
|
||||
to,
|
||||
label,
|
||||
value,
|
||||
Icon
|
||||
}: {
|
||||
to: string
|
||||
label: string
|
||||
value: number | string
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
}) {
|
||||
function Tile({ to, label, value, Icon }: { to: string; label: string; value: number | string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<Link to={to} className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all">
|
||||
<div>
|
||||
<div className="text-sm text-neutral-500">{label}</div>
|
||||
<div className="text-3xl font-bold tabular-nums">{value}</div>
|
||||
@@ -168,21 +165,18 @@ function Tile({
|
||||
|
||||
/* ========= Página ========= */
|
||||
function RouteComponent() {
|
||||
const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = Route.useLoaderData() as LoaderData
|
||||
const { data } = useSuspenseQuery(dashboardOptions())
|
||||
const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = data
|
||||
|
||||
const auth = useSupabaseAuth()
|
||||
const router = useRouter()
|
||||
const primary = hex(auth.claims?.facultad_color, '#1d4ed8') // si guardan color de facultad en claims
|
||||
const qc = useQueryClient()
|
||||
const primary = hex(auth.claims?.facultad_color, '#1d4ed8')
|
||||
|
||||
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
|
||||
|
||||
const isAdmin = !!auth.claims?.claims_admin
|
||||
const role = auth.claims?.role as
|
||||
| 'lci'
|
||||
| 'vicerrectoria'
|
||||
| 'secretario_academico'
|
||||
| 'jefe_carrera'
|
||||
| 'planeacion'
|
||||
| undefined
|
||||
const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
|
||||
|
||||
// Mensaje contextual
|
||||
const roleHint = useMemo(() => {
|
||||
@@ -204,10 +198,7 @@ function RouteComponent() {
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Header con saludo y búsqueda global */}
|
||||
<div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={gradient(primary)}>
|
||||
<div
|
||||
className="absolute inset-0 opacity-25"
|
||||
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-25" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
|
||||
<div className="relative p-6 md:p-8 flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -215,7 +206,11 @@ function RouteComponent() {
|
||||
<p className="opacity-95">{roleHint}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{role && <Badge variant="secondary" className="bg-white/20 text-white border-white/30">{role}</Badge>}
|
||||
{role && (
|
||||
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
|
||||
{role}
|
||||
</Badge>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Badge className="bg-white/20 text-white border-white/30 flex items-center gap-1">
|
||||
<Icons.ShieldCheck className="w-3.5 h-3.5" /> admin
|
||||
@@ -228,7 +223,7 @@ function RouteComponent() {
|
||||
<Input
|
||||
placeholder="Buscar planes, asignaturas o personas… (Enter)"
|
||||
className="bg-white/90 text-neutral-800 placeholder:text-neutral-400"
|
||||
onKeyDown={e => {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const q = (e.target as HTMLInputElement).value.trim()
|
||||
if (!q) return
|
||||
@@ -239,7 +234,10 @@ function RouteComponent() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30 border-white/30"
|
||||
onClick={() => router.invalidate()}
|
||||
onClick={async () => {
|
||||
await qc.invalidateQueries({ queryKey: dashboardKeys.root })
|
||||
router.invalidate()
|
||||
}}
|
||||
title="Actualizar"
|
||||
>
|
||||
<Icons.RefreshCcw className="w-4 h-4" />
|
||||
@@ -248,23 +246,18 @@ function RouteComponent() {
|
||||
|
||||
{/* Atajos rápidos (según rol) */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
|
||||
>
|
||||
<Link to="/planes" className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
|
||||
<Icons.ScrollText className="w-4 h-4" /> Nuevo plan
|
||||
</Link>
|
||||
<Link
|
||||
to="/asignaturas"
|
||||
search={{ carreraId: '', f: '', facultadId: '', planId: '', q: '' }}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
|
||||
>
|
||||
<Icons.BookOpen className="w-4 h-4" /> Nueva asignatura
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/usuarios"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
|
||||
>
|
||||
<Link to="/usuarios" className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
|
||||
<Icons.UserPlus className="w-4 h-4" /> Invitar usuario
|
||||
</Link>
|
||||
)}
|
||||
@@ -303,21 +296,9 @@ function RouteComponent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<HealthRow
|
||||
to="/_authenticated/asignaturas?f=sinBibliografia"
|
||||
label="Sin bibliografía"
|
||||
value={saludAsignaturas.sinBibliografia}
|
||||
/>
|
||||
<HealthRow
|
||||
to="/_authenticated/asignaturas?f=sinCriterios"
|
||||
label="Sin criterios de evaluación"
|
||||
value={saludAsignaturas.sinCriterios}
|
||||
/>
|
||||
<HealthRow
|
||||
to="/_authenticated/asignaturas?f=sinContenidos"
|
||||
label="Sin contenidos"
|
||||
value={saludAsignaturas.sinContenidos}
|
||||
/>
|
||||
<HealthRow to="/_authenticated/asignaturas?f=sinBibliografia" label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} />
|
||||
<HealthRow to="/_authenticated/asignaturas?f=sinCriterios" label="Sin criterios de evaluación" value={saludAsignaturas.sinCriterios} />
|
||||
<HealthRow to="/_authenticated/asignaturas?f=sinContenidos" label="Sin contenidos" value={saludAsignaturas.sinContenidos} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -330,11 +311,9 @@ function RouteComponent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recientes.length === 0 && (
|
||||
<div className="text-sm text-neutral-500">Sin actividad registrada.</div>
|
||||
)}
|
||||
{recientes.length === 0 && <div className="text-sm text-neutral-500">Sin actividad registrada.</div>}
|
||||
<ul className="divide-y">
|
||||
{recientes.map(r => (
|
||||
{recientes.map((r) => (
|
||||
<li key={`${r.tipo}-${r.id}`} className="py-2 flex items-center justify-between gap-3">
|
||||
<Link
|
||||
to={r.tipo === 'plan' ? '/plan/$planId' : '/asignatura/$asignaturaId'}
|
||||
@@ -349,9 +328,7 @@ function RouteComponent() {
|
||||
)}
|
||||
<span className="truncate">{r.nombre}</span>
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -367,10 +344,9 @@ function HealthRow({ label, value, to }: { label: string; value: number; to: str
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`flex items-center justify-between rounded-xl px-4 py-3 ring-1 ${warn
|
||||
? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100'
|
||||
: 'ring-neutral-200 hover:bg-neutral-50'
|
||||
} transition-colors`}
|
||||
className={`flex items-center justify-between rounded-xl px-4 py-3 ring-1 ${
|
||||
warn ? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100' : 'ring-neutral-200 hover:bg-neutral-50'
|
||||
} transition-colors`}
|
||||
>
|
||||
<span className="text-sm">{label}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from '@tanstack/react-query'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
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'
|
||||
@@ -9,25 +10,38 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Facultad = {
|
||||
/* -------------------- Tipos -------------------- */
|
||||
export type Facultad = {
|
||||
id: string
|
||||
nombre: string
|
||||
icon: string
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
/* -------------------- Query Keys & Fetchers -------------------- */
|
||||
const facultadKeys = {
|
||||
root: ['facultades'] as const,
|
||||
all: () => [...facultadKeys.root, 'all'] as const,
|
||||
}
|
||||
|
||||
async function fetchFacultades(): Promise<Facultad[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('facultades')
|
||||
.select('id, nombre, icon, color')
|
||||
.order('nombre')
|
||||
if (error) throw error
|
||||
return (data ?? []) as Facultad[]
|
||||
}
|
||||
|
||||
const facultadesOptions = () =>
|
||||
queryOptions({ queryKey: facultadKeys.all(), queryFn: fetchFacultades, staleTime: 60_000 })
|
||||
|
||||
/* -------------------- Ruta -------------------- */
|
||||
export const Route = createFileRoute('/_authenticated/facultades')({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('facultades')
|
||||
.select('id, nombre, icon, color')
|
||||
.order('nombre')
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return { facultades: [] as Facultad[] }
|
||||
}
|
||||
return { facultades: (data ?? []) as Facultad[] }
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(facultadesOptions())
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
@@ -54,28 +68,59 @@ const PALETTE: { name: string; hex: `#${string}` }[] = [
|
||||
/* 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'
|
||||
'Atom', 'FlaskConical', 'Microscope', 'Cpu', 'Hammer', 'Palette', 'Shapes', 'BookOpen', 'GraduationCap',
|
||||
] as const
|
||||
type IconName = typeof ICON_CHOICES[number]
|
||||
export type IconName = (typeof ICON_CHOICES)[number]
|
||||
|
||||
function gradientFrom(color?: string | null) {
|
||||
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
|
||||
const base = color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : '#2563eb'
|
||||
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
|
||||
}
|
||||
|
||||
/* -------------------- Página -------------------- */
|
||||
function RouteComponent() {
|
||||
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
|
||||
const router = useRouter()
|
||||
const qc = useQueryClient()
|
||||
const { data: facultades } = useSuspenseQuery(facultadesOptions())
|
||||
|
||||
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' }
|
||||
{ nombre: '', icon: 'Building2', color: '#2563EB' },
|
||||
)
|
||||
const [editing, setEditing] = useState<Facultad | null>(null)
|
||||
|
||||
/* --------- Mutations (create / update) --------- */
|
||||
const createFacultad = useMutation({
|
||||
mutationFn: async (payload: { nombre: string; icon: IconName; color: `#${string}` }) => {
|
||||
const { error } = await supabase.from('facultades').insert(payload)
|
||||
if (error) throw new Error(error.message)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Facultad creada ✨')
|
||||
setCreateOpen(false)
|
||||
await qc.invalidateQueries({ queryKey: facultadKeys.root })
|
||||
router.invalidate()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || 'No se pudo crear'),
|
||||
})
|
||||
|
||||
const updateFacultad = useMutation({
|
||||
mutationFn: async ({ id, ...payload }: { id: string; nombre: string; icon: IconName; color: `#${string}` }) => {
|
||||
const { error } = await supabase.from('facultades').update(payload).eq('id', id)
|
||||
if (error) throw new Error(error.message)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Cambios guardados ✅')
|
||||
setEditOpen(false)
|
||||
setEditing(null)
|
||||
await qc.invalidateQueries({ queryKey: facultadKeys.root })
|
||||
router.invalidate()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || 'No se pudo guardar'),
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
setForm({ nombre: '', icon: 'Building2', color: '#2563EB' })
|
||||
setCreateOpen(true)
|
||||
@@ -84,39 +129,12 @@ function RouteComponent() {
|
||||
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')
|
||||
icon: (ICON_CHOICES.includes(f.icon as IconName) ? (f.icon as IconName) : 'Building2'),
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
@@ -125,7 +143,13 @@ function RouteComponent() {
|
||||
<Icons.Building2 className="w-5 h-5" /> Facultades
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.invalidate()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await qc.invalidateQueries({ queryKey: facultadKeys.root })
|
||||
router.invalidate()
|
||||
}}
|
||||
>
|
||||
<Icons.RefreshCcw className="w-4 h-4 mr-2" /> Recargar
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
@@ -138,8 +162,7 @@ function RouteComponent() {
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{facultades.map((fac) => {
|
||||
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building2
|
||||
const bg = { background: gradientFrom(fac.color) } // ← sin useMemo aquí
|
||||
|
||||
const bg = { background: gradientFrom(fac.color) }
|
||||
return (
|
||||
<div key={fac.id} className="group relative rounded-3xl overflow-hidden shadow-xl border">
|
||||
<Link
|
||||
@@ -163,12 +186,7 @@ function RouteComponent() {
|
||||
</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)}
|
||||
>
|
||||
<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>
|
||||
@@ -177,7 +195,6 @@ function RouteComponent() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Dialog Crear */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -189,7 +206,15 @@ function RouteComponent() {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={doCreate} disabled={saving}>{saving ? 'Guardando…' : 'Crear'}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
|
||||
createFacultad.mutate({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
|
||||
}}
|
||||
disabled={createFacultad.isPending}
|
||||
>
|
||||
{createFacultad.isPending ? 'Guardando…' : 'Crear'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -205,7 +230,16 @@ function RouteComponent() {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setEditOpen(false); setEditing(null) }}>Cancelar</Button>
|
||||
<Button onClick={doEdit} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
|
||||
updateFacultad.mutate({ id: editing.id, nombre: form.nombre.trim(), icon: form.icon, color: form.color })
|
||||
}}
|
||||
disabled={updateFacultad.isPending}
|
||||
>
|
||||
{updateFacultad.isPending ? 'Guardando…' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -215,7 +249,8 @@ function RouteComponent() {
|
||||
|
||||
/* ----------- Subcomponentes ----------- */
|
||||
function FormFields({
|
||||
form, setForm
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: { nombre: string; icon: IconName; color: `#${string}` }
|
||||
setForm: React.Dispatch<React.SetStateAction<{ nombre: string; icon: IconName; color: `#${string}` }>>
|
||||
@@ -236,24 +271,22 @@ function FormFields({
|
||||
{/* 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"
|
||||
/>
|
||||
<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 }))}>
|
||||
<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 => {
|
||||
{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>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Ico className="w-4 h-4" /> {k}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
@@ -264,10 +297,7 @@ function FormFields({
|
||||
{/* Color (paleta curada) */}
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<ColorGrid
|
||||
value={form.color}
|
||||
onChange={(hex) => setForm(s => ({ ...s, color: hex }))}
|
||||
/>
|
||||
<ColorGrid value={form.color} onChange={(hex) => setForm((s) => ({ ...s, color: hex }))} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -276,20 +306,17 @@ function FormFields({
|
||||
function ColorGrid({ value, onChange }: { value: `#${string}`; onChange: (hex: `#${string}`) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{PALETTE.map(c => (
|
||||
{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]'}`}
|
||||
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" />
|
||||
)}
|
||||
{value === c.hex && <Icons.Check className="absolute right-1.5 bottom-1.5 w-4 h-4 text-white drop-shadow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { supabase, useSupabaseAuth } from '@/auth/supabase'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import gsap from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { AcademicSections } from '@/components/planes/academic-sections'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
|
||||
import confetti from 'canvas-confetti'
|
||||
import { AuroraButton } from '@/components/effect/aurora-button'
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import * as Icons from "lucide-react"
|
||||
import { useQueryClient, useSuspenseQuery, useMutation } from "@tanstack/react-query"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
|
||||
import { AcademicSections } from "@/components/planes/academic-sections"
|
||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import confetti from "canvas-confetti"
|
||||
import gsap from "gsap"
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger"
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
type PlanFull = {
|
||||
/* ================= Query Keys & Options ================= */
|
||||
const planKeys = {
|
||||
byId: (id: string) => ["plan", id] as const,
|
||||
}
|
||||
const asignaturaKeys = {
|
||||
count: (planId: string) => ["asignaturas", "count", planId] as const,
|
||||
preview: (planId: string) => ["asignaturas", "preview", planId] as const,
|
||||
extra: (asigId: string) => ["asignatura", "extra", asigId] as const,
|
||||
}
|
||||
|
||||
export type PlanFull = {
|
||||
id: string; nombre: string; nivel: string | null;
|
||||
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
|
||||
duracion: string | null; total_creditos: number | null;
|
||||
@@ -28,44 +39,95 @@ type PlanFull = {
|
||||
estado: string | null; fecha_creacion: string | null;
|
||||
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
|
||||
}
|
||||
type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
|
||||
type LoaderData = { plan: PlanFull; asignaturasCount: number; asignaturasPreview: AsignaturaLite[] }
|
||||
export type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
|
||||
|
||||
type LoaderData = { planId: string }
|
||||
|
||||
/* ---------- Query option builders ---------- */
|
||||
function planByIdOptions(planId: string) {
|
||||
return {
|
||||
queryKey: planKeys.byId(planId),
|
||||
queryFn: async (): Promise<PlanFull> => {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select(`
|
||||
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
||||
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
||||
pertinencia, prompt, estado, fecha_creacion,
|
||||
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||
`)
|
||||
.eq("id", planId)
|
||||
.maybeSingle()
|
||||
if (error || !data) throw error ?? new Error("Plan no encontrado")
|
||||
return data as unknown as PlanFull
|
||||
},
|
||||
staleTime: 60_000,
|
||||
} as const
|
||||
}
|
||||
function asignaturasCountOptions(planId: string) {
|
||||
return {
|
||||
queryKey: asignaturaKeys.count(planId),
|
||||
queryFn: async (): Promise<number> => {
|
||||
const { count, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("plan_id", planId)
|
||||
if (error) throw error
|
||||
return count ?? 0
|
||||
},
|
||||
staleTime: 30_000,
|
||||
} as const
|
||||
}
|
||||
function asignaturasPreviewOptions(planId: string) {
|
||||
return {
|
||||
queryKey: asignaturaKeys.preview(planId),
|
||||
queryFn: async (): Promise<AsignaturaLite[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("id, nombre, semestre, creditos")
|
||||
.eq("plan_id", planId)
|
||||
.order("semestre", { ascending: true })
|
||||
.order("nombre", { ascending: true })
|
||||
.limit(8)
|
||||
if (error) throw error
|
||||
return (data ?? []) as unknown as AsignaturaLite[]
|
||||
},
|
||||
staleTime: 30_000,
|
||||
} as const
|
||||
}
|
||||
function asignaturaExtraOptions(asigId: string) {
|
||||
return {
|
||||
queryKey: asignaturaKeys.extra(asigId),
|
||||
queryFn: async (): Promise<{
|
||||
tipo: string | null
|
||||
horas_teoricas: number | null
|
||||
horas_practicas: number | null
|
||||
contenidos: Record<string, Record<string, string>> | null
|
||||
} | null> => {
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("tipo, horas_teoricas, horas_practicas, contenidos")
|
||||
.eq("id", asigId)
|
||||
.maybeSingle()
|
||||
if (error) throw error
|
||||
return (data as any) ?? null
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
/* ============== ROUTE ============== */
|
||||
export const Route = createFileRoute('/_authenticated/plan/$planId')({
|
||||
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||
component: RouteComponent,
|
||||
pendingComponent: PageSkeleton,
|
||||
loader: async ({ params }): Promise<LoaderData> => {
|
||||
const { data: plan, error } = await supabase
|
||||
.from('plan_estudios')
|
||||
.select(`
|
||||
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
||||
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
||||
pertinencia, prompt, estado, fecha_creacion,
|
||||
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||
`)
|
||||
.eq('id', params.planId)
|
||||
.single()
|
||||
if (error || !plan) throw error ?? new Error('Plan no encontrado')
|
||||
|
||||
const { count } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('plan_id', params.planId)
|
||||
|
||||
const { data: asignaturasPreview } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('id, nombre, semestre, creditos')
|
||||
.eq('plan_id', params.planId)
|
||||
.order('semestre', { ascending: true })
|
||||
.order('nombre', { ascending: true })
|
||||
.limit(8)
|
||||
|
||||
return {
|
||||
plan: plan as unknown as PlanFull,
|
||||
asignaturasCount: count ?? 0,
|
||||
asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[],
|
||||
}
|
||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||
const { planId } = params
|
||||
// Prefetch/ensure all queries needed for the page
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(planByIdOptions(planId)),
|
||||
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
||||
])
|
||||
return { planId }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -129,12 +191,18 @@ function GradientMesh({ color }: { color?: string | null }) {
|
||||
/* ============== PAGE ============== */
|
||||
function RouteComponent() {
|
||||
const router = useRouter()
|
||||
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||
const qc = useQueryClient()
|
||||
const { planId } = Route.useLoaderData() as LoaderData
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
// Fetch via React Query (suspense)
|
||||
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
||||
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
||||
|
||||
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||
const showCarrera = auth.claims?.role === 'secretario_academico'
|
||||
|
||||
|
||||
const fac = plan.carreras?.facultades
|
||||
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||
@@ -145,7 +213,6 @@ function RouteComponent() {
|
||||
const fieldsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Header intro
|
||||
if (headerRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
||||
@@ -156,9 +223,7 @@ function RouteComponent() {
|
||||
return () => ctx.revert()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Stats y campos con ScrollTrigger
|
||||
if (statsRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from('.academics', {
|
||||
@@ -182,11 +247,11 @@ function RouteComponent() {
|
||||
return () => ctx.revert()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const facColor = plan.carreras?.facultades?.color ?? null
|
||||
|
||||
return (
|
||||
<div className="relative p-6 space-y-6">
|
||||
{/* Mesh global */}
|
||||
<GradientMesh color={fac?.color} />
|
||||
|
||||
<nav className="relative text-sm text-neutral-500">
|
||||
@@ -195,9 +260,8 @@ function RouteComponent() {
|
||||
<span className="text-primary">{plan.nombre}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header con acciones y brillo */}
|
||||
{/* Header */}
|
||||
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||
{/* velo de color muy suave */}
|
||||
<div className="absolute inset-0 -z-0" style={accent} />
|
||||
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -228,37 +292,30 @@ function RouteComponent() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* stats */}
|
||||
<CardContent
|
||||
ref={statsRef}
|
||||
>
|
||||
<CardContent ref={statsRef}>
|
||||
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||
|
||||
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
|
||||
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
|
||||
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
|
||||
<StatCard
|
||||
label="Creado"
|
||||
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
|
||||
Icon={Icons.CalendarDays}
|
||||
accent={facColor}
|
||||
/>
|
||||
<StatCard label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} Icon={Icons.CalendarDays} accent={facColor} />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
|
||||
<div className="academics">
|
||||
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
|
||||
<AcademicSections planId={plan.id} color={fac?.color} />
|
||||
</div>
|
||||
|
||||
{/* ===== Asignaturas (preview cards) ===== */}
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
|
||||
<AddAsignaturaButton planId={plan.id} onAdded={() => {
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(plan.id) })
|
||||
}} />
|
||||
<Link
|
||||
to="/asignaturas/$planId"
|
||||
search={{ q: "", planId: plan.id, carreraId: "", f: "", facultadId: "" }}
|
||||
@@ -283,7 +340,6 @@ function RouteComponent() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -298,48 +354,35 @@ function hexToRgbA(hex?: string | null, a = .25) {
|
||||
}
|
||||
|
||||
const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—"
|
||||
|
||||
/* ===== UI bits ===== */
|
||||
type StatProps = {
|
||||
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: {
|
||||
label: string
|
||||
value?: React.ReactNode
|
||||
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
accent?: string | null // color de facultad (hex) opcional
|
||||
accent?: string | null
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: StatProps) {
|
||||
}) {
|
||||
const border = hexToRgbA(accent, .28)
|
||||
const chipBg = hexToRgbA(accent, .08)
|
||||
const glow = hexToRgbA(accent, .14)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5
|
||||
bg-white/70 dark:bg-neutral-900/60 backdrop-blur
|
||||
shadow-sm hover:shadow-md transition-all ${className}`}
|
||||
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5 bg-white/70 dark:bg-neutral-900/60 backdrop-blur shadow-sm hover:shadow-md transition-all ${className}`}
|
||||
style={{ borderColor: border }}
|
||||
title={title ?? (typeof value === "string" ? value : undefined)}
|
||||
aria-label={`${label}: ${typeof value === "string" ? value : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-neutral-500">{label}</div>
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border"
|
||||
style={{ borderColor: border, background: chipBg }}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border" style={{ borderColor: border, background: chipBg }}>
|
||||
<Icon className="h-4 w-4 opacity-80" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{/* 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%, ${glow}, transparent 60%)` }}
|
||||
/>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">{value}</div>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -349,17 +392,38 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<Partial<PlanFull>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (payload: Partial<PlanFull>) => {
|
||||
const { error } = await supabase.from('plan_estudios').update({
|
||||
nombre: payload.nombre ?? plan.nombre,
|
||||
nivel: payload.nivel ?? plan.nivel,
|
||||
duracion: payload.duracion ?? plan.duracion,
|
||||
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
||||
}).eq('id', plan.id)
|
||||
if (error) throw error
|
||||
},
|
||||
onMutate: async (payload) => {
|
||||
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
|
||||
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
|
||||
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
|
||||
return { prev }
|
||||
},
|
||||
onError: (_e, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
|
||||
},
|
||||
onSettled: async () => {
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const { error } = await supabase.from('plan_estudios').update({
|
||||
nombre: form.nombre ?? plan.nombre,
|
||||
nivel: form.nivel ?? plan.nivel,
|
||||
duracion: form.duracion ?? plan.duracion,
|
||||
total_creditos: form.total_creditos ?? plan.total_creditos,
|
||||
}).eq('id', plan.id)
|
||||
setSaving(false)
|
||||
if (!error) setOpen(false)
|
||||
try {
|
||||
await mutation.mutateAsync(form)
|
||||
setOpen(false)
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -380,7 +444,7 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||
<Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
<Button onClick={save} disabled={saving || mutation.isPending}>{(saving || mutation.isPending) ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -410,7 +474,6 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
|
||||
}).catch(() => { })
|
||||
setLoading(false)
|
||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -463,20 +526,101 @@ function PageSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function AddAsignaturaButton({
|
||||
planId, onAdded,
|
||||
}: { planId: string; onAdded?: () => void }) {
|
||||
/* ===== Asignaturas ===== */
|
||||
function AsignaturaPreviewCard({ asignatura }: { asignatura: AsignaturaLite }) {
|
||||
const { data: extra } = useSuspenseQuery(asignaturaExtraOptions(asignatura.id))
|
||||
|
||||
const horasT = extra?.horas_teoricas ?? null
|
||||
const horasP = extra?.horas_practicas ?? null
|
||||
const horasTot = (horasT ?? 0) + (horasP ?? 0)
|
||||
|
||||
const resumenContenidos = useMemo(() => {
|
||||
const c = extra?.contenidos
|
||||
if (!c) return { unidades: 0, temas: 0 }
|
||||
const unidades = Object.keys(c).length
|
||||
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
|
||||
return { unidades, temas }
|
||||
}, [extra?.contenidos])
|
||||
|
||||
const tipo = (extra?.tipo ?? "").toLowerCase()
|
||||
const tipoChip =
|
||||
tipo.includes("oblig") ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
|
||||
tipo.includes("opt") ? "bg-amber-50 text-amber-800 border-amber-200" :
|
||||
tipo.includes("taller") ? "bg-indigo-50 text-indigo-700 border-indigo-200" :
|
||||
tipo.includes("lab") ? "bg-sky-50 text-sky-700 border-sky-200" :
|
||||
"bg-neutral-100 text-neutral-700 border-neutral-200"
|
||||
|
||||
return (
|
||||
<article className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5" role="region" aria-label={asignatura.nombre}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
<Icons.BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={asignatura.nombre}>{asignatura.nombre}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{asignatura.semestre != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
|
||||
</span>
|
||||
)}
|
||||
{asignatura.creditos != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
|
||||
</span>
|
||||
)}
|
||||
{extra?.tipo && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
|
||||
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
|
||||
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
|
||||
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
|
||||
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-[11px] text-neutral-500">
|
||||
{horasT != null || horasP != null ? (
|
||||
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
|
||||
) : (
|
||||
<span className="opacity-70">Resumen listo</span>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: asignatura.id }} className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50" title="Ver detalle">
|
||||
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== Crear Asignatura ===== */
|
||||
function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mode, setMode] = useState<"manual" | "ia">("manual")
|
||||
|
||||
// --- Manual ---
|
||||
const [f, setF] = useState({
|
||||
nombre: "", clave: "", tipo: "", semestre: "", creditos: "",
|
||||
horas_teoricas: "", horas_practicas: "", objetivos: "",
|
||||
})
|
||||
|
||||
// --- IA ---
|
||||
const [f, setF] = useState({ nombre: "", clave: "", tipo: "", semestre: "", creditos: "", horas_teoricas: "", horas_practicas: "", objetivos: "" })
|
||||
const [iaPrompt, setIaPrompt] = useState("")
|
||||
const [iaSemestre, setIaSemestre] = useState("")
|
||||
|
||||
@@ -505,7 +649,11 @@ function AddAsignaturaButton({
|
||||
const { error } = await supabase.from("asignaturas").insert([payload])
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message); return }
|
||||
setOpen(false); onAdded?.()
|
||||
setOpen(false)
|
||||
onAdded?.()
|
||||
// Warm up cache quickly
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||
}
|
||||
|
||||
async function createWithAI() {
|
||||
@@ -515,22 +663,17 @@ function AddAsignaturaButton({
|
||||
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
planEstudiosId: planId,
|
||||
prompt: iaPrompt,
|
||||
semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined,
|
||||
insert: true, // que la API inserte en DB
|
||||
}),
|
||||
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||
|
||||
setOpen(false); onAdded?.()
|
||||
setOpen(false)
|
||||
onAdded?.()
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al generar la asignatura")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const submit = () => (mode === "manual" ? createManual() : createWithAI())
|
||||
@@ -548,86 +691,41 @@ function AddAsignaturaButton({
|
||||
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Conmutador elegante */}
|
||||
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
|
||||
<TabsList
|
||||
className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1"
|
||||
aria-label="Modo de creación"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="manual"
|
||||
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1" aria-label="Modo de creación">
|
||||
<TabsTrigger value="manual" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
|
||||
<Icons.PencilLine className="h-4 w-4 mr-2" /> Manual
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ia"
|
||||
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsTrigger value="ia" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
|
||||
<Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* --- Pestaña: Manual --- */}
|
||||
<TabsContent value="manual" className="mt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Nombre">
|
||||
<Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Clave">
|
||||
<Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Tipo">
|
||||
<Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" />
|
||||
</Field>
|
||||
<Field label="Semestre">
|
||||
<Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="1–10" />
|
||||
</Field>
|
||||
<Field label="Créditos">
|
||||
<Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Horas teóricas">
|
||||
<Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Horas prácticas">
|
||||
<Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} />
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="Objetivo (opcional)">
|
||||
<Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Nombre"><Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} /></Field>
|
||||
<Field label="Clave"><Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} /></Field>
|
||||
<Field label="Tipo"><Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /></Field>
|
||||
<Field label="Semestre"><Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="1–10" /></Field>
|
||||
<Field label="Créditos"><Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} /></Field>
|
||||
<Field label="Horas teóricas"><Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} /></Field>
|
||||
<Field label="Horas prácticas"><Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} /></Field>
|
||||
<div className="sm:col-span-2"><Field label="Objetivo (opcional)"><Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" /></Field></div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* --- Pestaña: IA --- */}
|
||||
<TabsContent value="ia" className="mt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="Indica el enfoque / requisitos">
|
||||
<Textarea
|
||||
value={iaPrompt}
|
||||
onChange={e => setIaPrompt(e.target.value)}
|
||||
className="min-h-[120px]"
|
||||
placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Periodo (opcional)">
|
||||
<Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="1–10" />
|
||||
</Field>
|
||||
<div className="sm:col-span-2"><Field label="Indica el enfoque / requisitos"><Textarea value={iaPrompt} onChange={e => setIaPrompt(e.target.value)} className="min-h-[120px]" placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…" /></Field></div>
|
||||
<Field label="Periodo (opcional)"><Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="1–10" /></Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button >
|
||||
</Button>
|
||||
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
|
||||
{saving
|
||||
? (mode === "manual" ? "Guardando…" : "Generando…")
|
||||
: (mode === "manual" ? "Crear" : "Generar e insertar")}
|
||||
{saving ? (mode === "manual" ? "Guardando…" : "Generando…") : (mode === "manual" ? "Crear" : "Generar e insertar")}
|
||||
</AuroraButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -635,146 +733,3 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// routes/_authenticated/usuarios.tsx
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
|
||||
} from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
@@ -20,6 +19,7 @@ import { SupabaseClient } from "@supabase/supabase-js"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/* -------------------- Tipos -------------------- */
|
||||
type AdminUser = {
|
||||
id: string
|
||||
email: string | null
|
||||
@@ -27,58 +27,28 @@ type AdminUser = {
|
||||
last_sign_in_at: string | null
|
||||
user_metadata: any
|
||||
app_metadata: any
|
||||
banned_until?: string | null // NEW: lo usamos en UI
|
||||
banned_until?: string | null
|
||||
}
|
||||
|
||||
// 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
|
||||
"director_facultad",
|
||||
"secretario_academico",
|
||||
"jefe_carrera",
|
||||
"planeacion",
|
||||
] as const
|
||||
export type Role = typeof ROLES[number]
|
||||
|
||||
const ROLE_META: Record<Role, {
|
||||
label: string
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
className: string
|
||||
}> = {
|
||||
lci: {
|
||||
label: "Laboratorio de Cómputo de Ingeniería",
|
||||
Icon: Cpu,
|
||||
className: "bg-neutral-900 text-white"
|
||||
},
|
||||
vicerrectoria: {
|
||||
label: "Vicerrectoría Académica",
|
||||
Icon: Building2,
|
||||
className: "bg-indigo-600 text-white"
|
||||
},
|
||||
director_facultad: { // NEW
|
||||
label: "Director(a) de Facultad",
|
||||
Icon: Building2,
|
||||
className: "bg-purple-600 text-white"
|
||||
},
|
||||
secretario_academico: {
|
||||
label: "Secretario Académico",
|
||||
Icon: ScrollText,
|
||||
className: "bg-emerald-600 text-white"
|
||||
},
|
||||
jefe_carrera: {
|
||||
label: "Jefe de Carrera",
|
||||
Icon: GraduationCap,
|
||||
className: "bg-orange-600 text-white"
|
||||
},
|
||||
planeacion: {
|
||||
label: "Planeación Curricular",
|
||||
Icon: GanttChart,
|
||||
className: "bg-sky-600 text-white"
|
||||
}
|
||||
const ROLE_META: Record<Role, { label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }> = {
|
||||
lci: { label: "Laboratorio de Cómputo de Ingeniería", Icon: Cpu, className: "bg-neutral-900 text-white" },
|
||||
vicerrectoria: { label: "Vicerrectoría Académica", Icon: Building2, className: "bg-indigo-600 text-white" },
|
||||
director_facultad: { label: "Director(a) de Facultad", Icon: Building2, className: "bg-purple-600 text-white" },
|
||||
secretario_academico: { label: "Secretario Académico", Icon: ScrollText, className: "bg-emerald-600 text-white" },
|
||||
jefe_carrera: { label: "Jefe de Carrera", Icon: GraduationCap, className: "bg-orange-600 text-white" },
|
||||
planeacion: { label: "Planeación Curricular", Icon: GanttChart, className: "bg-sky-600 text-white" },
|
||||
}
|
||||
|
||||
function RolePill({ role }: { role: Role }) {
|
||||
@@ -86,46 +56,56 @@ function RolePill({ role }: { role: Role }) {
|
||||
if (!meta) return null
|
||||
const { Icon, className, label } = meta
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`}
|
||||
title={label}
|
||||
>
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||
const usersKeys = {
|
||||
root: ["usuarios"] as const,
|
||||
list: () => [...usersKeys.root, "list"] as const,
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<AdminUser[]> {
|
||||
// ⚠️ Dev only: service role en cliente
|
||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||
const { data } = await admin.auth.admin.listUsers()
|
||||
return (data?.users ?? []) as AdminUser[]
|
||||
}
|
||||
|
||||
const usersOptions = () =>
|
||||
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
||||
|
||||
/* -------------------- Ruta -------------------- */
|
||||
export const Route = createFileRoute("/_authenticated/usuarios")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
// ⚠️ Dev only: service role en cliente
|
||||
const supabsaeAdmin = new SupabaseClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
|
||||
)
|
||||
const { data: data_users } = await supabsaeAdmin.auth.admin.listUsers()
|
||||
return { data: data_users.users as AdminUser[] }
|
||||
}
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(usersOptions())
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
/* -------------------- Página -------------------- */
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const router = useRouter()
|
||||
const { data } = Route.useLoaderData()
|
||||
const qc = useQueryClient()
|
||||
const { data } = useSuspenseQuery(usersOptions())
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<{
|
||||
role?: Role;
|
||||
claims_admin?: boolean;
|
||||
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string;
|
||||
facultad_id?: string | null;
|
||||
carrera_id?: string | null;
|
||||
role?: Role
|
||||
claims_admin?: boolean
|
||||
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string
|
||||
facultad_id?: string | null
|
||||
carrera_id?: string | null
|
||||
}>({})
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [createForm, setCreateForm] = useState<{
|
||||
email: string
|
||||
@@ -138,123 +118,138 @@ function RouteComponent() {
|
||||
}>({ email: "", password: "" })
|
||||
|
||||
function genPassword() {
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
|
||||
.map(n => n.toString(36)).join("")
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
|
||||
return s.slice(0, 14)
|
||||
}
|
||||
|
||||
|
||||
// NEW: helpers nombramientos
|
||||
async function upsertNombramiento(opts: {
|
||||
user_id: string,
|
||||
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera",
|
||||
facultad_id?: string | null,
|
||||
carrera_id?: string | null
|
||||
}) {
|
||||
// cierra vigentes del mismo scope y puesto
|
||||
if (opts.puesto === "jefe_carrera") {
|
||||
if (!opts.carrera_id) throw new Error("Selecciona carrera")
|
||||
await supabase.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", "jefe_carrera")
|
||||
.eq("carrera_id", opts.carrera_id)
|
||||
.is("hasta", null)
|
||||
} else {
|
||||
if (!opts.facultad_id) throw new Error("Selecciona facultad")
|
||||
await supabase.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", opts.puesto)
|
||||
.eq("facultad_id", opts.facultad_id)
|
||||
.is("hasta", null)
|
||||
}
|
||||
// inserta vigente
|
||||
const { error } = await supabase.from("nombramientos").insert({
|
||||
user_id: opts.user_id,
|
||||
puesto: opts.puesto,
|
||||
facultad_id: opts.facultad_id ?? null,
|
||||
carrera_id: opts.carrera_id ?? null,
|
||||
desde: new Date().toISOString().slice(0, 10),
|
||||
hasta: null
|
||||
})
|
||||
if (error) throw error
|
||||
/* ---------- Mutations ---------- */
|
||||
const invalidateAll = async () => {
|
||||
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
||||
router.invalidate()
|
||||
}
|
||||
|
||||
// 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)
|
||||
const upsertNombramiento = useMutation({
|
||||
mutationFn: async (opts: {
|
||||
user_id: string
|
||||
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
|
||||
facultad_id?: string | null
|
||||
carrera_id?: string | null
|
||||
}) => {
|
||||
// cierra vigentes
|
||||
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)
|
||||
}
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
|
||||
})
|
||||
|
||||
async function createUserNow() {
|
||||
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
|
||||
try {
|
||||
const adminClient = new SupabaseClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
||||
)
|
||||
const toggleBan = useMutation({
|
||||
mutationFn: async (u: AdminUser) => {
|
||||
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 new Error(error.message)
|
||||
return !banned
|
||||
},
|
||||
onSuccess: async (isBanned) => {
|
||||
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
|
||||
})
|
||||
|
||||
setCreateSaving(true)
|
||||
const password = createForm.password?.trim() || genPassword()
|
||||
const { error, data } = await adminClient.auth.admin.createUser({
|
||||
email: createForm.email.trim(),
|
||||
const createUser = useMutation({
|
||||
mutationFn: async (payload: typeof createForm) => {
|
||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||
const password = payload.password?.trim() || genPassword()
|
||||
const { error, data } = await admin.auth.admin.createUser({
|
||||
email: payload.email.trim(),
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
nombre: createForm.nombre ?? "",
|
||||
apellidos: createForm.apellidos ?? "",
|
||||
title: createForm.title ?? "",
|
||||
clave: createForm.clave ?? "",
|
||||
avatar: createForm.avatar ?? ""
|
||||
nombre: payload.nombre ?? "",
|
||||
apellidos: payload.apellidos ?? "",
|
||||
title: payload.title ?? "",
|
||||
clave: payload.clave ?? "",
|
||||
avatar: payload.avatar ?? "",
|
||||
},
|
||||
app_metadata: {
|
||||
role: createForm.role,
|
||||
claims_admin: !!createForm.claims_admin,
|
||||
facultad_id: createForm.facultad_id ?? null,
|
||||
carrera_id: createForm.carrera_id ?? null
|
||||
}
|
||||
role: payload.role,
|
||||
claims_admin: !!payload.claims_admin,
|
||||
facultad_id: payload.facultad_id ?? null,
|
||||
carrera_id: payload.carrera_id ?? null,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
// NEW: si es rol jerárquico => crea nombramiento
|
||||
if (error) throw new Error(error.message)
|
||||
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
|
||||
})
|
||||
if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) {
|
||||
if (payload.role === "director_facultad") {
|
||||
if (!payload.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id })
|
||||
} else if (payload.role === "secretario_academico") {
|
||||
if (!payload.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id })
|
||||
} else if (payload.role === "jefe_carrera") {
|
||||
if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id })
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Usuario creado")
|
||||
setCreateOpen(false)
|
||||
setCreateForm({ email: "", password: "" })
|
||||
router.invalidate()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(e?.message || "No se pudo crear el usuario")
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "No se pudo crear el usuario"),
|
||||
})
|
||||
|
||||
const saveUser = useMutation({
|
||||
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
|
||||
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
|
||||
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
|
||||
// Simula éxito:
|
||||
// 2) Nombramiento si aplica
|
||||
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
|
||||
if (f.role === "director_facultad") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
|
||||
} else if (f.role === "secretario_academico") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
|
||||
} else if (f.role === "jefe_carrera") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Cambios guardados")
|
||||
setEditing(null)
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
|
||||
})
|
||||
|
||||
if (!auth.claims?.claims_admin) {
|
||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||
@@ -263,12 +258,12 @@ function RouteComponent() {
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
if (!t) return data
|
||||
return data.filter(u => {
|
||||
return data.filter((u) => {
|
||||
const role: Role | undefined = u.app_metadata?.role
|
||||
const label = role ? ROLE_META[role]?.label : ""
|
||||
return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
.some((v) => String(v).toLowerCase().includes(t))
|
||||
})
|
||||
}, [q, data])
|
||||
|
||||
@@ -287,7 +282,6 @@ function RouteComponent() {
|
||||
})
|
||||
}
|
||||
|
||||
// NEW: validación de scope por rol antes de guardar
|
||||
function validateScopeForSave(): string | null {
|
||||
if (!editing) return "Sin usuario"
|
||||
if (form.role === "director_facultad" || form.role === "secretario_academico") {
|
||||
@@ -299,58 +293,14 @@ function RouteComponent() {
|
||||
return null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
const scopeErr = validateScopeForSave()
|
||||
if (scopeErr) { toast.error(scopeErr); return }
|
||||
|
||||
setSaving(true)
|
||||
// 1) Actualiza metadatos via Edge Function existente (mantengo tu flujo)
|
||||
const error = true;
|
||||
if (error) {
|
||||
setSaving(false)
|
||||
console.error(error)
|
||||
toast.error("No se pudo guardar")
|
||||
return
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center">
|
||||
<CardTitle>Usuarios</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Buscar por nombre, email o rol…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
||||
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
||||
@@ -361,94 +311,55 @@ function RouteComponent() {
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{filtered.map(u => {
|
||||
{filtered.map((u) => {
|
||||
const m = u.user_metadata || {}
|
||||
const a = u.app_metadata || {}
|
||||
const roleCode: Role | undefined = a.role
|
||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now() // NEW
|
||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
||||
return (
|
||||
<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">
|
||||
<img
|
||||
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<img src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
||||
</div>
|
||||
<div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{roleCode && <RolePill role={roleCode} />}
|
||||
{a.claims_admin ? (
|
||||
<Badge className="gap-1" variant="secondary">
|
||||
<ShieldCheck className="w-3 h-3" /> Admin
|
||||
</Badge>
|
||||
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1" variant="outline">
|
||||
<ShieldAlert className="w-3 h-3" /> Usuario
|
||||
</Badge>
|
||||
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
||||
)}
|
||||
{/* NEW: estado ban */}
|
||||
<Badge variant={banned ? "destructive" as any : "secondary"} className="gap-1">
|
||||
<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 className="flex items-center gap-1">
|
||||
{/* 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 variant="outline" size="sm" onClick={() => toggleBan.mutate(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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex shrink-0"
|
||||
onClick={() => openEdit(u)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
||||
<Pencil className="w-4 h-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Mail className="w-3 h-3" /> {u.email ?? "—"}
|
||||
</span>
|
||||
<span className="hidden xs:inline">
|
||||
Creado: {new Date(u.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="hidden md:inline">
|
||||
Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
||||
<span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
||||
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile actions */}
|
||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan(u)} aria-label="Ban/Unban">
|
||||
<BanIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!filtered.length && (
|
||||
<div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>
|
||||
)}
|
||||
{!filtered.length && <div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -458,60 +369,31 @@ function RouteComponent() {
|
||||
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input value={form.nombre ?? ""} onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Apellidos</Label>
|
||||
<Input value={form.apellidos ?? ""} onChange={(e) => setForm(s => ({ ...s, apellidos: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título</Label>
|
||||
<Input value={form.title ?? ""} onChange={(e) => setForm(s => ({ ...s, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Clave</Label>
|
||||
<Input value={form.clave ?? ""} onChange={(e) => setForm(s => ({ ...s, clave: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Avatar (URL)</Label>
|
||||
<Input value={form.avatar ?? ""} onChange={(e) => setForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título</Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Clave</Label><Input value={form.clave ?? ""} onChange={(e) => setForm((s) => ({ ...s, clave: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||
<div className="space-y-1">
|
||||
<Label>Rol</Label>
|
||||
<Select
|
||||
value={form.role ?? ""}
|
||||
onValueChange={(v) => {
|
||||
setForm(s => {
|
||||
setForm((s) => {
|
||||
const role = v as Role
|
||||
if (role === "jefe_carrera") {
|
||||
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
}
|
||||
if (role === "secretario_academico" || role === "director_facultad") {
|
||||
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
}
|
||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] max-h-72 overflow-auto"
|
||||
>
|
||||
{ROLES.map(code => {
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start" className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] max-h-72 overflow-auto">
|
||||
{ROLES.map((code) => {
|
||||
const meta = ROLE_META[code]; const Icon = meta.Icon
|
||||
return (
|
||||
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" /> {meta.label}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2"><Icon className="w-4 h-4" /> {meta.label}</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
@@ -519,43 +401,30 @@ function RouteComponent() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* DIRECTOR/SECRETARIO: facultad */}
|
||||
{(form.role === "secretario_academico" || form.role === "director_facultad") && (
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||
/>
|
||||
<FacultadCombobox value={form.facultad_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />
|
||||
<p className="text-[11px] text-neutral-500">Este rol requiere <strong>Facultad</strong>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JEFE DE CARRERA: ambos */}
|
||||
{form.role === "jefe_carrera" && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:col-span-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||
/>
|
||||
<FacultadCombobox value={form.facultad_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={form.facultad_id ?? ""}
|
||||
value={form.carrera_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={!form.facultad_id}
|
||||
/>
|
||||
<CarreraCombobox facultadId={form.facultad_id ?? ""} value={form.carrera_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, carrera_id: id }))} disabled={!form.facultad_id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<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' }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Administrador</SelectItem>
|
||||
@@ -566,7 +435,16 @@ function RouteComponent() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const scopeErr = validateScopeForSave()
|
||||
if (scopeErr || !editing) { toast.error(scopeErr || 'Sin usuario'); return }
|
||||
saveUser.mutate({ u: editing, f: form })
|
||||
}}
|
||||
disabled={saveUser.isPending}
|
||||
>
|
||||
{saveUser.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -579,60 +457,31 @@ function RouteComponent() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Correo</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm(s => ({ ...s, email: e.target.value }))}
|
||||
placeholder="usuario@lasalle.mx"
|
||||
/>
|
||||
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((s) => ({ ...s, email: e.target.value }))} placeholder="usuario@lasalle.mx" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Contraseña temporal</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type={showPwd ? "text" : "password"}
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm(s => ({ ...s, password: e.target.value }))}
|
||||
placeholder="Se generará si la dejas vacía"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateForm(s => ({ ...s, password: genPassword() }))}>
|
||||
Generar
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />
|
||||
<Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Apellidos</Label>
|
||||
<Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título</Label>
|
||||
<Input value={createForm.title ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Clave</Label>
|
||||
<Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, clave: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Avatar (URL)</Label>
|
||||
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label>Nombre</Label><Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Apellidos</Label><Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título</Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Clave</Label><Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, clave: e.target.value }))} /></div>
|
||||
<div className="space-y-1 md:col-span-2"><Label>Avatar (URL)</Label><Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Rol</Label>
|
||||
<Select
|
||||
value={createForm.role ?? ""}
|
||||
onValueChange={(v) => {
|
||||
setCreateForm(s => {
|
||||
setCreateForm((s) => {
|
||||
const role = v as Role
|
||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
@@ -642,7 +491,7 @@ function RouteComponent() {
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{ROLES.map(code => {
|
||||
{ROLES.map((code) => {
|
||||
const M = ROLE_META[code]; const I = M.Icon
|
||||
return (
|
||||
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
|
||||
@@ -657,10 +506,7 @@ function RouteComponent() {
|
||||
{(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && (
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={createForm.facultad_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||
/>
|
||||
<FacultadCombobox value={createForm.facultad_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -668,26 +514,18 @@ function RouteComponent() {
|
||||
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={createForm.facultad_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||
/>
|
||||
<FacultadCombobox value={createForm.facultad_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={createForm.facultad_id ?? ""}
|
||||
value={createForm.carrera_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={!createForm.facultad_id}
|
||||
/>
|
||||
<CarreraCombobox facultadId={createForm.facultad_id ?? ""} value={createForm.carrera_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, carrera_id: id }))} disabled={!createForm.facultad_id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
|
||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Administrador</SelectItem>
|
||||
@@ -699,13 +537,12 @@ function RouteComponent() {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={createUserNow} disabled={!createForm.email || createSaving}>
|
||||
{createSaving ? "Creando…" : "Crear usuario"}
|
||||
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
|
||||
{createUser.isPending ? "Creando…" : "Crear usuario"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user