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