feat(events): add admin registration detail sidebar
This commit is contained in:
@@ -1,216 +1,277 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { useParams, Link, Navigate } from '@/lib/router';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { CheckCircle2, Clock3, XCircle } from "lucide-react";
|
||||||
import { api } from '@/lib/api';
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import { Badge } from '@/components/ui/badge';
|
import Markdown from "@/components/Markdown";
|
||||||
import { Button } from '@/components/ui/button';
|
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Input } from '@/components/ui/input';
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { formatJalali, formatToman, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
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 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() {
|
export default function AdminEventDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
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 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({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['admin', 'event-detail', eventId],
|
queryKey: ["admin", "event-detail", eventId],
|
||||||
queryFn: () => api.getEventAdminDetail(eventId),
|
queryFn: () => api.getEventAdminDetail(eventId),
|
||||||
enabled: Number.isFinite(eventId),
|
enabled: Number.isFinite(eventId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registrationsQuery = useQuery({
|
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),
|
enabled: Number.isFinite(eventId),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.listEventRegistrationsAdmin(eventId, {
|
api.listEventRegistrationsAdmin(eventId, {
|
||||||
statuses:
|
statuses: statusFilter === "all" ? undefined : [statusFilter],
|
||||||
statusFilter === 'all'
|
search: debouncedSearch || undefined,
|
||||||
? registrationStatusOptions.map((s) => s.value)
|
university: university || undefined,
|
||||||
: [statusFilter],
|
major: major || undefined,
|
||||||
search: search || undefined,
|
|
||||||
limit: REGISTRATIONS_PAGE_SIZE,
|
limit: REGISTRATIONS_PAGE_SIZE,
|
||||||
offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE,
|
offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (detailQuery.error) {
|
const error = detailQuery.error || registrationsQuery.error;
|
||||||
toast({ title: 'خطا در دریافت جزئیات رویداد', description: resolveErrorMessage(detailQuery.error), variant: 'destructive' });
|
if (error) toast({ title: "خطا در دریافت اطلاعات رویداد", description: resolveErrorMessage(error), variant: "destructive" });
|
||||||
}
|
}, [detailQuery.error, registrationsQuery.error, toast]);
|
||||||
}, [detailQuery.error, toast]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const loadMajors = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
if (registrationsQuery.error) {
|
const data = await api.getMajorsPaged(params);
|
||||||
toast({ title: 'خطا در ثبتنامها', description: resolveErrorMessage(registrationsQuery.error), variant: 'destructive' });
|
return { count: data.count, results: data.results.map((item) => ({ value: item.code, label: item.label })) };
|
||||||
}
|
}, []);
|
||||||
}, [registrationsQuery.error, toast]);
|
|
||||||
|
|
||||||
if (loading) {
|
const loadUniversities = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
const data = await api.getUniversitiesPaged(params);
|
||||||
}
|
return { count: data.count, results: data.results.map((item) => ({ value: item.code, label: item.label })) };
|
||||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
}, []);
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
if (loading) return <div className="flex min-h-screen items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||||
if (!Number.isFinite(eventId)) {
|
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" replace />;
|
||||||
return <div className="min-h-screen flex items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
if (!Number.isFinite(eventId)) return <div className="flex min-h-screen items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||||
}
|
|
||||||
|
|
||||||
const event = detailQuery.data;
|
const event = detailQuery.data;
|
||||||
const paged = registrationsQuery.data;
|
const paged = registrationsQuery.data;
|
||||||
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background" dir="rtl">
|
<div className="space-y-6" dir="rtl">
|
||||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-black">{event?.title ?? "جزئیات رویداد"}</h1>
|
||||||
<h1 className="text-2xl font-bold">{event?.title ?? 'جزئیات رویداد'}</h1>
|
{event ? <p className="mt-1 text-sm text-muted-foreground">شروع: {formatJalali(event.start_time)} · ثبتنامها: {toPersianDigits(event.registration_count)}</p> : null}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{event && (
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<main className="space-y-6">
|
||||||
<Card>
|
{event ? (
|
||||||
<CardHeader>
|
<>
|
||||||
<CardTitle>وضعیت</CardTitle>
|
<ProgressiveImage
|
||||||
<CardDescription>اطلاعات پایه رویداد</CardDescription>
|
src={getEventCardImageUrl(event)}
|
||||||
</CardHeader>
|
alt={event.title}
|
||||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
wrapperClassName="aspect-video overflow-hidden rounded-3xl bg-muted shadow-sm"
|
||||||
<div>ظرفیت: {event.capacity ?? 'نامحدود'}</div>
|
className="h-full w-full object-cover"
|
||||||
<div>ثبتنامها: {toPersianDigits(event.registration_count ?? 0)}</div>
|
/>
|
||||||
<div>قیمت: {formatToman(event.price)}</div>
|
<Card>
|
||||||
</CardContent>
|
<CardContent className="grid gap-3 p-5 text-sm md:grid-cols-2">
|
||||||
</Card>
|
<Info label="نوع" value={event.event_type} />
|
||||||
<Card className="md:col-span-2">
|
<Info label="وضعیت" value={event.status} />
|
||||||
<CardHeader>
|
<Info label="ظرفیت" value={event.capacity ?? "نامحدود"} />
|
||||||
<CardTitle>توضیحات</CardTitle>
|
<Info label="قیمت" value={Number(event.price || 0) === 0 ? "رایگان" : formatToman(event.price)} />
|
||||||
</CardHeader>
|
<Info label="شروع ثبتنام" value={event.registration_start_date ? formatJalali(event.registration_start_date) : "—"} />
|
||||||
<CardContent className="text-sm text-muted-foreground leading-6">
|
<Info label="پایان ثبتنام" value={event.registration_end_date ? formatJalali(event.registration_end_date) : "—"} />
|
||||||
{event.description || 'توضیحی ثبت نشده است.'}
|
<Info label="آدرس" value={event.address} />
|
||||||
</CardContent>
|
<Info label="لینک آنلاین" value={event.online_link} />
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<Markdown content={event.description || "توضیحی ثبت نشده است."} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
<Card>
|
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>ثبتنامها و پرداختها</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>لیست ثبتنامهای مرتبط با این رویداد</CardDescription>
|
<CardTitle>اطلاعات ثبتنام</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<Input placeholder="جستجو نام، موبایل یا ایمیل..." value={search} onChange={(event) => { setSearch(event.target.value); setRegPage(1); }} />
|
||||||
<div className="flex flex-wrap gap-2">
|
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); setRegPage(1); }}>
|
||||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value as typeof statusFilter); setRegPage(1); }}>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectTrigger className="w-full md:w-40">
|
|
||||||
<SelectValue placeholder="وضعیت">وضعیت: {statusFilter === 'all' ? 'همه' : statusFilter}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">همه</SelectItem>
|
{statusOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||||
{registrationStatusOptions.map((s) => (
|
|
||||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<AsyncSearchableCombobox value={university} onChange={(value) => { setUniversity(value); setRegPage(1); }} loadOptions={loadUniversities} placeholder="دانشگاه" />
|
||||||
<Input
|
<AsyncSearchableCombobox value={major} onChange={(value) => { setMajor(value); setRegPage(1); }} loadOptions={loadMajors} placeholder="رشته" />
|
||||||
className="md:w-64"
|
|
||||||
placeholder="جستجو نام/ایمیل/نامکاربری"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setRegPage(1); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{registrationsQuery.isLoading ? (
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">در حال بارگذاری ثبتنامها...</p>
|
{registrationsQuery.isLoading ? (
|
||||||
) : !paged || paged.results.length === 0 ? (
|
<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]">
|
) : paged.results.map((registration) => (
|
||||||
<div className="divide-y">
|
<button
|
||||||
{paged.results.map((registration) => (
|
key={registration.id}
|
||||||
<div key={registration.id} className="p-4">
|
type="button"
|
||||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
className="flex w-full items-center gap-3 rounded-2xl border bg-background p-3 text-right transition hover:bg-muted/40"
|
||||||
<div>
|
onClick={() => setSelectedRegistration(registration)}
|
||||||
<div className="font-semibold">{registration.user.first_name} {registration.user.last_name}</div>
|
>
|
||||||
<div className="text-xs text-muted-foreground">{registration.user.email}</div>
|
<Avatar className="h-10 w-10">
|
||||||
</div>
|
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||||
<Badge variant={registration.status === 'confirmed' ? 'default' : 'outline'}>
|
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||||
{registration.status_label}
|
</Avatar>
|
||||||
</Badge>
|
<span className="min-w-0 flex-1">
|
||||||
</div>
|
<span className="block truncate text-sm font-semibold">{registration.user.first_name} {registration.user.last_name}</span>
|
||||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-2 lg:grid-cols-3">
|
<span className="block truncate text-xs text-muted-foreground">{registration.user.mobile || registration.user.email}</span>
|
||||||
<div>نامکاربری: {registration.user.username}</div>
|
</span>
|
||||||
<div>کد بلیت: {registration.ticket_id}</div>
|
{statusIcon(registration.status)}
|
||||||
<div>تاریخ ثبتنام: {formatJalali(registration.registered_at)}</div>
|
</button>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
</Card>
|
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<RegistrationDialog registration={selectedRegistration} onOpenChange={(open) => { if (!open) setSelectedRegistration(null); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user