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,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>
|
||||
|
||||
Reference in New Issue
Block a user