From 0e7bf49b6127b31dc4eba3cea788695c1353fea7 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 14 Jun 2026 00:04:35 +0330 Subject: [PATCH] feat(admin): add user and metadata management pages --- src/app/admin/majors/page.tsx | 5 + src/app/admin/universities/page.tsx | 5 + src/views/AdminMetaOptions.tsx | 205 ++++++++++++++ src/views/AdminUsers.tsx | 422 ++++++++++++++++------------ 4 files changed, 451 insertions(+), 186 deletions(-) create mode 100644 src/app/admin/majors/page.tsx create mode 100644 src/app/admin/universities/page.tsx create mode 100644 src/views/AdminMetaOptions.tsx diff --git a/src/app/admin/majors/page.tsx b/src/app/admin/majors/page.tsx new file mode 100644 index 0000000..1ca2ffe --- /dev/null +++ b/src/app/admin/majors/page.tsx @@ -0,0 +1,5 @@ +import AdminMetaOptions from "@/views/AdminMetaOptions"; + +export default function AdminMajorsRoute() { + return ; +} diff --git a/src/app/admin/universities/page.tsx b/src/app/admin/universities/page.tsx new file mode 100644 index 0000000..574db00 --- /dev/null +++ b/src/app/admin/universities/page.tsx @@ -0,0 +1,5 @@ +import AdminMetaOptions from "@/views/AdminMetaOptions"; + +export default function AdminUniversitiesRoute() { + return ; +} diff --git a/src/views/AdminMetaOptions.tsx b/src/views/AdminMetaOptions.tsx new file mode 100644 index 0000000..e6fcd37 --- /dev/null +++ b/src/views/AdminMetaOptions.tsx @@ -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(null); + const [open, setOpen] = React.useState(false); + const [form, setForm] = React.useState({ 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 ( +
+
+
+

{spec.title}

+

{spec.description}

+
+ +
+ + + + فهرست + جستجو، ویرایش و حذف نرم موارد + + + { + setSearch(event.target.value); + setPage(1); + }} + placeholder="جستجو..." + className="max-w-md" + /> +
+ + + + + + + + + + + {query.isLoading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item) => ( + + + + + + + )) + )} + +
نامکدکاربران
در حال بارگذاری...
موردی یافت نشد.
{item.name}{item.code}{formatNumberPersian(item.user_count ?? 0)} +
+ + +
+
+
+
+ صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))} +
+ + +
+
+
+
+ + + + + {editing ? "ویرایش" : "افزودن"} {spec.title} + +
+
+ + setForm((current) => ({ ...current, name: event.target.value }))} /> +
+
+ + setForm((current) => ({ ...current, code: event.target.value }))} /> +
+
+ + + + +
+
+
+ ); +} diff --git a/src/views/AdminUsers.tsx b/src/views/AdminUsers.tsx index 774461d..02c38a8 100644 --- a/src/views/AdminUsers.tsx +++ b/src/views/AdminUsers.tsx @@ -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) { + return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username; +} + +function initials(user: Pick) { + 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 ( +
+ {label} + {value || "—"} +
+ ); +} + +function UserDetailDialog({ + user, + open, + onOpenChange, +}: { + user: UserProfileSchema | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!user) return null; + return ( + + + + جزئیات کاربر + +
+
+ + + {initials(user)} + +
+

{fullName(user)}

+

{user.username}

+
+
+ {user.is_active ? "فعال" : "غیرفعال"} + {user.is_staff ? Staff : null} + {user.is_superuser ? Superuser : null} +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + {user.bio ? ( +
+
بیوگرافی
+ {user.bio} +
+ ) : null} +
+
+
+ ); +} + +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(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 (

کاربران

-

مدیریت و جستجوی کاربران سامانه

+

مدیریت و جستجوی کاربران سامانه

فیلترها - جستجو و محدود کردن نتایج + جستجو با نام، ایمیل، موبایل، دانشگاه یا رشته -
+
handleFilterChange('search', event.target.value)} + onChange={(event) => handleFilterChange("search", event.target.value)} /> handleFilterChange('studentId', event.target.value)} + onChange={(event) => handleFilterChange("studentId", event.target.value)} /> - handleFilterChange("isActive", value)}> - - {{ - all: 'همه وضعیت‌ها', - active: 'فعال', - inactive: 'غیرفعال', - }[filters.isActive]} - + - همه + همه وضعیت‌ها فعال غیرفعال -
- -
- - + onChange={(value) => handleFilterChange("university", value)} + loadOptions={loadUniversities} + placeholder="دانشگاه" + /> +
+
+ handleFilterChange("major", value)} + loadOptions={loadMajors} + placeholder="رشته" + />
- + لیست کاربران - نمایش کاربران مطابق فیلترهای انتخابی + برای مشاهده جزئیات، روی هر ردیف کلیک کنید. {usersQuery.isLoading ? ( @@ -185,91 +240,86 @@ const AdminUsersPage: React.FC = () => { ) : users.length === 0 ? (

کاربری یافت نشد.

) : ( -
- - - - - - - - - - +
+
نام کاملنام کاربریایمیلدانشگاه / گرایشوضعیتتاریخ عضویت
+ + + + + + + + + {users.map((user) => ( + setSelectedUserId(user.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") setSelectedUserId(user.id); + }} + > + + + - - - {users.map((user) => ( - - - - - - - - - ))} - -
کاربرموبایلایمیل
+
+ + + {initials(user)} + +
+
{fullName(user)}
+
{user.username}
+
+
+
+ + + {user.mobile || "—"} + + + + + {user.email || "—"} + +
- {(() => { - const parts = [user.first_name, user.last_name].filter(Boolean); - if (parts.length) return parts.join(' '); - return user.username; - })()} - {user.username}{user.email} - {user.major || '—'} · {user.university || '—'} - - - {user.is_active ? 'فعال' : 'غیرفعال'} - - - {formatJalali(user.date_joined)} -
-
- -
- {users.map((user) => ( -
-
-
{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.username}
- {user.is_active ? 'فعال' : 'غیرفعال'} -
-
-
نام کاربری: {user.username}
-
ایمیل: {user.email}
-
دانشگاه / گرایش: {user.university || '—'} · {user.major || '—'}
-
تاریخ عضویت: {formatJalali(user.date_joined)}
-
-
- ))} -
+ ))} + +
)} +
صفحه {formatNumberPersian(page)}
- -
+ + { + if (!open) setSelectedUserId(null); + }} + /> + + {selectedUserQuery.isFetching && selectedUserId ? ( +
+ + در حال بارگذاری جزئیات... +
+ ) : null}
); -}; - -export default AdminUsersPage; +}