| import { useState } from "react"; |
| import { createFileRoute } from "@tanstack/react-router"; |
| import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| import { trpc } from "@/utils/trpc"; |
| import { Input } from "@pram/ui/components/input"; |
| import { Button } from "@pram/ui/components/button"; |
| import { toast } from "sonner"; |
| import { getErrorMessage } from "@/lib/error-utils"; |
| import { useDebouncedValue } from "@/hooks/use-debounced-value"; |
| import { DataTable } from "@/components/admin/DataTable"; |
| import type { ColumnDef } from "@/components/admin/DataTable"; |
|
|
| const PAGE_SIZE = 20; |
|
|
| export const Route = createFileRoute("/admin/users")({ |
| component: AdminUsers, |
| }); |
|
|
| type UserRow = { |
| id: string; |
| name: string; |
| email: string; |
| role: string; |
| suspended: boolean; |
| emailVerified: boolean; |
| }; |
|
|
| function AdminUsers() { |
| const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300); |
| const [page, setPage] = useState(1); |
| const queryClient = useQueryClient(); |
|
|
| const usersQuery = useQuery( |
| trpc.admin.listUsers.queryOptions({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }), |
| ); |
|
|
| const users = usersQuery.data?.users ?? []; |
| const total = usersQuery.data?.total ?? 0; |
| const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); |
|
|
| function handleSearch(val: string) { |
| setSearch(val); |
| setPage(1); |
| } |
|
|
| const suspendMutation = useMutation( |
| trpc.admin.suspendUser.mutationOptions({ |
| onSuccess: (data) => { |
| queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); |
| toast.success(data.suspended ? "User suspended" : "User unsuspended"); |
| }, |
| onError: (e: unknown) => toast.error(getErrorMessage(e)), |
| }), |
| ); |
|
|
| const roleMutation = useMutation( |
| trpc.admin.setUserRole.mutationOptions({ |
| onSuccess: (data) => { |
| queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); |
| toast.success(`Role: ${data.role}`); |
| }, |
| onError: (e: unknown) => toast.error(getErrorMessage(e)), |
| }), |
| ); |
|
|
| const columns: ColumnDef<UserRow>[] = [ |
| { |
| id: "name", |
| header: "Name", |
| accessorKey: "name", |
| size: "w-[25%]", |
| cell: ({ value }) => <span className="font-medium text-[var(--clay-black)]">{value as string}</span>, |
| }, |
| { |
| id: "email", |
| header: "Email", |
| accessorKey: "email", |
| size: "w-[30%]", |
| cell: ({ row }) => ( |
| <span className="text-[var(--warm-charcoal)]"> |
| {row.email} |
| {!row.emailVerified && ( |
| <span className="ml-1.5 text-[10px] bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)] px-1.5 py-0.5 rounded-full font-medium">unverified</span> |
| )} |
| </span> |
| ), |
| }, |
| { |
| id: "role", |
| header: "Role", |
| size: "w-[10%]", |
| cell: ({ row }) => ( |
| <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${row.role === "admin" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"}`}> |
| {row.role} |
| </span> |
| ), |
| }, |
| { |
| id: "status", |
| header: "Status", |
| size: "w-[10%]", |
| cell: ({ row }) => ( |
| <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ |
| row.suspended ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" : "bg-[var(--matcha-300)] text-[var(--matcha-800)]" |
| }`}> |
| {row.suspended ? "Suspended" : "Active"} |
| </span> |
| ), |
| }, |
| ]; |
|
|
| return ( |
| <div> |
| <div className="flex items-center justify-between mb-6"> |
| <div> |
| <h1 className="text-3xl font-headline font-bold text-[var(--clay-black)]">Users</h1> |
| <p className="text-[var(--warm-charcoal)] mt-1">{total.toLocaleString()} total users</p> |
| </div> |
| </div> |
| |
| <div className="mb-4"> |
| <Input |
| placeholder="Search by name or email..." |
| value={search} |
| onChange={(e) => handleSearch(e.target.value)} |
| aria-label="Cari user" |
| className="max-w-md rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11" |
| /> |
| </div> |
| |
| <DataTable |
| data={users} |
| columns={columns} |
| isLoading={usersQuery.isLoading} |
| emptyMessage="No users found." |
| keyExtractor={(u) => u.id} |
| page={page} |
| totalPages={totalPages} |
| onPageChange={setPage} |
| actions={(u) => ( |
| <div className="flex items-center justify-end gap-2"> |
| <Button variant="outline" onClick={() => suspendMutation.mutate({ userId: u.id, suspended: !u.suspended })} disabled={suspendMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs"> |
| {u.suspended ? "Unsuspend" : "Suspend"} |
| </Button> |
| <Button variant="outline" onClick={() => roleMutation.mutate({ userId: u.id, role: u.role === "admin" ? "user" : "admin" })} disabled={roleMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs"> |
| {u.role === "admin" ? "Demote" : "Promote"} |
| </Button> |
| </div> |
| )} |
| /> |
| </div> |
| ); |
| } |
|
|