From ec38a4435e36fe3cc76fb29da10924461f754858 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 14 Jun 2026 00:05:13 +0330 Subject: [PATCH] feat(events): add admin registration detail sidebar --- src/views/AdminEventDetail.tsx | 403 +++++++++++++++++++-------------- 1 file changed, 232 insertions(+), 171 deletions(-) diff --git a/src/views/AdminEventDetail.tsx b/src/views/AdminEventDetail.tsx index 48ca146..8de99cc 100644 --- a/src/views/AdminEventDetail.tsx +++ b/src/views/AdminEventDetail.tsx @@ -1,216 +1,277 @@ "use client"; -import * as React from 'react'; -import { useParams, Link, Navigate } from '@/lib/router'; -import { useQuery } from '@tanstack/react-query'; -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, formatToman, resolveErrorMessage, toPersianDigits } from '@/lib/utils'; -import { useAuth } from '@/contexts/AuthContext'; +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { CheckCircle2, Clock3, XCircle } from "lucide-react"; +import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox"; +import Markdown from "@/components/Markdown"; +import ProgressiveImage from "@/components/ProgressiveImage"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, 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 { Link, Navigate, useParams } from "@/lib/router"; +import type { RegistrationAdminSchema } from "@/lib/types"; +import { api } from "@/lib/api"; +import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from "@/lib/utils"; +import { useAuth } from "@/contexts/AuthContext"; +import { useToast } from "@/hooks/use-toast"; -const registrationStatusOptions = [ - { value: 'confirmed', label: 'تایید شده' }, - { value: 'pending', label: 'در انتظار' }, - { value: 'cancelled', label: 'لغو شده' }, - { value: 'attended', label: 'حضور یافته' }, -] as const; const REGISTRATIONS_PAGE_SIZE = 10; +const statusOptions = [ + { value: "all", label: "همه" }, + { value: "confirmed", label: "تایید شده" }, + { value: "pending", label: "در انتظار" }, + { value: "cancelled", label: "لغو شده" }, + { value: "attended", label: "حضور یافته" }, +] as const; + +function initials(registration: RegistrationAdminSchema) { + const text = [registration.user.first_name, registration.user.last_name].filter(Boolean).join(" ") || registration.user.username; + return text.slice(0, 2).toUpperCase(); +} + +function statusIcon(status: RegistrationAdminSchema["status"]) { + if (status === "confirmed" || status === "attended") return ; + if (status === "pending") return ; + return ; +} + +function RegistrationDialog({ + registration, + onOpenChange, +}: { + registration: RegistrationAdminSchema | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + جزئیات ثبت‌نام + + {registration ? ( +
+
+ + + {initials(registration)} + +
+
{registration.user.first_name} {registration.user.last_name}
+
{registration.user.mobile || registration.user.email}
+
+ + {registration.status_label} + +
+
+ + + + + + + + + +
+
+
پرداخت‌ها
+ {registration.payments.length ? registration.payments.map((payment) => ( +
+
+ + + + + + +
+
+ )) :

پرداختی ثبت نشده است.

} +
+
+ ) : null} +
+
+ ); +} + +function Info({ label, value }: { label: string; value?: React.ReactNode }) { + return ( +
+ {label} + {value || "—"} +
+ ); +} + export default function AdminEventDetail() { const { id } = useParams(); const { toast } = useToast(); const { user, isAuthenticated, loading } = useAuth(); - const [statusFilter, setStatusFilter] = React.useState('all'); - const [search, setSearch] = React.useState(''); - const [regPage, setRegPage] = React.useState(1); - const eventId = Number(id); + const [statusFilter, setStatusFilter] = React.useState("all"); + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const [university, setUniversity] = React.useState(null); + const [major, setMajor] = React.useState(null); + const [regPage, setRegPage] = React.useState(1); + const [selectedRegistration, setSelectedRegistration] = React.useState(null); + + React.useEffect(() => { + const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => window.clearTimeout(timer); + }, [search]); + const detailQuery = useQuery({ - queryKey: ['admin', 'event-detail', eventId], + queryKey: ["admin", "event-detail", eventId], queryFn: () => api.getEventAdminDetail(eventId), enabled: Number.isFinite(eventId), }); const registrationsQuery = useQuery({ - queryKey: ['admin', 'event', eventId, 'registrations', statusFilter, search, regPage], + queryKey: ["admin", "event", eventId, "registrations", statusFilter, debouncedSearch, university, major, regPage], enabled: Number.isFinite(eventId), queryFn: () => api.listEventRegistrationsAdmin(eventId, { - statuses: - statusFilter === 'all' - ? registrationStatusOptions.map((s) => s.value) - : [statusFilter], - search: search || undefined, + statuses: statusFilter === "all" ? undefined : [statusFilter], + search: debouncedSearch || undefined, + university: university || undefined, + major: major || undefined, limit: REGISTRATIONS_PAGE_SIZE, offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE, }), }); React.useEffect(() => { - if (detailQuery.error) { - toast({ title: 'خطا در دریافت جزئیات رویداد', description: resolveErrorMessage(detailQuery.error), variant: 'destructive' }); - } - }, [detailQuery.error, toast]); + const error = detailQuery.error || registrationsQuery.error; + if (error) toast({ title: "خطا در دریافت اطلاعات رویداد", description: resolveErrorMessage(error), variant: "destructive" }); + }, [detailQuery.error, registrationsQuery.error, toast]); - React.useEffect(() => { - if (registrationsQuery.error) { - toast({ title: 'خطا در ثبت‌نام‌ها', description: resolveErrorMessage(registrationsQuery.error), variant: 'destructive' }); - } - }, [registrationsQuery.error, toast]); + 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 })) }; + }, []); - if (loading) { - return
در حال بارگذاری...
; - } - if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) { - return ; - } - if (!Number.isFinite(eventId)) { - return
شناسه رویداد معتبر نیست.
; - } + 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 })) }; + }, []); + + if (loading) return
در حال بارگذاری...
; + if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return ; + if (!Number.isFinite(eventId)) return
شناسه رویداد معتبر نیست.
; const event = detailQuery.data; const paged = registrationsQuery.data; const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1; return ( -
-
-
-
-

{event?.title ?? 'جزئیات رویداد'}

- {event && ( -
- {event.status_label ?? event.status} - {event.start_time ? شروع: {formatJalali(event.start_time)} : null} - {event.event_type ? نوع: {event.event_type_label ?? event.event_type} : null} -
- )} -
-
- - -
+
+
+
+

{event?.title ?? "جزئیات رویداد"}

+ {event ?

شروع: {formatJalali(event.start_time)} · ثبت‌نام‌ها: {toPersianDigits(event.registration_count)}

: null}
+
+ + +
+
- {event && ( -
- - - وضعیت - اطلاعات پایه رویداد - - -
ظرفیت: {event.capacity ?? 'نامحدود'}
-
ثبت‌نام‌ها: {toPersianDigits(event.registration_count ?? 0)}
-
قیمت: {formatToman(event.price)}
-
-
- - - توضیحات - - - {event.description || 'توضیحی ثبت نشده است.'} - - -
- )} +
+
+ {event ? ( + <> + + + + + + + + + + + + + + + + + + + + ) : ( +

در حال بارگذاری جزئیات...

+ )} +
- - - ثبت‌نام‌ها و پرداخت‌ها - لیست ثبت‌نام‌های مرتبط با این رویداد - - -
-
- { setSearch(event.target.value); setRegPage(1); }} /> + -
- { setSearch(e.target.value); setRegPage(1); }} - /> -
+ { setUniversity(value); setRegPage(1); }} loadOptions={loadUniversities} placeholder="دانشگاه" /> + { setMajor(value); setRegPage(1); }} loadOptions={loadMajors} placeholder="رشته" /> - {registrationsQuery.isLoading ? ( -

در حال بارگذاری ثبت‌نام‌ها...

- ) : !paged || paged.results.length === 0 ? ( -

ثبت‌نامی یافت نشد.

- ) : ( - -
- {paged.results.map((registration) => ( -
-
-
-
{registration.user.first_name} {registration.user.last_name}
-
{registration.user.email}
-
- - {registration.status_label} - -
-
-
نام‌کاربری: {registration.user.username}
-
کد بلیت: {registration.ticket_id}
-
تاریخ ثبت‌نام: {formatJalali(registration.registered_at)}
-
مبلغ پرداختی: {formatToman(registration.final_price ?? 0)}
-
تخفیف: {formatToman(registration.discount_amount ?? 0)}
-
- {registration.payments.length > 0 && ( -
-
پرداخت‌ها
- {registration.payments.map((payment) => ( -
- {payment.status_label} - {formatToman(payment.amount)} - Ref: {payment.ref_id ?? '—'} -
- ))} -
- )} -
- ))} -
-
- )} - -
- صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)} -
- - +
+ {registrationsQuery.isLoading ? ( +

در حال بارگذاری...

+ ) : !paged || paged.results.length === 0 ? ( +

ثبت‌نامی یافت نشد.

+ ) : paged.results.map((registration) => ( + + ))}
-
- - + +
+ صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)} +
+ + +
+
+ + +
+ + { if (!open) setSelectedRegistration(null); }} />
); }