feat(admin): add user and metadata management pages

This commit is contained in:
2026-06-14 00:04:35 +03:30
parent 9080b0caea
commit 0e7bf49b61
4 changed files with 451 additions and 186 deletions

View File

@@ -0,0 +1,5 @@
import AdminMetaOptions from "@/views/AdminMetaOptions";
export default function AdminMajorsRoute() {
return <AdminMetaOptions kind="majors" />;
}

View File

@@ -0,0 +1,5 @@
import AdminMetaOptions from "@/views/AdminMetaOptions";
export default function AdminUniversitiesRoute() {
return <AdminMetaOptions kind="universities" />;
}

View File

@@ -0,0 +1,205 @@
"use client";
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit3, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MetaOptionSchema, MetaOptionWriteSchema } from "@/lib/types";
import { api } from "@/lib/api";
import { formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
const PAGE_SIZE = 20;
type Kind = "majors" | "universities";
const config = {
majors: {
title: "رشته‌ها",
description: "مدیریت رشته‌های قابل انتخاب کاربران",
list: api.listAdminMajors.bind(api),
create: api.createMajor.bind(api),
update: api.updateMajor.bind(api),
delete: api.deleteMajor.bind(api),
},
universities: {
title: "دانشگاه‌ها",
description: "مدیریت دانشگاه‌های قابل انتخاب کاربران",
list: api.listAdminUniversities.bind(api),
create: api.createUniversity.bind(api),
update: api.updateUniversity.bind(api),
delete: api.deleteUniversity.bind(api),
},
};
export default function AdminMetaOptions({ kind }: { kind: Kind }) {
const spec = config[kind];
const queryClient = useQueryClient();
const { toast } = useToast();
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [page, setPage] = React.useState(1);
const [editing, setEditing] = React.useState<MetaOptionSchema | null>(null);
const [open, setOpen] = React.useState(false);
const [form, setForm] = React.useState<MetaOptionWriteSchema>({ code: "", name: "" });
React.useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
const query = useQuery({
queryKey: ["admin", kind, debouncedSearch, page],
queryFn: () => spec.list({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
});
const saveMutation = useMutation({
mutationFn: () => (editing ? spec.update(editing.id, form) : spec.create(form)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", kind] });
setOpen(false);
toast({ title: "ذخیره شد", variant: "success" });
},
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => spec.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", kind] });
toast({ title: "حذف شد", variant: "success" });
},
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
});
const openCreate = () => {
setEditing(null);
setForm({ code: "", name: "" });
setOpen(true);
};
const openEdit = (item: MetaOptionSchema) => {
setEditing(item);
setForm({ code: item.code, name: item.name });
setOpen(true);
};
const items = query.data?.results ?? [];
const count = query.data?.count ?? 0;
const hasMore = page * PAGE_SIZE < count;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-semibold">{spec.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{spec.description}</p>
</div>
<Button onClick={openCreate}>
<Plus className="ml-2 h-4 w-4" />
افزودن
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>فهرست</CardTitle>
<CardDescription>جستجو، ویرایش و حذف نرم موارد</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="جستجو..."
className="max-w-md"
/>
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full min-w-[520px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-right">نام</th>
<th className="px-4 py-3 text-right">کد</th>
<th className="px-4 py-3 text-right">کاربران</th>
<th className="px-4 py-3 text-left"></th>
</tr>
</thead>
<tbody>
{query.isLoading ? (
<tr>
<td className="px-4 py-6 text-center text-muted-foreground" colSpan={4}>در حال بارگذاری...</td>
</tr>
) : items.length === 0 ? (
<tr>
<td className="px-4 py-6 text-center text-muted-foreground" colSpan={4}>موردی یافت نشد.</td>
</tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-t hover:bg-muted/40">
<td className="px-4 py-3 font-medium">{item.name}</td>
<td className="px-4 py-3 text-muted-foreground">{item.code}</td>
<td className="px-4 py-3">{formatNumberPersian(item.user_count ?? 0)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
<Edit3 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => deleteMutation.mutate(item.id)}
aria-label="حذف"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))}</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>قبلی</Button>
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((current) => current + 1)}>بعدی</Button>
</div>
</div>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent dir="rtl">
<DialogHeader className="text-right">
<DialogTitle>{editing ? "ویرایش" : "افزودن"} {spec.title}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<Label>نام</Label>
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>کد</Label>
<Input dir="ltr" value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} />
</div>
</div>
<DialogFooter className="gap-2 sm:justify-start">
<Button variant="outline" onClick={() => setOpen(false)}>انصراف</Button>
<Button disabled={saveMutation.isPending || !form.name.trim() || !form.code.trim()} onClick={() => saveMutation.mutate()}>
ذخیره
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,183 +1,238 @@
"use client"; "use client";
import * as React from 'react'; import * as React from "react";
import { import { useQuery } from "@tanstack/react-query";
useQuery, import { Mail, Phone, UserRound } from "lucide-react";
} from '@tanstack/react-query'; import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
import type { UserListSchema } from '@/lib/types'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { api } from '@/lib/api'; import { Badge } from "@/components/ui/badge";
import { Badge } from '@/components/ui/badge'; import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Card, import { Input } from "@/components/ui/input";
CardContent, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
CardDescription, import type { UserListSchema, UserProfileSchema } from "@/lib/types";
CardHeader, import { api } from "@/lib/api";
CardTitle, import { formatJalali, formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
} from '@/components/ui/card'; import { useToast } from "@/hooks/use-toast";
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 USERS_PAGE_SIZE = 25;
const AdminUsersPage: React.FC = () => { function fullName(user: Pick<UserListSchema, "first_name" | "last_name" | "username">) {
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
}
function initials(user: Pick<UserListSchema, "first_name" | "last_name" | "username">) {
const base = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
return base.slice(0, 2).toUpperCase();
}
function InfoRow({ label, value }: { label: string; value?: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/35 px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="text-left font-medium">{value || "—"}</span>
</div>
);
}
function UserDetailDialog({
user,
open,
onOpenChange,
}: {
user: UserProfileSchema | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle>جزئیات کاربر</DialogTitle>
</DialogHeader>
<div className="space-y-5">
<div className="flex items-center gap-3 rounded-2xl border bg-muted/20 p-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.profile_picture_thumbnail_url || user.profile_picture || undefined} />
<AvatarFallback>{initials(user)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-right">
<h3 className="truncate text-lg font-bold">{fullName(user)}</h3>
<p className="truncate text-sm text-muted-foreground">{user.username}</p>
</div>
<div className="mr-auto flex flex-wrap gap-2">
<Badge variant={user.is_active ? "default" : "outline"}>{user.is_active ? "فعال" : "غیرفعال"}</Badge>
{user.is_staff ? <Badge variant="secondary">Staff</Badge> : null}
{user.is_superuser ? <Badge variant="destructive">Superuser</Badge> : null}
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<InfoRow label="نام" value={user.first_name} />
<InfoRow label="نام خانوادگی" value={user.last_name} />
<InfoRow label="موبایل" value={user.mobile} />
<InfoRow label="ایمیل" value={user.email} />
<InfoRow label="شماره دانشجویی" value={user.student_id} />
<InfoRow label="سال ورود" value={user.year_of_study ? formatNumberPersian(user.year_of_study) : null} />
<InfoRow label="دانشگاه" value={user.university} />
<InfoRow label="رشته" value={user.major} />
<InfoRow label="تاریخ عضویت" value={formatJalali(user.date_joined)} />
<InfoRow label="حذف شده" value={user.is_deleted ? "بله" : "خیر"} />
<InfoRow label="تایید موبایل" value={user.is_mobile_verified ? "بله" : "خیر"} />
<InfoRow label="تایید ایمیل" value={user.is_email_verified ? "بله" : "خیر"} />
<InfoRow label="دسترسی بلاگ" value={user.can_access_blog_admin ? "دارد" : "ندارد"} />
<InfoRow label="نوشتن بلاگ" value={user.can_write_blog_posts ? "دارد" : "ندارد"} />
<InfoRow label="بازبینی بلاگ" value={user.can_review_blog_posts ? "دارد" : "ندارد"} />
<InfoRow label="اتصال گوگل" value={user.has_google_link ? "دارد" : "ندارد"} />
</div>
{user.bio ? (
<div className="rounded-2xl border bg-background p-4 text-right text-sm leading-7">
<div className="mb-2 font-semibold">بیوگرافی</div>
{user.bio}
</div>
) : null}
</div>
</DialogContent>
</Dialog>
);
}
export default function AdminUsersPage() {
const { toast } = useToast(); const { toast } = useToast();
const [filters, setFilters] = React.useState({ const [filters, setFilters] = React.useState({
search: '', search: "",
studentId: '', studentId: "",
university: 'all', university: null as string | null,
major: 'all', major: null as string | null,
isActive: 'all', isActive: "all",
}); });
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
const [selectedUserId, setSelectedUserId] = React.useState<number | null>(null);
const majorsQuery = useQuery({ React.useEffect(() => {
queryKey: ['majors'], const timer = window.setTimeout(() => setDebouncedSearch(filters.search.trim()), 300);
queryFn: () => api.getMajors(), return () => window.clearTimeout(timer);
}); }, [filters.search]);
const universitiesQuery = useQuery({
queryKey: ['universities'],
queryFn: () => api.getUniversities(),
});
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ['admin', 'users', filters, page], queryKey: ["admin", "users", filters, debouncedSearch, page],
queryFn: () => queryFn: () =>
api.listUsers({ api.listUsers({
search: filters.search || undefined, search: debouncedSearch || undefined,
student_id: filters.studentId || undefined, student_id: filters.studentId || undefined,
university: filters.university === 'all' ? undefined : filters.university, university: filters.university || undefined,
major: filters.major === 'all' ? undefined : filters.major, major: filters.major || undefined,
is_active: is_active:
filters.isActive === 'all' filters.isActive === "all"
? undefined ? undefined
: filters.isActive === 'active' : filters.isActive === "active"
? 'true' ? "true"
: 'false', : "false",
limit: USERS_PAGE_SIZE, limit: USERS_PAGE_SIZE,
offset: (page - 1) * USERS_PAGE_SIZE, offset: (page - 1) * USERS_PAGE_SIZE,
}), }),
}); });
const users = usersQuery.data ?? []; const selectedUserQuery = useQuery({
const hasMore = users.length === USERS_PAGE_SIZE; queryKey: ["admin", "users", selectedUserId, "detail"],
queryFn: () => api.getUserDetail(selectedUserId as number),
enabled: selectedUserId != null,
});
React.useEffect(() => { React.useEffect(() => {
if (usersQuery.error) { if (usersQuery.error) {
toast({ toast({
title: 'خطا در بارگذاری کاربران', title: "خطا در بارگذاری کاربران",
description: resolveErrorMessage(usersQuery.error), description: resolveErrorMessage(usersQuery.error),
variant: 'destructive', variant: "destructive",
}); });
} }
}, [usersQuery.error, toast]); }, [usersQuery.error, toast]);
const handleFilterChange = (field: keyof typeof filters, value: string) => { const users = usersQuery.data ?? [];
setFilters((prev) => ({ ...prev, [field]: value })); const hasMore = users.length === USERS_PAGE_SIZE;
const handleFilterChange = (field: keyof typeof filters, value: string | null) => {
setFilters((prev) => ({ ...prev, [field]: value ?? "" }));
setPage(1); setPage(1);
}; };
const loadMajors = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
const data = await api.getMajorsPaged(params);
return {
count: data.count,
results: data.results.map((item) => ({ value: item.code, label: item.label })),
};
}, []);
const loadUniversities = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
const data = await api.getUniversitiesPaged(params);
return {
count: data.count,
results: data.results.map((item) => ({ value: item.code, label: item.label })),
};
}, []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-semibold">کاربران</h2> <h2 className="text-xl font-semibold">کاربران</h2>
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p> <p className="mt-1 text-sm text-muted-foreground">مدیریت و جستجوی کاربران سامانه</p>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>فیلترها</CardTitle> <CardTitle>فیلترها</CardTitle>
<CardDescription>جستجو و محدود کردن نتایج</CardDescription> <CardDescription>جستجو با نام، ایمیل، موبایل، دانشگاه یا رشته</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Input <Input
placeholder="نام، نام‌کاربری یا ایمیل..." placeholder="نام، نام‌کاربری، ایمیل یا موبایل..."
value={filters.search} value={filters.search}
onChange={(event) => handleFilterChange('search', event.target.value)} onChange={(event) => handleFilterChange("search", event.target.value)}
/> />
<Input <Input
placeholder="شماره دانشجویی" placeholder="شماره دانشجویی"
value={filters.studentId} value={filters.studentId}
onChange={(event) => handleFilterChange('studentId', event.target.value)} onChange={(event) => handleFilterChange("studentId", event.target.value)}
/> />
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange('isActive', value)}> <Select value={filters.isActive} onValueChange={(value) => handleFilterChange("isActive", value)}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="وضعیت"> <SelectValue />
{{
all: 'همه وضعیت‌ها',
active: 'فعال',
inactive: 'غیرفعال',
}[filters.isActive]}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">همه</SelectItem> <SelectItem value="all">همه وضعیتها</SelectItem>
<SelectItem value="active">فعال</SelectItem> <SelectItem value="active">فعال</SelectItem>
<SelectItem value="inactive">غیرفعال</SelectItem> <SelectItem value="inactive">غیرفعال</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> <AsyncSearchableCombobox
<div className="grid gap-3 md:grid-cols-2">
<Select
value={filters.university} value={filters.university}
onValueChange={(value) => handleFilterChange('university', value)} onChange={(value) => handleFilterChange("university", value)}
> loadOptions={loadUniversities}
<SelectTrigger> placeholder="دانشگاه"
<SelectValue placeholder="دانشگاه"> />
{filters.university === 'all' </div>
? 'همه' <div className="max-w-xl">
: universitiesQuery.data?.find((item) => item.code === filters.university)?.label} <AsyncSearchableCombobox
</SelectValue> value={filters.major}
</SelectTrigger> onChange={(value) => handleFilterChange("major", value)}
<SelectContent> loadOptions={loadMajors}
<SelectItem value="all">همه</SelectItem> placeholder="رشته"
{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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-0 md:pb-2"> <CardHeader>
<CardTitle>لیست کاربران</CardTitle> <CardTitle>لیست کاربران</CardTitle>
<CardDescription>نمایش کاربران مطابق فیلترهای انتخابی</CardDescription> <CardDescription>برای مشاهده جزئیات، روی هر ردیف کلیک کنید.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{usersQuery.isLoading ? ( {usersQuery.isLoading ? (
@@ -185,91 +240,86 @@ const AdminUsersPage: React.FC = () => {
) : users.length === 0 ? ( ) : users.length === 0 ? (
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p> <p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
) : ( ) : (
<div className="space-y-3"> <div className="overflow-x-auto rounded-2xl border">
<ScrollArea className="rounded-md border hidden md:block"> <table dir="rtl" className="w-full min-w-[620px] text-sm">
<table dir="rtl" className="w-full min-w-[700px] text-sm"> <thead className="bg-muted/40 text-muted-foreground">
<thead className="text-xs uppercase text-muted-foreground"> <tr>
<tr> <th className="px-4 py-3 text-right">کاربر</th>
<th className="px-3 py-2 text-right">نام کامل</th> <th className="px-4 py-3 text-right">موبایل</th>
<th className="px-3 py-2 text-right">نام کاربری</th> <th className="px-4 py-3 text-right">ایمیل</th>
<th className="px-3 py-2 text-right">ایمیل</th> </tr>
<th className="px-3 py-2 text-right">دانشگاه / گرایش</th> </thead>
<th className="px-3 py-2 text-right">وضعیت</th> <tbody>
<th className="px-3 py-2 text-right">تاریخ عضویت</th> {users.map((user) => (
<tr
key={user.id}
role="button"
tabIndex={0}
className="cursor-pointer border-t transition hover:bg-muted/50"
onClick={() => setSelectedUserId(user.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") setSelectedUserId(user.id);
}}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Avatar className="h-11 w-11">
<AvatarImage src={user.profile_picture_thumbnail_url || user.profile_picture || undefined} />
<AvatarFallback>{initials(user)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-right">
<div className="truncate font-semibold">{fullName(user)}</div>
<div className="truncate text-xs text-muted-foreground">{user.username}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
{user.mobile || "—"}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
{user.email || "—"}
</span>
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{users.map((user) => ( </table>
<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>
)} )}
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>صفحه {formatNumberPersian(page)}</span> <span>صفحه {formatNumberPersian(page)}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
>
قبلی قبلی
</Button> </Button>
<Button <Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((prev) => prev + 1)}>
size="sm"
variant="outline"
disabled={!hasMore}
onClick={() => setPage((prev) => prev + 1)}
>
بعدی بعدی
</Button> </Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<UserDetailDialog
user={selectedUserQuery.data ?? null}
open={selectedUserId != null}
onOpenChange={(open) => {
if (!open) setSelectedUserId(null);
}}
/>
{selectedUserQuery.isFetching && selectedUserId ? (
<div className="fixed inset-x-0 bottom-24 z-50 mx-auto flex w-fit items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm shadow-lg">
<UserRound className="h-4 w-4 animate-pulse" />
در حال بارگذاری جزئیات...
</div>
) : null}
</div> </div>
); );
}; }
export default AdminUsersPage;