feat(admin): add user and metadata management pages
This commit is contained in:
5
src/app/admin/majors/page.tsx
Normal file
5
src/app/admin/majors/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||
|
||||
export default function AdminMajorsRoute() {
|
||||
return <AdminMetaOptions kind="majors" />;
|
||||
}
|
||||
5
src/app/admin/universities/page.tsx
Normal file
5
src/app/admin/universities/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||
|
||||
export default function AdminUniversitiesRoute() {
|
||||
return <AdminMetaOptions kind="universities" />;
|
||||
}
|
||||
205
src/views/AdminMetaOptions.tsx
Normal file
205
src/views/AdminMetaOptions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +1,238 @@
|
||||
"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';
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Mail, Phone, UserRound } from "lucide-react";
|
||||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { UserListSchema, UserProfileSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatJalali, formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
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 [filters, setFilters] = React.useState({
|
||||
search: '',
|
||||
studentId: '',
|
||||
university: 'all',
|
||||
major: 'all',
|
||||
isActive: 'all',
|
||||
search: "",
|
||||
studentId: "",
|
||||
university: null as string | null,
|
||||
major: null as string | null,
|
||||
isActive: "all",
|
||||
});
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<number | null>(null);
|
||||
|
||||
const majorsQuery = useQuery({
|
||||
queryKey: ['majors'],
|
||||
queryFn: () => api.getMajors(),
|
||||
});
|
||||
const universitiesQuery = useQuery({
|
||||
queryKey: ['universities'],
|
||||
queryFn: () => api.getUniversities(),
|
||||
});
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(filters.search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [filters.search]);
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin', 'users', filters, page],
|
||||
queryKey: ["admin", "users", filters, debouncedSearch, page],
|
||||
queryFn: () =>
|
||||
api.listUsers({
|
||||
search: filters.search || undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
student_id: filters.studentId || undefined,
|
||||
university: filters.university === 'all' ? undefined : filters.university,
|
||||
major: filters.major === 'all' ? undefined : filters.major,
|
||||
university: filters.university || undefined,
|
||||
major: filters.major || undefined,
|
||||
is_active:
|
||||
filters.isActive === 'all'
|
||||
filters.isActive === "all"
|
||||
? undefined
|
||||
: filters.isActive === 'active'
|
||||
? 'true'
|
||||
: 'false',
|
||||
: 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;
|
||||
const selectedUserQuery = useQuery({
|
||||
queryKey: ["admin", "users", selectedUserId, "detail"],
|
||||
queryFn: () => api.getUserDetail(selectedUserId as number),
|
||||
enabled: selectedUserId != null,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (usersQuery.error) {
|
||||
toast({
|
||||
title: 'خطا در بارگذاری کاربران',
|
||||
title: "خطا در بارگذاری کاربران",
|
||||
description: resolveErrorMessage(usersQuery.error),
|
||||
variant: 'destructive',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [usersQuery.error, toast]);
|
||||
|
||||
const handleFilterChange = (field: keyof typeof filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }));
|
||||
const users = usersQuery.data ?? [];
|
||||
const hasMore = users.length === USERS_PAGE_SIZE;
|
||||
|
||||
const handleFilterChange = (field: keyof typeof filters, value: string | null) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value ?? "" }));
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>فیلترها</CardTitle>
|
||||
<CardDescription>جستجو و محدود کردن نتایج</CardDescription>
|
||||
<CardDescription>جستجو با نام، ایمیل، موبایل، دانشگاه یا رشته</CardDescription>
|
||||
</CardHeader>
|
||||
<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
|
||||
placeholder="نام، نامکاربری یا ایمیل..."
|
||||
placeholder="نام، نامکاربری، ایمیل یا موبایل..."
|
||||
value={filters.search}
|
||||
onChange={(event) => handleFilterChange('search', event.target.value)}
|
||||
onChange={(event) => handleFilterChange("search", event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="شماره دانشجویی"
|
||||
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>
|
||||
<SelectValue placeholder="وضعیت">
|
||||
{{
|
||||
all: 'همه وضعیتها',
|
||||
active: 'فعال',
|
||||
inactive: 'غیرفعال',
|
||||
}[filters.isActive]}
|
||||
</SelectValue>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
<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
|
||||
<AsyncSearchableCombobox
|
||||
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>
|
||||
onChange={(value) => handleFilterChange("university", value)}
|
||||
loadOptions={loadUniversities}
|
||||
placeholder="دانشگاه"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-xl">
|
||||
<AsyncSearchableCombobox
|
||||
value={filters.major}
|
||||
onChange={(value) => handleFilterChange("major", value)}
|
||||
loadOptions={loadMajors}
|
||||
placeholder="رشته"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-0 md:pb-2">
|
||||
<CardHeader>
|
||||
<CardTitle>لیست کاربران</CardTitle>
|
||||
<CardDescription>نمایش کاربران مطابق فیلترهای انتخابی</CardDescription>
|
||||
<CardDescription>برای مشاهده جزئیات، روی هر ردیف کلیک کنید.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{usersQuery.isLoading ? (
|
||||
@@ -185,91 +240,86 @@ const AdminUsersPage: React.FC = () => {
|
||||
) : 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">
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<table dir="rtl" className="w-full min-w-[620px] text-sm">
|
||||
<thead className="bg-muted/40 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>
|
||||
<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>
|
||||
</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;
|
||||
})()}
|
||||
<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-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 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-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 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>
|
||||
))}
|
||||
</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 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 size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((prev) => prev + 1)}>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsersPage;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user