Files
guilan-ace-frontend/src/views/AdminUsers.tsx

326 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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;
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: 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);
React.useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(filters.search.trim()), 300);
return () => window.clearTimeout(timer);
}, [filters.search]);
const usersQuery = useQuery({
queryKey: ["admin", "users", filters, debouncedSearch, page],
queryFn: () =>
api.listUsers({
search: debouncedSearch || undefined,
student_id: filters.studentId || undefined,
university: filters.university || undefined,
major: filters.major || undefined,
is_active:
filters.isActive === "all"
? undefined
: filters.isActive === "active"
? "true"
: "false",
limit: USERS_PAGE_SIZE,
offset: (page - 1) * 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: "خطا در بارگذاری کاربران",
description: resolveErrorMessage(usersQuery.error),
variant: "destructive",
});
}
}, [usersQuery.error, toast]);
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="mt-1 text-sm text-muted-foreground">مدیریت و جستجوی کاربران سامانه</p>
</div>
<Card>
<CardHeader>
<CardTitle>فیلترها</CardTitle>
<CardDescription>جستجو با نام، ایمیل، موبایل، دانشگاه یا رشته</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Input
placeholder="نام، نام‌کاربری، ایمیل یا موبایل..."
value={filters.search}
onChange={(event) => handleFilterChange("search", event.target.value)}
/>
<Input
placeholder="شماره دانشجویی"
value={filters.studentId}
onChange={(event) => handleFilterChange("studentId", event.target.value)}
/>
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange("isActive", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">همه وضعیت‌ها</SelectItem>
<SelectItem value="active">فعال</SelectItem>
<SelectItem value="inactive">غیرفعال</SelectItem>
</SelectContent>
</Select>
<AsyncSearchableCombobox
value={filters.university}
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>
<CardTitle>لیست کاربران</CardTitle>
<CardDescription>برای مشاهده جزئیات، روی هر ردیف کلیک کنید.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{usersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
) : users.length === 0 ? (
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
) : (
<div className="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-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}
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>
))}
</tbody>
</table>
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>صفحه {formatNumberPersian(page)}</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
قبلی
</Button>
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((prev) => prev + 1)}>
بعدی
</Button>
</div>
</div>
</CardContent>
</Card>
<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>
);
}