refactor(all): migrate from React to Next.js

This commit is contained in:
2026-05-20 09:46:17 +03:30
parent dacbd3a328
commit f23108cda3
86 changed files with 2831 additions and 2679 deletions

275
src/views/AdminUsers.tsx Normal file
View File

@@ -0,0 +1,275 @@
"use client";
import * as React from 'react';
import {
useQuery,
} from '@tanstack/react-query';
import type { UserListSchema } from '@/lib/types';
import { api } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/use-toast';
import {
formatJalali,
formatNumberPersian,
resolveErrorMessage,
} from '@/lib/utils';
const USERS_PAGE_SIZE = 25;
const AdminUsersPage: React.FC = () => {
const { toast } = useToast();
const [filters, setFilters] = React.useState({
search: '',
studentId: '',
university: 'all',
major: 'all',
isActive: 'all',
});
const [page, setPage] = React.useState(1);
const majorsQuery = useQuery({
queryKey: ['majors'],
queryFn: () => api.getMajors(),
});
const universitiesQuery = useQuery({
queryKey: ['universities'],
queryFn: () => api.getUniversities(),
});
const usersQuery = useQuery({
queryKey: ['admin', 'users', filters, page],
queryFn: () =>
api.listUsers({
search: filters.search || undefined,
student_id: filters.studentId || undefined,
university: filters.university === 'all' ? undefined : filters.university,
major: filters.major === 'all' ? undefined : filters.major,
is_active:
filters.isActive === 'all'
? undefined
: filters.isActive === 'active'
? 'true'
: 'false',
limit: USERS_PAGE_SIZE,
offset: (page - 1) * USERS_PAGE_SIZE,
}),
});
const users = usersQuery.data ?? [];
const hasMore = users.length === USERS_PAGE_SIZE;
React.useEffect(() => {
if (usersQuery.error) {
toast({
title: 'خطا در بارگذاری کاربران',
description: resolveErrorMessage(usersQuery.error),
variant: 'destructive',
});
}
}, [usersQuery.error, toast]);
const handleFilterChange = (field: keyof typeof filters, value: string) => {
setFilters((prev) => ({ ...prev, [field]: value }));
setPage(1);
};
return (
<div className="space-y-6" dir="rtl">
<div>
<h2 className="text-xl font-semibold">کاربران</h2>
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>
</div>
<Card>
<CardHeader>
<CardTitle>فیلترها</CardTitle>
<CardDescription>جستجو و محدود کردن نتایج</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<Input
placeholder="نام، نام‌کاربری یا ایمیل..."
value={filters.search}
onChange={(event) => handleFilterChange('search', event.target.value)}
/>
<Input
placeholder="شماره دانشجویی"
value={filters.studentId}
onChange={(event) => handleFilterChange('studentId', event.target.value)}
/>
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange('isActive', value)}>
<SelectTrigger>
<SelectValue placeholder="وضعیت">
{{
all: 'همه وضعیت‌ها',
active: 'فعال',
inactive: 'غیرفعال',
}[filters.isActive]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">همه</SelectItem>
<SelectItem value="active">فعال</SelectItem>
<SelectItem value="inactive">غیرفعال</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Select
value={filters.university}
onValueChange={(value) => handleFilterChange('university', value)}
>
<SelectTrigger>
<SelectValue placeholder="دانشگاه">
{filters.university === 'all'
? 'همه'
: universitiesQuery.data?.find((item) => item.code === filters.university)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">همه</SelectItem>
{universitiesQuery.data?.map((item) => (
<SelectItem key={item.code} value={item.code}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.major} onValueChange={(value) => handleFilterChange('major', value)}>
<SelectTrigger>
<SelectValue placeholder="رشته">
{filters.major === 'all'
? 'همه'
: majorsQuery.data?.find((item) => item.code === filters.major)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">همه</SelectItem>
{majorsQuery.data?.map((item) => (
<SelectItem key={item.code} value={item.code}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-0 md:pb-2">
<CardTitle>لیست کاربران</CardTitle>
<CardDescription>نمایش کاربران مطابق فیلترهای انتخابی</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{usersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
) : users.length === 0 ? (
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
) : (
<div className="space-y-3">
<ScrollArea className="rounded-md border hidden md:block">
<table dir="rtl" className="w-full min-w-[700px] text-sm">
<thead className="text-xs uppercase text-muted-foreground">
<tr>
<th className="px-3 py-2 text-right">نام کامل</th>
<th className="px-3 py-2 text-right">نام کاربری</th>
<th className="px-3 py-2 text-right">ایمیل</th>
<th className="px-3 py-2 text-right">دانشگاه / گرایش</th>
<th className="px-3 py-2 text-right">وضعیت</th>
<th className="px-3 py-2 text-right">تاریخ عضویت</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/50">
<td className="px-3 py-2 text-right">
{(() => {
const parts = [user.first_name, user.last_name].filter(Boolean);
if (parts.length) return parts.join(' ');
return user.username;
})()}
</td>
<td className="px-3 py-2 text-right">{user.username}</td>
<td className="px-3 py-2 text-right">{user.email}</td>
<td className="px-3 py-2 text-right">
{user.major || '—'} · {user.university || '—'}
</td>
<td className="px-3 py-2 text-right">
<Badge variant={user.is_active ? 'default' : 'outline'}>
{user.is_active ? 'فعال' : 'غیرفعال'}
</Badge>
</td>
<td className="px-3 py-2 text-right">
{formatJalali(user.date_joined)}
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
<div className="grid gap-3 md:hidden">
{users.map((user) => (
<div key={user.id} className="rounded-lg border p-3 space-y-2 bg-card">
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-right">{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.username}</div>
<Badge variant={user.is_active ? 'default' : 'outline'}>{user.is_active ? 'فعال' : 'غیرفعال'}</Badge>
</div>
<div className="text-xs text-muted-foreground text-right space-y-1">
<div>نام کاربری: {user.username}</div>
<div>ایمیل: {user.email}</div>
<div>دانشگاه / گرایش: {user.university || '—'} · {user.major || '—'}</div>
<div>تاریخ عضویت: {formatJalali(user.date_joined)}</div>
</div>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>صفحه {formatNumberPersian(page)}</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
>
قبلی
</Button>
<Button
size="sm"
variant="outline"
disabled={!hasMore}
onClick={() => setPage((prev) => prev + 1)}
>
بعدی
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default AdminUsersPage;