Yimaru-Admin/src/pages/user-management/DeletionRequestsPage.tsx
Yared Yemane 92a2fab833 feat(admin): dynamic content flows, cleaner UI copy, and table pagination
Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:34:39 -07:00

644 lines
26 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Search, SlidersHorizontal } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import type {
DeletionRequest,
DeletionState,
DeletionUserStatus,
GetDeletionRequestsParams,
} from "../../types/user.types"
import type { Role } from "../../types/rbac.types"
import { mapDeletionRequestApiItem } from "../../types/user.types"
const stateBadge: Record<string, string> = {
PENDING: "bg-amber-100 text-amber-700",
DUE: "bg-brand-100 text-brand-600",
CANCELLED: "bg-grayScale-200 text-grayScale-600",
}
const DATE_PLACEHOLDER = "DD-MM-YYYY HH:mm"
const STATUS_OPTIONS: DeletionUserStatus[] = ["ACTIVE", "PENDING", "SUSPENDED", "DEACTIVATED"]
const STATE_OPTIONS: DeletionState[] = ["PENDING", "DUE", "CANCELLED"]
type DateTimeFilterInputProps = {
value: string
onChange: (value: string) => void
placeholder: string
}
function DateTimeFilterInput({ value, onChange, placeholder }: DateTimeFilterInputProps) {
return (
<div className="relative">
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
type="datetime-local"
value={value}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => {
// Opens native date-time picker immediately on supported browsers.
(e.currentTarget as HTMLInputElement).showPicker?.()
}}
aria-label={placeholder}
className="h-11 rounded-xl border-grayScale-200 bg-white pl-10 pr-3 text-sm shadow-sm transition focus-visible:border-brand-400 focus-visible:ring-brand-200 [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-70 hover:[&::-webkit-calendar-picker-indicator]:opacity-100"
/>
</div>
)
}
export function DeletionRequestsPage() {
const [items, setItems] = useState<DeletionRequest[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState("")
const [role, setRole] = useState("")
const [roleOptions, setRoleOptions] = useState<string[]>([])
const [rolesLoading, setRolesLoading] = useState(false)
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
const [roleSearch, setRoleSearch] = useState("")
const [statusMenuOpen, setStatusMenuOpen] = useState(false)
const [stateMenuOpen, setStateMenuOpen] = useState(false)
const [status, setStatus] = useState<DeletionUserStatus | "">("")
const [state, setState] = useState<DeletionState | "">("")
const [requestedAfter, setRequestedAfter] = useState("")
const [requestedBefore, setRequestedBefore] = useState("")
const [scheduledAfter, setScheduledAfter] = useState("")
const [scheduledBefore, setScheduledBefore] = useState("")
const roleMenuRef = useRef<HTMLDivElement | null>(null)
const statusMenuRef = useRef<HTMLDivElement | null>(null)
const stateMenuRef = useRef<HTMLDivElement | null>(null)
const toRfc3339 = (value: string) => {
if (!value) return undefined
const normalized = value.includes("T") ? value : value.replace(" ", "T")
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params: GetDeletionRequestsParams = {
query: query || undefined,
role: role || undefined,
status: status || undefined,
state: state || undefined,
requested_after: toRfc3339(requestedAfter),
requested_before: toRfc3339(requestedBefore),
scheduled_after: toRfc3339(scheduledAfter),
scheduled_before: toRfc3339(scheduledBefore),
page,
page_size: pageSize,
}
const res = await getDeletionRequests(params)
const rows = res.data?.data?.items ?? []
setItems(rows.map(mapDeletionRequestApiItem))
setTotal(res.data?.data?.total ?? 0)
} catch (error) {
console.error("Failed to fetch deletion requests:", error)
setItems([])
setTotal(0)
} finally {
setLoading(false)
}
}, [
query,
role,
status,
state,
requestedAfter,
requestedBefore,
scheduledAfter,
scheduledBefore,
page,
pageSize,
])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
const fetchRoles = async () => {
setRolesLoading(true)
try {
const pageSize = 50
const firstRes = await getRoles({ page: 1, page_size: pageSize })
const firstBatch = firstRes.data?.data?.roles ?? []
const total = firstRes.data?.data?.total ?? firstBatch.length
const totalPages = Math.max(1, Math.ceil(total / pageSize))
let allRoles: Role[] = firstBatch
if (totalPages > 1) {
const requests: Array<ReturnType<typeof getRoles>> = []
for (let currentPage = 2; currentPage <= totalPages; currentPage += 1) {
requests.push(getRoles({ page: currentPage, page_size: pageSize }))
}
const remaining = await Promise.all(requests)
allRoles = [...firstBatch, ...remaining.flatMap((r) => r.data?.data?.roles ?? [])]
}
const uniqueNames = Array.from(
new Set(
allRoles
.map((r) => r.name?.trim())
.filter((name): name is string => Boolean(name)),
),
).sort((a, b) => a.localeCompare(b))
setRoleOptions(uniqueNames)
} catch (error) {
console.error("Failed to fetch role options:", error)
setRoleOptions([])
} finally {
setRolesLoading(false)
}
}
fetchRoles()
}, [])
useEffect(() => {
if (!roleMenuOpen && !statusMenuOpen && !stateMenuOpen) return
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as Node
if (roleMenuRef.current && !roleMenuRef.current.contains(target)) {
setRoleMenuOpen(false)
}
if (statusMenuRef.current && !statusMenuRef.current.contains(target)) {
setStatusMenuOpen(false)
}
if (stateMenuRef.current && !stateMenuRef.current.contains(target)) {
setStateMenuOpen(false)
}
}
document.addEventListener("mousedown", handleOutsideClick)
return () => {
document.removeEventListener("mousedown", handleOutsideClick)
}
}, [roleMenuOpen, statusMenuOpen, stateMenuOpen])
const filteredRoleOptions = useMemo(() => {
const q = roleSearch.trim().toLowerCase()
if (!q) return roleOptions
return roleOptions.filter((option) => option.toLowerCase().includes(q))
}, [roleOptions, roleSearch])
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize])
const safePage = Math.min(page, totalPages)
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const resetFilters = () => {
setQuery("")
setRole("")
setStatus("")
setState("")
setRequestedAfter("")
setRequestedBefore("")
setScheduledAfter("")
setScheduledBefore("")
setRoleSearch("")
setRoleMenuOpen(false)
setStatusMenuOpen(false)
setStateMenuOpen(false)
setPage(1)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Deletion Requests</h1>
<p className="mt-1 text-sm text-grayScale-400">
Review and monitor account deletion requests from users.
</p>
</div>
<Card className="overflow-visible rounded-2xl border border-brand-100/70 bg-white shadow-soft">
<CardHeader className="border-b border-brand-100/70 bg-gradient-to-r from-brand-100/50 via-white to-brand-100/25 pb-4">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-grayScale-700">
<SlidersHorizontal className="h-4 w-4 text-brand-600" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 bg-gradient-to-b from-white to-brand-100/10 pt-5">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<div className="relative lg:col-span-2">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(1)
}}
placeholder="Search name, email, phone..."
className="h-11 rounded-xl border-grayScale-200 bg-white pl-10 shadow-sm transition focus-visible:border-brand-400 focus-visible:ring-brand-200"
/>
</div>
<div className="relative" ref={roleMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setRoleMenuOpen((prev) => !prev)
setStatusMenuOpen(false)
setStateMenuOpen(false)
if (!roleMenuOpen) setRoleSearch("")
}}
>
<span>{rolesLoading ? "Loading roles..." : role || "Role"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{roleMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-2 shadow-lg">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-grayScale-300" />
<Input
value={roleSearch}
onChange={(e) => setRoleSearch(e.target.value)}
placeholder="Search role..."
className="h-9 rounded-lg border-grayScale-200 pl-8 text-sm"
/>
</div>
<div className="max-h-56 space-y-1 overflow-auto">
<button
type="button"
className={cn(
"w-full rounded-md px-2 py-2 text-left text-sm transition",
role === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setRole("")
setPage(1)
setRoleMenuOpen(false)
}}
>
All Roles
</button>
{filteredRoleOptions.map((roleName) => (
<button
key={roleName}
type="button"
className={cn(
"w-full rounded-md px-2 py-2 text-left text-sm transition",
role === roleName
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setRole(roleName)
setPage(1)
setRoleMenuOpen(false)
}}
>
{roleName}
</button>
))}
{!rolesLoading && filteredRoleOptions.length === 0 ? (
<p className="px-2 py-1.5 text-sm text-grayScale-400">No roles found</p>
) : null}
</div>
</div>
) : null}
</div>
<div className="relative" ref={statusMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setStatusMenuOpen((prev) => !prev)
setStateMenuOpen(false)
setRoleMenuOpen(false)
}}
>
<span>{status || "All Statuses"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{statusMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-1.5 shadow-lg">
<button
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
status === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setStatus("")
setPage(1)
setStatusMenuOpen(false)
}}
>
All Statuses
</button>
{STATUS_OPTIONS.map((statusOption) => (
<button
key={statusOption}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
status === statusOption
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setStatus(statusOption)
setPage(1)
setStatusMenuOpen(false)
}}
>
{statusOption}
</button>
))}
</div>
) : null}
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<div className="relative" ref={stateMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setStateMenuOpen((prev) => !prev)
setStatusMenuOpen(false)
setRoleMenuOpen(false)
}}
>
<span>{state || "All States"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{stateMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-1.5 shadow-lg">
<button
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
state === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setState("")
setPage(1)
setStateMenuOpen(false)
}}
>
All States
</button>
{STATE_OPTIONS.map((stateOption) => (
<button
key={stateOption}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
state === stateOption
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setState(stateOption)
setPage(1)
setStateMenuOpen(false)
}}
>
{stateOption}
</button>
))}
</div>
) : null}
</div>
<DateTimeFilterInput
value={requestedAfter}
onChange={(value) => {
setRequestedAfter(value)
setPage(1)
}}
placeholder={`Requested after (${DATE_PLACEHOLDER})`}
/>
<DateTimeFilterInput
value={requestedBefore}
onChange={(value) => {
setRequestedBefore(value)
setPage(1)
}}
placeholder={`Requested before (${DATE_PLACEHOLDER})`}
/>
<DateTimeFilterInput
value={scheduledAfter}
onChange={(value) => {
setScheduledAfter(value)
setPage(1)
}}
placeholder={`Scheduled after (${DATE_PLACEHOLDER})`}
/>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<DateTimeFilterInput
value={scheduledBefore}
onChange={(value) => {
setScheduledBefore(value)
setPage(1)
}}
placeholder={`Scheduled before (${DATE_PLACEHOLDER})`}
/>
<div className="flex justify-end lg:col-start-4 lg:col-span-1">
<Button
variant="outline"
className="h-11 rounded-xl border-brand-200 px-5 text-brand-700 hover:bg-brand-100/40"
onClick={resetFilters}
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Requests ({total})
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role / Status</TableHead>
<TableHead className="hidden md:table-cell">Requested At</TableHead>
<TableHead className="hidden md:table-cell">Scheduled At</TableHead>
<TableHead>Deletion State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading requests...</span>
</div>
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<Search className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No deletion requests found</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={`${item.user_id}-${index}`} className="group">
<TableCell className="py-3.5">
<p className="text-sm font-medium text-grayScale-700">
{item.first_name} {item.last_name}
</p>
<p className="text-xs text-grayScale-500">{item.email}</p>
<p className="text-xs text-grayScale-500">{item.phone_number || "—"}</p>
</TableCell>
<TableCell className="py-3.5">
<p className="text-sm text-grayScale-700">{item.role || "—"}</p>
<p className="text-xs text-grayScale-500">{item.status || "—"}</p>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-600 md:table-cell">
{item.deletion_requested_at || "—"}
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-600 md:table-cell">
{item.deletion_scheduled_at || "—"}
</TableCell>
<TableCell className="py-3.5">
<Badge className={stateBadge[item.deletion_state] || "bg-grayScale-200 text-grayScale-600"}>
{item.deletion_state || "—"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}