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>
644 lines
26 KiB
TypeScript
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>
|
|
)
|
|
}
|