Compare commits
3 Commits
053b742f89
...
25bd46ea2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 25bd46ea2a | |||
| 9e5be244f0 | |||
| 10992303de |
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
|
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -16,6 +16,11 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const normalizeSearchDigits = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
|
||||||
|
|
||||||
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
|
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
|
||||||
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||||
}
|
}
|
||||||
@@ -34,6 +39,14 @@ export default function AdminAuthorizations() {
|
|||||||
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
|
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setSearch(normalizeSearchDigits(searchDraft).trim());
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [searchDraft]);
|
||||||
|
|
||||||
const authQuery = useQuery({
|
const authQuery = useQuery({
|
||||||
queryKey: ["admin", "authorizations", selectedUserId],
|
queryKey: ["admin", "authorizations", selectedUserId],
|
||||||
queryFn: () => api.getUserAuthorization(selectedUserId as number),
|
queryFn: () => api.getUserAuthorization(selectedUserId as number),
|
||||||
@@ -104,18 +117,13 @@ export default function AdminAuthorizations() {
|
|||||||
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
|
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4" dir="ltr">
|
<CardContent className="space-y-4" dir="ltr">
|
||||||
<div className="flex gap-2">
|
<div className="relative">
|
||||||
<Button type="button" onClick={() => setSearch(searchDraft.trim())} size="icon" aria-label="جستجو">
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Search className="h-5 w-5 bold" />
|
|
||||||
</Button>
|
|
||||||
<Input
|
<Input
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={(event) => setSearchDraft(event.target.value)}
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") setSearch(searchDraft.trim());
|
|
||||||
}}
|
|
||||||
placeholder="جستجو..."
|
placeholder="جستجو..."
|
||||||
className="text-right"
|
className="pl-10 text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{usersQuery.isLoading ? (
|
{usersQuery.isLoading ? (
|
||||||
@@ -140,7 +148,10 @@ export default function AdminAuthorizations() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-medium">{fullName(item)}</span>
|
<span className="font-medium">{fullName(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{item.mobile || item.email || item.username}</p>
|
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p dir="ltr" className="text-right">{item.mobile || "بدون شماره موبایل"}</p>
|
||||||
|
<p>{item.email || item.username}</p>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,15 +2,28 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Edit3, Eye, Trash2 } from 'lucide-react';
|
||||||
import { Link, useNavigate } from '@/lib/router';
|
import { Link, useNavigate } from '@/lib/router';
|
||||||
import type { EventListItemSchema } from '@/lib/types';
|
import type { EventListItemSchema } from '@/lib/types';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import ProgressiveImage from '@/components/ProgressiveImage';
|
import ProgressiveImage from '@/components/ProgressiveImage';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
||||||
@@ -101,6 +114,70 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [eventsQuery.data, filters.sort]);
|
}, [eventsQuery.data, filters.sort]);
|
||||||
|
|
||||||
|
const renderEventActions = (event: EventListItemSchema) => (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/admin/events/${event.id}`)}
|
||||||
|
aria-label="جزئیات"
|
||||||
|
title="جزئیات"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>جزئیات</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon" variant="outline" asChild aria-label="ویرایش" title="ویرایش">
|
||||||
|
<Link to={`/admin/events/${event.id}/edit`}>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>ویرایش</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<AlertDialog>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
aria-label="حذف"
|
||||||
|
title="حذف"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>حذف</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<AlertDialogContent dir="rtl">
|
||||||
|
<AlertDialogHeader className="text-right">
|
||||||
|
<AlertDialogTitle>حذف رویداد</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
آیا از حذف رویداد «{event.title}» مطمئن هستید؟ این عملیات رویداد را از لیستهای عادی حذف میکند.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||||
|
<AlertDialogCancel>انصراف</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => deleteMutation.mutate(event.id)}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -219,7 +296,7 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
||||||
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
||||||
<th className="px-3 py-2 text-right">قیمت (تومان)</th>
|
<th className="px-3 py-2 text-right">قیمت (تومان)</th>
|
||||||
<th className="px-3 py-2 text-right">عملیات</th>
|
<th className="px-3 py-2 text-right"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -244,20 +321,8 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
||||||
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
||||||
<td className="px-3 py-2 text-right">{formatToman(event.price)}</td>
|
<td className="px-3 py-2 text-right">{formatToman(event.price)}</td>
|
||||||
<td className="px-3 py-2 text-left flex items-center gap-1">
|
<td className="px-3 py-2 text-left">
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
{renderEventActions(event)}
|
||||||
جزئیات
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => deleteMutation.mutate(event.id)}
|
|
||||||
>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -278,16 +343,8 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
||||||
<div>قیمت: {formatToman(event.price)}</div>
|
<div>قیمت: {formatToman(event.price)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
{renderEventActions(event)}
|
||||||
جزئیات
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(event.id)}>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
formatJalali,
|
formatJalali,
|
||||||
formatNumberPersian,
|
formatNumberPersian,
|
||||||
getBlogCardImageUrl,
|
getBlogCardImageUrl,
|
||||||
|
getEventCardImageUrl,
|
||||||
resolveErrorMessage,
|
resolveErrorMessage,
|
||||||
toPersianDigits,
|
toPersianDigits,
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
@@ -405,19 +406,30 @@ export default function Profile() {
|
|||||||
const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => {
|
const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => {
|
||||||
const eventData = registration.event as Types.EventListItemSchema & { start_date?: string };
|
const eventData = registration.event as Types.EventListItemSchema & { start_date?: string };
|
||||||
const rawDate = eventData.start_date ?? eventData.start_time;
|
const rawDate = eventData.start_date ?? eventData.start_time;
|
||||||
|
const eventImage = getEventCardImageUrl(eventData);
|
||||||
|
const eventImageUrl = eventImage === "/placeholder.svg" ? eventImage : toAbsoluteUrl(eventImage, apiBaseUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={registration.id} className="rounded-2xl border border-border/70 bg-background/80 p-4">
|
<div key={registration.id} className="rounded-2xl border border-border/70 bg-background/80 p-4 text-right">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row-reverse sm:items-center sm:justify-between">
|
<div className="flex flex-row gap-4">
|
||||||
<div className="text-right">
|
<Link to={`/events/${registration.event.slug}`} className="block w-24 shrink-0 overflow-hidden rounded-2xl sm:w-28">
|
||||||
<p className="font-medium">{registration.event.title}</p>
|
<img
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
|
src={eventImageUrl}
|
||||||
|
alt={registration.event.title}
|
||||||
|
className="aspect-video h-full w-full rounded-2xl object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={`/events/${registration.event.slug}`} className="font-medium text-primary hover:underline">
|
||||||
|
{registration.event.title}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{registration.event.description || "بدون توضیح"}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||||
<span>{statusLabels[registration.status] ?? registration.status}</span>
|
<span>{statusLabels[registration.status] ?? registration.status}</span>
|
||||||
{rawDate ? <span>• {formatJalali(rawDate)}</span> : null}
|
{rawDate ? <span>• {formatJalali(rawDate)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={`/events/${registration.event.slug}`}>مشاهده رویداد</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -430,7 +442,7 @@ export default function Profile() {
|
|||||||
<BlogThumbnail
|
<BlogThumbnail
|
||||||
post={post}
|
post={post}
|
||||||
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
||||||
className="aspect-square rounded-2xl"
|
className="aspect-video rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user