326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|