Files
guilan-ace-frontend/src/views/AdminEventForm.tsx
Amirhossein Khalili fc94ceb9f5
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
feat(ui): confirm destructive admin actions
2026-06-14 09:10:32 +03:30

388 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 ConfirmAction from "@/components/ConfirmAction";
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>
<ConfirmAction
title="حذف تصویر گالری"
description={`آیا از حذف «${item.title}» از گالری رویداد مطمئن هستید؟`}
onConfirm={() => galleryDeleteMutation.mutate(item.id)}
disabled={galleryDeleteMutation.isPending}
trigger={
<Button size="icon" variant="ghost" className="text-destructive" disabled={galleryDeleteMutation.isPending}>
<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>
);
}