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";
|
"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-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>
|
<th className="px-4 py-3 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/50">
|
<tr
|
||||||
<td className="px-3 py-2 text-right">
|
key={user.id}
|
||||||
{(() => {
|
role="button"
|
||||||
const parts = [user.first_name, user.last_name].filter(Boolean);
|
tabIndex={0}
|
||||||
if (parts.length) return parts.join(' ');
|
className="cursor-pointer border-t transition hover:bg-muted/50"
|
||||||
return user.username;
|
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>
|
||||||
<td className="px-3 py-2 text-right">{user.username}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-3 py-2 text-right">{user.email}</td>
|
<span className="inline-flex items-center gap-2">
|
||||||
<td className="px-3 py-2 text-right">
|
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||||
{user.major || '—'} · {user.university || '—'}
|
{user.mobile || "—"}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-4 py-3">
|
||||||
<Badge variant={user.is_active ? 'default' : 'outline'}>
|
<span className="inline-flex items-center gap-2">
|
||||||
{user.is_active ? 'فعال' : 'غیرفعال'}
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
</Badge>
|
{user.email || "—"}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
{formatJalali(user.date_joined)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user