Add search, filters, pagination to admin users table

- Add emailVerified filter (true/false/any) to UsersSearch component
- Wire emailVerified param through page searchParams, where clause, and pageUrl helper
- Preserve emailVerified in pagination links alongside existing search/role/balance params
- Update pagination label to "Страница X из Y · Всего: N"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 14:25:53 +05:00
parent e5ba94cb33
commit 4f5b5c535a
2 changed files with 27 additions and 9 deletions
+7 -4
View File
@@ -8,11 +8,11 @@ import { UsersSearch } from "@/components/admin/users-search";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
interface Props { interface Props {
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string }>; searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string; emailVerified?: string }>;
} }
export default async function UsersPage({ searchParams }: Props) { export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1", balance = "" } = await searchParams; const { search = "", role = "", page = "1", balance = "", emailVerified = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1); const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE; const skip = (currentPage - 1) * PAGE_SIZE;
@@ -37,6 +37,8 @@ export default async function UsersPage({ searchParams }: Props) {
} }
: {}), : {}),
...(role ? { role } : {}), ...(role ? { role } : {}),
...(emailVerified === "true" ? { emailVerified: true } : {}),
...(emailVerified === "false" ? { emailVerified: false } : {}),
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}), ...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
}; };
@@ -78,6 +80,7 @@ export default async function UsersPage({ searchParams }: Props) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (role) params.set("role", role); if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance); if (balance) params.set("balance", balance);
params.set("page", String(p)); params.set("page", String(p));
return `/admin/users?${params.toString()}`; return `/admin/users?${params.toString()}`;
@@ -101,7 +104,7 @@ export default async function UsersPage({ searchParams }: Props) {
{/* Filters */} {/* Filters */}
<Suspense> <Suspense>
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} /> <UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
</Suspense> </Suspense>
<UsersTable users={tableUsers} /> <UsersTable users={tableUsers} />
@@ -132,7 +135,7 @@ export default async function UsersPage({ searchParams }: Props) {
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link> <Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)} )}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span> <span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div> </div>
)} )}
</div> </div>
+20 -5
View File
@@ -16,20 +16,23 @@ const inputStyle: React.CSSProperties = {
export function UsersSearch({ export function UsersSearch({
initialSearch, initialSearch,
initialRole, initialRole,
initialEmailVerified,
initialBalance, initialBalance,
}: { }: {
initialSearch: string; initialSearch: string;
initialRole: string; initialRole: string;
initialEmailVerified: string;
initialBalance: string; initialBalance: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
function update(search: string, role: string, balance: string) { function update(search: string, role: string, emailVerified: string, balance: string) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (role) params.set("role", role); if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance); if (balance) params.set("balance", balance);
startTransition(() => router.push(`${pathname}?${params.toString()}`)); startTransition(() => router.push(`${pathname}?${params.toString()}`));
} }
@@ -45,7 +48,7 @@ export function UsersSearch({
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => { onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.borderColor = "var(--border)";
update(e.currentTarget.value.trim(), initialRole, initialBalance); update(e.currentTarget.value.trim(), initialRole, initialEmailVerified, initialBalance);
}} }}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/> />
@@ -53,7 +56,7 @@ export function UsersSearch({
<select <select
defaultValue={initialRole} defaultValue={initialRole}
onChange={(e) => update(initialSearch, e.target.value, initialBalance)} onChange={(e) => update(initialSearch, e.target.value, initialEmailVerified, initialBalance)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }} style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
@@ -64,9 +67,21 @@ export function UsersSearch({
<option value="admin">Администраторы</option> <option value="admin">Администраторы</option>
</select> </select>
<select
defaultValue={initialEmailVerified}
onChange={(e) => update(initialSearch, initialRole, e.target.value, initialBalance)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Любой статус</option>
<option value="true">Email подтверждён</option>
<option value="false">Не подтверждён</option>
</select>
<button <button
type="button" type="button"
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")} onClick={() => update(initialSearch, initialRole, initialEmailVerified, initialBalance === "nonzero" ? "" : "nonzero")}
className="text-xs px-3" className="text-xs px-3"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
@@ -78,7 +93,7 @@ export function UsersSearch({
С балансом С балансом
</button> </button>
{(initialSearch || initialRole || initialBalance) && ( {(initialSearch || initialRole || initialEmailVerified || initialBalance) && (
<button <button
type="button" type="button"
onClick={() => startTransition(() => router.push(pathname))} onClick={() => startTransition(() => router.push(pathname))}