feat(events): redesign admin event editing
This commit is contained in:
5
src/app/admin/events/create/page.tsx
Normal file
5
src/app/admin/events/create/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminEventForm from "@/views/AdminEventForm";
|
||||||
|
|
||||||
|
export default function AdminEventCreateRoute() {
|
||||||
|
return <AdminEventForm mode="create" />;
|
||||||
|
}
|
||||||
@@ -1,284 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import AdminEventForm from "@/views/AdminEventForm";
|
||||||
import { useNavigate, useParams, Navigate } from '@/lib/router';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import type { EventAdminDetailSchema, EventUpdateSchema } from '@/lib/types';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import { resolveErrorMessage } from '@/lib/utils';
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ value: 'draft', label: 'پیشنویس' },
|
|
||||||
{ value: 'published', label: 'منتشر شده' },
|
|
||||||
{ value: 'cancelled', label: 'لغو شده' },
|
|
||||||
{ value: 'completed', label: 'برگزار شده' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const typeOptions = [
|
|
||||||
{ value: 'online', label: 'آنلاین' },
|
|
||||||
{ value: 'on_site', label: 'حضوری' },
|
|
||||||
{ value: 'hybrid', label: 'ترکیبی' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const toInputDateTime = (iso?: string | null) => {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return `${d.getFullYear().toString().padStart(4, '0')}-${(d.getMonth() + 1)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}T${d
|
|
||||||
.getHours()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminEventEdit() {
|
export default function AdminEventEdit() {
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
return <AdminEventForm mode="edit" />;
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const eventId = Number(id);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
|
||||||
queryKey: ['admin', 'edit-event', eventId],
|
|
||||||
queryFn: () => api.getEventAdminDetail(eventId),
|
|
||||||
enabled: Boolean(eventId) && isAuthenticated,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [formData, setFormData] = React.useState({
|
|
||||||
title: '',
|
|
||||||
status: 'draft' as NonNullable<EventUpdateSchema['status']>,
|
|
||||||
event_type: 'online' as NonNullable<EventUpdateSchema['event_type']>,
|
|
||||||
price: '',
|
|
||||||
capacity: '',
|
|
||||||
start_time: '',
|
|
||||||
end_time: '',
|
|
||||||
registration_start_date: '',
|
|
||||||
registration_end_date: '',
|
|
||||||
location: '',
|
|
||||||
address: '',
|
|
||||||
online_link: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (detailQuery.data) {
|
|
||||||
const d: EventAdminDetailSchema = detailQuery.data;
|
|
||||||
setFormData({
|
|
||||||
title: d.title || '',
|
|
||||||
status: d.status || 'draft',
|
|
||||||
event_type: d.event_type || 'online',
|
|
||||||
price: d.price ? Math.floor(Number(d.price) / 10).toString() : '',
|
|
||||||
capacity: d.capacity != null ? String(d.capacity) : '',
|
|
||||||
start_time: toInputDateTime(d.start_time),
|
|
||||||
end_time: toInputDateTime(d.end_time),
|
|
||||||
registration_start_date: toInputDateTime(d.registration_start_date),
|
|
||||||
registration_end_date: toInputDateTime(d.registration_end_date),
|
|
||||||
location: d.location || '',
|
|
||||||
address: d.address || '',
|
|
||||||
online_link: d.online_link || '',
|
|
||||||
description: d.description || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [detailQuery.data]);
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: (payload: EventUpdateSchema) => api.updateEvent(eventId, payload),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'رویداد بهروزرسانی شد', variant: 'success' });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'edit-event', eventId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
|
||||||
navigate(`/admin/events/${eventId}`);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: 'خطا در ذخیرهسازی رویداد',
|
|
||||||
description: resolveErrorMessage(error),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (detailQuery.error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: 'خطا در دریافت رویداد',
|
|
||||||
description: resolveErrorMessage(detailQuery.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [detailQuery.error, toast]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
||||||
<p className="text-muted-foreground">در حال بررسی دسترسی...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
|
||||||
return <Navigate to="/" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background" dir="rtl">
|
|
||||||
<div className="container mx-auto px-4 py-10">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>ویرایش رویداد</CardTitle>
|
|
||||||
<CardDescription>فرم کامل برای ویرایش جزئیات رویداد</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{detailQuery.isLoading ? (
|
|
||||||
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
|
||||||
) : detailQuery.data ? (
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
updateMutation.mutate({
|
|
||||||
title: formData.title,
|
|
||||||
status: formData.status,
|
|
||||||
event_type: formData.event_type,
|
|
||||||
price: formData.price ? Number(formData.price) * 10 : 0,
|
|
||||||
capacity: formData.capacity ? Number(formData.capacity) : null,
|
|
||||||
start_time: formData.start_time || undefined,
|
|
||||||
end_time: formData.end_time || null,
|
|
||||||
registration_start_date: formData.registration_start_date || null,
|
|
||||||
registration_end_date: formData.registration_end_date || null,
|
|
||||||
location: formData.location || null,
|
|
||||||
address: formData.address || null,
|
|
||||||
online_link: formData.online_link || null,
|
|
||||||
description: formData.description || '',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<Input
|
|
||||||
placeholder="عنوان رویداد"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, title: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData((p) => ({
|
|
||||||
...p,
|
|
||||||
status: value as NonNullable<EventUpdateSchema['status']>,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="وضعیت" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{statusOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={formData.event_type}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData((p) => ({
|
|
||||||
...p,
|
|
||||||
event_type: value as NonNullable<EventUpdateSchema['event_type']>,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="نوع رویداد" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{typeOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
placeholder="قیمت (تومان)"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, price: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="ظرفیت"
|
|
||||||
value={formData.capacity}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, capacity: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="تاریخ شروع"
|
|
||||||
value={formData.start_time}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, start_time: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="تاریخ پایان"
|
|
||||||
value={formData.end_time}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, end_time: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="شروع ثبتنام"
|
|
||||||
value={formData.registration_start_date}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, registration_start_date: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="پایان ثبتنام"
|
|
||||||
value={formData.registration_end_date}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, registration_end_date: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="محل برگزاری"
|
|
||||||
value={formData.location}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, location: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="آدرس دقیق"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, address: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="لینک آنلاین"
|
|
||||||
value={formData.online_link}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, online_link: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
placeholder="توضیحات رویداد"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
|
||||||
rows={8}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2 justify-end">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={updateMutation.isPending}>
|
|
||||||
ذخیره
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-destructive">امکان دریافت رویداد وجود ندارد.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
378
src/views/AdminEventForm.tsx
Normal file
378
src/views/AdminEventForm.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ImagePlus, Trash2, Upload } from "lucide-react";
|
||||||
|
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||||||
|
import Markdown from "@/components/Markdown";
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
|
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
|
||||||
|
import type { EventCreateSchema, EventDetailSchema, EventGalleryItem } from "@/lib/types";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { getEventCardImageUrl, resolveErrorMessage } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type Mode = "create" | "edit";
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
title: "",
|
||||||
|
slug: "",
|
||||||
|
status: "draft" as EventCreateSchema["status"],
|
||||||
|
event_type: "on_site" as EventCreateSchema["event_type"],
|
||||||
|
price: "",
|
||||||
|
capacity: "",
|
||||||
|
start_time: null as string | null,
|
||||||
|
end_time: null as string | null,
|
||||||
|
registration_start_date: null as string | null,
|
||||||
|
registration_end_date: null as string | null,
|
||||||
|
location: "",
|
||||||
|
address: "",
|
||||||
|
online_link: "",
|
||||||
|
description: "",
|
||||||
|
registration_success_markdown: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toForm(event: EventDetailSchema) {
|
||||||
|
return {
|
||||||
|
title: event.title || "",
|
||||||
|
slug: event.slug || "",
|
||||||
|
status: event.status || "draft",
|
||||||
|
event_type: event.event_type || "on_site",
|
||||||
|
price: event.price ? String(Math.floor(Number(event.price) / 10)) : "",
|
||||||
|
capacity: event.capacity != null ? String(event.capacity) : "",
|
||||||
|
start_time: event.start_time || null,
|
||||||
|
end_time: event.end_time || null,
|
||||||
|
registration_start_date: event.registration_start_date || null,
|
||||||
|
registration_end_date: event.registration_end_date || null,
|
||||||
|
location: event.location || "",
|
||||||
|
address: event.address || "",
|
||||||
|
online_link: event.online_link || "",
|
||||||
|
description: event.description || "",
|
||||||
|
registration_success_markdown: event.registration_success_markdown || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminEventForm({ mode }: { mode: Mode }) {
|
||||||
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
|
const { id } = useParams<{ id?: string }>();
|
||||||
|
const eventId = Number(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = React.useState(emptyForm);
|
||||||
|
const [previewMode, setPreviewMode] = React.useState<"editor" | "preview">("editor");
|
||||||
|
const [galleryPreview, setGalleryPreview] = React.useState<EventGalleryItem | null>(null);
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: ["admin", "edit-event", eventId],
|
||||||
|
queryFn: () => api.getEventAdminDetail(eventId),
|
||||||
|
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryQuery = useQuery({
|
||||||
|
queryKey: ["admin", "event", eventId, "gallery"],
|
||||||
|
queryFn: () => api.listEventGallery(eventId),
|
||||||
|
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (detailQuery.data) setForm(toForm(detailQuery.data));
|
||||||
|
}, [detailQuery.data]);
|
||||||
|
|
||||||
|
const makePayload = (): EventCreateSchema => ({
|
||||||
|
title: form.title,
|
||||||
|
slug: form.slug || null,
|
||||||
|
status: form.status,
|
||||||
|
event_type: form.event_type,
|
||||||
|
price: form.price ? Number(form.price) * 10 : 0,
|
||||||
|
capacity: form.capacity ? Number(form.capacity) : null,
|
||||||
|
start_time: form.start_time || new Date().toISOString(),
|
||||||
|
end_time: form.end_time || form.start_time || new Date().toISOString(),
|
||||||
|
registration_start_date: form.registration_start_date,
|
||||||
|
registration_end_date: form.registration_end_date,
|
||||||
|
location: form.location || null,
|
||||||
|
address: form.address || null,
|
||||||
|
online_link: form.online_link || null,
|
||||||
|
description: form.description || "",
|
||||||
|
registration_success_markdown: form.registration_success_markdown || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = makePayload();
|
||||||
|
return mode === "edit" ? api.updateEvent(eventId, payload) : api.createEvent(payload);
|
||||||
|
},
|
||||||
|
onSuccess: (event) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||||||
|
toast({ title: "رویداد ذخیره شد", variant: "success" });
|
||||||
|
navigate(`/admin/events/${event.id}/edit`);
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در ذخیره رویداد", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const posterMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadEventFeaturedImage(eventId, file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "edit-event", eventId] });
|
||||||
|
toast({ title: "پوستر ذخیره شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در آپلود پوستر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryUploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadEventGalleryImage(eventId, file, { title: file.name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||||||
|
toast({ title: "تصویر گالری افزوده شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در آپلود گالری", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryDeleteMutation = useMutation({
|
||||||
|
mutationFn: (imageId: number) => api.deleteEventGalleryImage(eventId, imageId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||||||
|
toast({ title: "تصویر حذف شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در حذف تصویر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) return <div className="py-10 text-center text-muted-foreground">در حال بررسی دسترسی...</div>;
|
||||||
|
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" />;
|
||||||
|
if (mode === "edit" && !Number.isFinite(eventId)) return <div className="py-10 text-center">شناسه رویداد معتبر نیست.</div>;
|
||||||
|
|
||||||
|
const event = detailQuery.data;
|
||||||
|
const gallery = galleryQuery.data ?? event?.gallery_images ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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-black">{mode === "edit" ? "ویرایش رویداد" : "افزودن رویداد"}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">فرم کامل رویداد با توضیحات Markdown، زمانبندی، پوستر و گالری</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||||||
|
<Button disabled={saveMutation.isPending || !form.title.trim()} onClick={() => saveMutation.mutate()}>ذخیره</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>اطلاعات اصلی</CardTitle>
|
||||||
|
<CardDescription>عنوان، وضعیت، نوع رویداد، ظرفیت و هزینه</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>عنوان</Label>
|
||||||
|
<Input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>اسلاگ</Label>
|
||||||
|
<Input dir="ltr" value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>وضعیت</Label>
|
||||||
|
<Select value={form.status} onValueChange={(value) => setForm((current) => ({ ...current, status: value as typeof form.status }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||||
|
<SelectItem value="published">منتشر شده</SelectItem>
|
||||||
|
<SelectItem value="cancelled">لغو شده</SelectItem>
|
||||||
|
<SelectItem value="completed">برگزار شده</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>نوع</Label>
|
||||||
|
<Select value={form.event_type} onValueChange={(value) => setForm((current) => ({ ...current, event_type: value as typeof form.event_type }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="on_site">حضوری</SelectItem>
|
||||||
|
<SelectItem value="online">آنلاین</SelectItem>
|
||||||
|
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>قیمت (تومان)</Label>
|
||||||
|
<Input type="number" value={form.price} onChange={(event) => setForm((current) => ({ ...current, price: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>ظرفیت</Label>
|
||||||
|
<Input type="number" value={form.capacity} onChange={(event) => setForm((current) => ({ ...current, capacity: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>محل / مختصات</Label>
|
||||||
|
<Input value={form.location} onChange={(event) => setForm((current) => ({ ...current, location: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>آدرس</Label>
|
||||||
|
<Input value={form.address} onChange={(event) => setForm((current) => ({ ...current, address: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>لینک آنلاین</Label>
|
||||||
|
<Input dir="ltr" value={form.online_link} onChange={(event) => setForm((current) => ({ ...current, online_link: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>زمانبندی</CardTitle>
|
||||||
|
<CardDescription>تاریخ شمسی و زمان جداگانه نمایش داده میشود.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<AdminDateTimeField label="شروع رویداد" value={form.start_time} required onChange={(value) => setForm((current) => ({ ...current, start_time: value }))} />
|
||||||
|
<AdminDateTimeField label="پایان رویداد" value={form.end_time} required onChange={(value) => setForm((current) => ({ ...current, end_time: value }))} />
|
||||||
|
<AdminDateTimeField label="شروع ثبتنام" value={form.registration_start_date} onChange={(value) => setForm((current) => ({ ...current, registration_start_date: value }))} />
|
||||||
|
<AdminDateTimeField label="پایان ثبتنام" value={form.registration_end_date} onChange={(value) => setForm((current) => ({ ...current, registration_end_date: value }))} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>پوستر</CardTitle>
|
||||||
|
<CardDescription>تصویر شاخص رویداد</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{event ? (
|
||||||
|
<ProgressiveImage
|
||||||
|
src={getEventCardImageUrl(event)}
|
||||||
|
alt={event.title}
|
||||||
|
wrapperClassName="aspect-video max-w-xl overflow-hidden rounded-2xl bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video max-w-xl items-center justify-center rounded-2xl border bg-muted/30 text-muted-foreground">
|
||||||
|
<ImagePlus className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
آپلود پوستر
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) posterMutation.mutate(file);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود پوستر فعال میشود.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>متن رویداد</CardTitle>
|
||||||
|
<CardDescription>ویرایشگر Markdown و پیشنمایش</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-2 md:hidden">
|
||||||
|
<Button variant={previewMode === "editor" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("editor")}>ویرایش</Button>
|
||||||
|
<Button variant={previewMode === "preview" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("preview")}>پیشنمایش</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className={previewMode === "preview" ? "hidden lg:block" : ""}>
|
||||||
|
<MarkdownEditor value={form.description} onChange={(value) => setForm((current) => ({ ...current, description: value }))} minHeight="520px" onSave={() => saveMutation.mutate()} />
|
||||||
|
</div>
|
||||||
|
<div className={previewMode === "editor" ? "hidden lg:block" : ""}>
|
||||||
|
<div className="min-h-[520px] rounded-2xl border bg-background p-5">
|
||||||
|
<Markdown content={form.description || "هنوز متنی وارد نشده است."} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>پیام موفقیت ثبتنام</CardTitle>
|
||||||
|
<CardDescription>متنی که بعد از ثبتنام موفق به کاربر نمایش داده میشود.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={form.registration_success_markdown}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, registration_success_markdown: event.target.value }))}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>گالری رویداد</CardTitle>
|
||||||
|
<CardDescription>تصاویر مرتبط با رویداد</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
آپلود تصویر
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
files.forEach((file) => galleryUploadMutation.mutate(file));
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود گالری فعال میشود.</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{gallery.map((item) => (
|
||||||
|
<div key={item.id} className="overflow-hidden rounded-2xl border bg-card">
|
||||||
|
<button type="button" className="block w-full" onClick={() => setGalleryPreview(item)}>
|
||||||
|
<ProgressiveImage
|
||||||
|
src={item.absolute_image_preview_url || item.absolute_image_url}
|
||||||
|
alt={item.title}
|
||||||
|
wrapperClassName="aspect-video w-full bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between gap-2 p-3 text-sm">
|
||||||
|
<span className="truncate">{item.title}</span>
|
||||||
|
<Button size="icon" variant="ghost" className="text-destructive" onClick={() => galleryDeleteMutation.mutate(item.id)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{galleryPreview ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setGalleryPreview(null)}>
|
||||||
|
<img
|
||||||
|
src={galleryPreview.absolute_image_preview_url || galleryPreview.absolute_image_url || ""}
|
||||||
|
alt={galleryPreview.title}
|
||||||
|
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
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 { Edit3, Eye, Plus, 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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -16,68 +16,65 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
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';
|
|
||||||
|
|
||||||
const EVENTS_PAGE_SIZE = 30;
|
const EVENTS_PAGE_SIZE = 30;
|
||||||
|
|
||||||
const eventStatusOptions = [
|
const eventStatusOptions = [
|
||||||
{ value: 'all', label: 'همه وضعیتها' },
|
{ value: "all", label: "همه وضعیتها" },
|
||||||
{ value: 'draft', label: 'پیشنویس' },
|
{ value: "draft", label: "پیشنویس" },
|
||||||
{ value: 'published', label: 'منتشر شده' },
|
{ value: "published", label: "منتشر شده" },
|
||||||
{ value: 'cancelled', label: 'لغو شده' },
|
{ value: "cancelled", label: "لغو شده" },
|
||||||
{ value: 'completed', label: 'برگزار شده' },
|
{ value: "completed", label: "برگزار شده" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
EventListItemSchema['status'],
|
EventListItemSchema["status"],
|
||||||
{ label: string; variant: 'outline' | 'default' | 'destructive' | 'secondary' }
|
{ label: string; variant: "outline" | "default" | "destructive" | "secondary" }
|
||||||
> = {
|
> = {
|
||||||
draft: { label: 'پیشنویس', variant: 'outline' },
|
draft: { label: "پیشنویس", variant: "outline" },
|
||||||
published: { label: 'منتشر شده', variant: 'default' },
|
published: { label: "منتشر شده", variant: "default" },
|
||||||
cancelled: { label: 'لغو شده', variant: 'destructive' },
|
cancelled: { label: "لغو شده", variant: "destructive" },
|
||||||
completed: { label: 'برگزار شده', variant: 'secondary' },
|
completed: { label: "برگزار شده", variant: "secondary" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventSortOptions = [
|
const eventSortOptions = [
|
||||||
{ value: 'newest', label: 'جدیدترین شروع' },
|
{ value: "newest", label: "جدیدترین شروع" },
|
||||||
{ value: 'oldest', label: 'قدیمیترین شروع' },
|
{ value: "oldest", label: "قدیمیترین شروع" },
|
||||||
{ value: 'priceAsc', label: 'قیمت صعودی' },
|
{ value: "priceAsc", label: "قیمت صعودی" },
|
||||||
{ value: 'priceDesc', label: 'قیمت نزولی' },
|
{ value: "priceDesc", label: "قیمت نزولی" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const AdminEventsPage: React.FC = () => {
|
function priceLabel(price?: number | null) {
|
||||||
|
return Number(price || 0) === 0 ? "رایگان" : formatToman(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminEventsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filters, setFilters] = React.useState({
|
const [filters, setFilters] = React.useState({
|
||||||
search: '',
|
search: "",
|
||||||
status: 'all' as 'all' | EventListItemSchema['status'],
|
status: "all" as "all" | EventListItemSchema["status"],
|
||||||
type: 'all' as 'all' | EventListItemSchema['event_type'],
|
type: "all" as "all" | EventListItemSchema["event_type"],
|
||||||
sort: 'newest' as (typeof eventSortOptions)[number]['value'],
|
sort: "newest" as (typeof eventSortOptions)[number]["value"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventsQuery = useQuery({
|
const eventsQuery = useQuery({
|
||||||
queryKey: ['admin', 'events', filters],
|
queryKey: ["admin", "events", filters],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.getEvents({
|
api.getEvents({
|
||||||
statuses:
|
statuses: filters.status === "all" ? undefined : [filters.status],
|
||||||
filters.status === 'all'
|
event_type: filters.type === "all" ? undefined : filters.type,
|
||||||
? undefined
|
|
||||||
: [filters.status as EventListItemSchema['status']],
|
|
||||||
event_type:
|
|
||||||
filters.type === 'all'
|
|
||||||
? undefined
|
|
||||||
: (filters.type as EventListItemSchema['event_type']),
|
|
||||||
search: filters.search || undefined,
|
search: filters.search || undefined,
|
||||||
limit: EVENTS_PAGE_SIZE,
|
limit: EVENTS_PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
@@ -86,28 +83,24 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (eventId: number) => api.deleteEvent(eventId),
|
mutationFn: (eventId: number) => api.deleteEvent(eventId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||||||
toast({ title: 'رویداد حذف شد', variant: 'success' });
|
toast({ title: "رویداد حذف شد", variant: "success" });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" });
|
||||||
title: 'خطا',
|
|
||||||
description: resolveErrorMessage(error),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedEvents = React.useMemo(() => {
|
const sortedEvents = React.useMemo(() => {
|
||||||
const list = (eventsQuery.data ?? []).slice();
|
const list = (eventsQuery.data ?? []).slice();
|
||||||
switch (filters.sort) {
|
switch (filters.sort) {
|
||||||
case 'newest':
|
case "newest":
|
||||||
return list.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime());
|
return list.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime());
|
||||||
case 'oldest':
|
case "oldest":
|
||||||
return list.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
return list.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
case 'priceAsc':
|
case "priceAsc":
|
||||||
return list.sort((a, b) => Number(a.price) - Number(b.price));
|
return list.sort((a, b) => Number(a.price) - Number(b.price));
|
||||||
case 'priceDesc':
|
case "priceDesc":
|
||||||
return list.sort((a, b) => Number(b.price) - Number(a.price));
|
return list.sort((a, b) => Number(b.price) - Number(a.price));
|
||||||
default:
|
default:
|
||||||
return list;
|
return list;
|
||||||
@@ -118,13 +111,7 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button size="icon" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)} aria-label="جزئیات">
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/admin/events/${event.id}`)}
|
|
||||||
aria-label="جزئیات"
|
|
||||||
title="جزئیات"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -132,7 +119,7 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button size="icon" variant="outline" asChild aria-label="ویرایش" title="ویرایش">
|
<Button size="icon" variant="outline" asChild aria-label="ویرایش">
|
||||||
<Link to={`/admin/events/${event.id}/edit`}>
|
<Link to={`/admin/events/${event.id}/edit`}>
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -144,13 +131,7 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button size="icon" variant="destructive" disabled={deleteMutation.isPending} aria-label="حذف">
|
||||||
size="icon"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
aria-label="حذف"
|
|
||||||
title="حذف"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
@@ -180,65 +161,41 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="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-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-xl font-semibold">رویدادها</h2>
|
<h2 className="text-xl font-semibold">رویدادها</h2>
|
||||||
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/events/create">
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
افزودن رویداد
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>فیلترها</CardTitle>
|
<CardTitle>فیلترها</CardTitle>
|
||||||
<CardDescription>پیدا کردن سریع رویدادها</CardDescription>
|
<CardDescription>پیدا کردن سریع رویدادها</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="عنوان رویداد..."
|
placeholder="عنوان رویداد..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
|
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select value={filters.status} onValueChange={(value) => setFilters((prev) => ({ ...prev, status: value as typeof filters.status }))}>
|
||||||
value={filters.status}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: value as 'all' | EventListItemSchema['status'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue>
|
|
||||||
{eventStatusOptions.find((option) => option.value === filters.status)?.label ||
|
|
||||||
'وضعیت'}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{eventStatusOptions.map((option) => (
|
{eventStatusOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select value={filters.type} onValueChange={(value) => setFilters((prev) => ({ ...prev, type: value as typeof filters.type }))}>
|
||||||
value={filters.type}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
type: value as 'all' | EventListItemSchema['event_type'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue>
|
|
||||||
{{
|
|
||||||
all: 'همه انواع',
|
|
||||||
online: 'آنلاین',
|
|
||||||
on_site: 'حضوری',
|
|
||||||
hybrid: 'ترکیبی',
|
|
||||||
}[filters.type]}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">همه انواع</SelectItem>
|
<SelectItem value="all">همه انواع</SelectItem>
|
||||||
<SelectItem value="online">آنلاین</SelectItem>
|
<SelectItem value="online">آنلاین</SelectItem>
|
||||||
@@ -246,26 +203,11 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select value={filters.sort} onValueChange={(value) => setFilters((prev) => ({ ...prev, sort: value as typeof filters.sort }))}>
|
||||||
value={filters.sort}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
sort: value as (typeof eventSortOptions)[number]['value'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue>
|
|
||||||
{eventSortOptions.find((option) => option.value === filters.sort)?.label ||
|
|
||||||
'مرتبسازی'}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{eventSortOptions.map((option) => (
|
{eventSortOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -284,77 +226,75 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
) : sortedEvents.length === 0 ? (
|
) : sortedEvents.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">رویدادی یافت نشد.</p>
|
<p className="text-sm text-muted-foreground">رویدادی یافت نشد.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<div className="hidden md:block">
|
<div className="hidden overflow-x-auto rounded-md border md:block">
|
||||||
<ScrollArea className="rounded-md border">
|
<table dir="rtl" className="w-full min-w-[860px] text-sm">
|
||||||
<table dir="rtl" className="w-full min-w-[780px] text-sm">
|
|
||||||
<thead className="text-xs uppercase text-muted-foreground">
|
<thead className="text-xs uppercase text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-right">پوستر</th>
|
<th className="w-36 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>
|
<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>
|
||||||
{sortedEvents.map((event) => (
|
{sortedEvents.map((event) => (
|
||||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2">
|
||||||
<ProgressiveImage
|
<ProgressiveImage
|
||||||
src={getEventCardImageUrl(event)}
|
src={getEventCardImageUrl(event)}
|
||||||
alt={event.title}
|
alt={event.title}
|
||||||
wrapperClassName="h-12 w-12 rounded"
|
wrapperClassName="aspect-video w-28 overflow-hidden rounded-lg bg-muted"
|
||||||
className="h-12 w-12 rounded object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right cursor-pointer" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
<td className="cursor-pointer px-3 py-2 text-right font-medium" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||||
{event.title}
|
{event.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-right">
|
||||||
<Badge variant={statusConfig[event.status].variant}>
|
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||||
{statusConfig[event.status].label}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<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">{priceLabel(event.price)}</td>
|
||||||
<td className="px-3 py-2 text-left">
|
<td className="px-3 py-2 text-left">{renderEventActions(event)}</td>
|
||||||
{renderEventActions(event)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:hidden">
|
<div className="grid gap-3 md:hidden">
|
||||||
{sortedEvents.map((event) => (
|
{sortedEvents.map((event) => (
|
||||||
<div key={event.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
<div key={event.id} className="space-y-3 rounded-lg border bg-card p-3">
|
||||||
|
<ProgressiveImage
|
||||||
|
src={getEventCardImageUrl(event)}
|
||||||
|
alt={event.title}
|
||||||
|
wrapperClassName="aspect-video w-full overflow-hidden rounded-lg bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="font-semibold text-right">{event.title}</div>
|
<button className="text-right font-semibold" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||||
|
{event.title}
|
||||||
|
</button>
|
||||||
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
<div className="space-y-1 text-right text-xs text-muted-foreground">
|
||||||
<div>تاریخ شروع: {formatJalali(event.start_time)}</div>
|
<div>تاریخ شروع: {formatJalali(event.start_time)}</div>
|
||||||
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
||||||
<div>قیمت: {formatToman(event.price)}</div>
|
<div>قیمت: {priceLabel(event.price)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
|
||||||
{renderEventActions(event)}
|
{renderEventActions(event)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AdminEventsPage;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user