feat(events): add admin registration detail sidebar
This commit is contained in:
@@ -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 <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
|
||||
if (status === "pending") return <Clock3 className="h-4 w-4 text-amber-500" />;
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
}
|
||||
|
||||
function RegistrationDialog({
|
||||
registration,
|
||||
onOpenChange,
|
||||
}: {
|
||||
registration: RegistrationAdminSchema | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={Boolean(registration)} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto" dir="rtl">
|
||||
<DialogHeader className="text-right">
|
||||
<DialogTitle>جزئیات ثبتنام</DialogTitle>
|
||||
</DialogHeader>
|
||||
{registration ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border p-4">
|
||||
<Avatar className="h-14 w-14">
|
||||
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 text-right">
|
||||
<div className="font-bold">{registration.user.first_name} {registration.user.last_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{registration.user.mobile || registration.user.email}</div>
|
||||
</div>
|
||||
<Badge className="mr-auto" variant={registration.status === "cancelled" ? "destructive" : "secondary"}>
|
||||
{registration.status_label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Info label="موبایل" value={registration.user.mobile} />
|
||||
<Info label="ایمیل" value={registration.user.email} />
|
||||
<Info label="دانشگاه" value={registration.user.university} />
|
||||
<Info label="رشته" value={registration.user.major} />
|
||||
<Info label="شماره دانشجویی" value={registration.user.student_id} />
|
||||
<Info label="کد بلیت" value={registration.ticket_id} />
|
||||
<Info label="تاریخ ثبتنام" value={formatJalali(registration.registered_at)} />
|
||||
<Info label="مبلغ نهایی" value={formatToman(registration.final_price ?? 0)} />
|
||||
<Info label="تخفیف" value={formatToman(registration.discount_amount ?? 0)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">پرداختها</div>
|
||||
{registration.payments.length ? registration.payments.map((payment) => (
|
||||
<div key={payment.id} className="rounded-2xl border bg-muted/20 p-3 text-sm">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Info label="وضعیت" value={payment.status_label} />
|
||||
<Info label="مبلغ" value={formatToman(payment.amount)} />
|
||||
<Info label="کد رهگیری" value={payment.ref_id} />
|
||||
<Info label="Authority" value={payment.authority} />
|
||||
<Info label="کارت" value={payment.card_pan} />
|
||||
<Info label="کد تخفیف" value={payment.discount_code} />
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-sm text-muted-foreground">پرداختی ثبت نشده است.</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-background px-3 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-left font-medium">{value || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminEventDetail() {
|
||||
const { id } = useParams();
|
||||
const { toast } = useToast();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [statusFilter, setStatusFilter] = React.useState<typeof registrationStatusOptions[number]['value'] | 'all'>('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<string | null>(null);
|
||||
const [major, setMajor] = React.useState<string | null>(null);
|
||||
const [regPage, setRegPage] = React.useState(1);
|
||||
const [selectedRegistration, setSelectedRegistration] = React.useState<RegistrationAdminSchema | null>(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 <div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||
}
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
if (!Number.isFinite(eventId)) {
|
||||
return <div className="min-h-screen flex items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||
}
|
||||
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 <div className="flex min-h-screen items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" replace />;
|
||||
if (!Number.isFinite(eventId)) return <div className="flex min-h-screen items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||
|
||||
const event = detailQuery.data;
|
||||
const paged = registrationsQuery.data;
|
||||
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{event?.title ?? 'جزئیات رویداد'}</h1>
|
||||
{event && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<Badge variant="secondary">{event.status_label ?? event.status}</Badge>
|
||||
{event.start_time ? <span>شروع: {formatJalali(event.start_time)}</span> : null}
|
||||
{event.event_type ? <span>نوع: {event.event_type_label ?? event.event_type}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl font-black">{event?.title ?? "جزئیات رویداد"}</h1>
|
||||
{event ? <p className="mt-1 text-sm text-muted-foreground">شروع: {formatJalali(event.start_time)} · ثبتنامها: {toPersianDigits(event.registration_count)}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/admin/events/${eventId}/edit`}>ویرایش پیشرفته</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/events">بازگشت</Link>
|
||||
</Button>
|
||||
<Button asChild><Link to={`/admin/events/${eventId}/edit`}>ویرایش</Link></Button>
|
||||
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<main className="space-y-6">
|
||||
{event ? (
|
||||
<>
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="aspect-video overflow-hidden rounded-3xl bg-muted shadow-sm"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>وضعیت</CardTitle>
|
||||
<CardDescription>اطلاعات پایه رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<div>ظرفیت: {event.capacity ?? 'نامحدود'}</div>
|
||||
<div>ثبتنامها: {toPersianDigits(event.registration_count ?? 0)}</div>
|
||||
<div>قیمت: {formatToman(event.price)}</div>
|
||||
<CardContent className="grid gap-3 p-5 text-sm md:grid-cols-2">
|
||||
<Info label="نوع" value={event.event_type} />
|
||||
<Info label="وضعیت" value={event.status} />
|
||||
<Info label="ظرفیت" value={event.capacity ?? "نامحدود"} />
|
||||
<Info label="قیمت" value={Number(event.price || 0) === 0 ? "رایگان" : formatToman(event.price)} />
|
||||
<Info label="شروع ثبتنام" value={event.registration_start_date ? formatJalali(event.registration_start_date) : "—"} />
|
||||
<Info label="پایان ثبتنام" value={event.registration_end_date ? formatJalali(event.registration_end_date) : "—"} />
|
||||
<Info label="آدرس" value={event.address} />
|
||||
<Info label="لینک آنلاین" value={event.online_link} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>توضیحات</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground leading-6">
|
||||
{event.description || 'توضیحی ثبت نشده است.'}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Markdown content={event.description || "توضیحی ثبت نشده است."} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ثبتنامها و پرداختها</CardTitle>
|
||||
<CardDescription>لیست ثبتنامهای مرتبط با این رویداد</CardDescription>
|
||||
<CardTitle>اطلاعات ثبتنام</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value as typeof statusFilter); setRegPage(1); }}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="وضعیت">وضعیت: {statusFilter === 'all' ? 'همه' : statusFilter}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<CardContent className="space-y-3">
|
||||
<Input placeholder="جستجو نام، موبایل یا ایمیل..." value={search} onChange={(event) => { setSearch(event.target.value); setRegPage(1); }} />
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); setRegPage(1); }}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{registrationStatusOptions.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
{statusOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
className="md:w-64"
|
||||
placeholder="جستجو نام/ایمیل/نامکاربری"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setRegPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<AsyncSearchableCombobox value={university} onChange={(value) => { setUniversity(value); setRegPage(1); }} loadOptions={loadUniversities} placeholder="دانشگاه" />
|
||||
<AsyncSearchableCombobox value={major} onChange={(value) => { setMajor(value); setRegPage(1); }} loadOptions={loadMajors} placeholder="رشته" />
|
||||
|
||||
<div className="space-y-2">
|
||||
{registrationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری ثبتنامها...</p>
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : !paged || paged.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">ثبتنامی یافت نشد.</p>
|
||||
) : (
|
||||
<ScrollArea className="rounded-md border max-h-[70vh]">
|
||||
<div className="divide-y">
|
||||
{paged.results.map((registration) => (
|
||||
<div key={registration.id} className="p-4">
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">{registration.user.first_name} {registration.user.last_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{registration.user.email}</div>
|
||||
</div>
|
||||
<Badge variant={registration.status === 'confirmed' ? 'default' : 'outline'}>
|
||||
{registration.status_label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>نامکاربری: {registration.user.username}</div>
|
||||
<div>کد بلیت: {registration.ticket_id}</div>
|
||||
<div>تاریخ ثبتنام: {formatJalali(registration.registered_at)}</div>
|
||||
<div>مبلغ پرداختی: {formatToman(registration.final_price ?? 0)}</div>
|
||||
<div>تخفیف: {formatToman(registration.discount_amount ?? 0)}</div>
|
||||
</div>
|
||||
{registration.payments.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="font-medium">پرداختها</div>
|
||||
{registration.payments.map((payment) => (
|
||||
<div key={payment.id} className="flex flex-wrap items-center justify-between gap-2 rounded border px-2 py-1">
|
||||
<span className="text-muted-foreground">{payment.status_label}</span>
|
||||
<span>{formatToman(payment.amount)}</span>
|
||||
<span className="text-muted-foreground text-[11px]">Ref: {payment.ref_id ?? '—'}</span>
|
||||
</div>
|
||||
) : paged.results.map((registration) => (
|
||||
<button
|
||||
key={registration.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-2xl border bg-background p-3 text-right transition hover:bg-muted/40"
|
||||
onClick={() => setSelectedRegistration(registration)}
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{registration.user.first_name} {registration.user.last_name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{registration.user.mobile || registration.user.email}</span>
|
||||
</span>
|
||||
{statusIcon(registration.status)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((p) => Math.max(1, p - 1))}>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((p) => p + 1)}>
|
||||
بعدی
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((page) => Math.max(1, page - 1))}>قبلی</Button>
|
||||
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((page) => page + 1)}>بعدی</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RegistrationDialog registration={selectedRegistration} onOpenChange={(open) => { if (!open) setSelectedRegistration(null); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user