Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
'use client'
|
||
|
||
import type { AdminEventDetailData } from './admin-event-detail/types'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||
|
||
import type { EventCategory, EventRevision } from '@/services/events'
|
||
import type { City, Province } from '@/services/geography'
|
||
import type { EventFaq, EventMedia } from '@/services/eventDetail'
|
||
import type { EventManagementInsights } from '@/services/eventManagement'
|
||
import { Tab } from '@/components/heroui/Tabs'
|
||
import { addToast } from '@/lib/toast'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
||
import AdminState from '@/components/feedback/AdminState'
|
||
import AppTabs from '@/components/ui/AppTabs'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import { isEventEnded, isLiveEventStatus } from '@/features/events/eventEditPolicy'
|
||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
||
import {
|
||
approveEventAsAdmin,
|
||
approveEventRevisionAsAdmin,
|
||
cancelEventAsAdmin,
|
||
completeEventAsAdmin,
|
||
deleteEventAsAdmin,
|
||
fetchAdminPendingEventRevision,
|
||
fetchEventCategories,
|
||
fetchAdminEventDetail,
|
||
publishEventAsAdmin,
|
||
rejectEventRevisionAsAdmin,
|
||
updateEventAsAdmin,
|
||
} from '@/services/events'
|
||
import { fetchEventInsightsAsAdmin } from '@/services/eventManagement'
|
||
import { fetchEventFaqs, fetchEventMedia } from '@/services/eventDetail'
|
||
import { fetchAllCities, fetchProvinces } from '@/services/geography'
|
||
import useAdminAction from '@/hooks/useAdminAction'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import { texts } from '@/texts'
|
||
|
||
import AdminEventDetailsSection from './admin-event-detail/AdminEventDetailsSection'
|
||
import AdminEventLifecycleActions from './admin-event-detail/AdminEventLifecycleActions'
|
||
import AdminEventOverviewTab from './admin-event-detail/AdminEventOverviewTab'
|
||
import AdminEventPendingRevisionCard from './admin-event-detail/AdminEventPendingRevisionCard'
|
||
import AdminEventRevisionRejectModal from './admin-event-detail/AdminEventRevisionRejectModal'
|
||
import AdminEventSummaryCard from './admin-event-detail/AdminEventSummaryCard'
|
||
|
||
const TabContentLoading = () => (
|
||
<div
|
||
aria-busy="true"
|
||
aria-live="polite"
|
||
className="admin-surface flex min-h-32 items-center justify-center gap-3 p-6 text-sm text-text-muted"
|
||
role="status"
|
||
>
|
||
<span className="size-5 animate-spin rounded-full border-2 border-primary/25 border-t-primary motion-reduce:animate-none" />
|
||
در حال بارگذاری اطلاعات…
|
||
</div>
|
||
)
|
||
|
||
const AdminEventFinancialTab = dynamic(() => import('./admin-event-detail/AdminEventFinancialTab'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const EventDiscountsPanel = dynamic(() => import('@/features/events/detail/EventDiscountsPanel'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const AdminEventBookingsTab = dynamic(() => import('./admin-event-detail/AdminEventBookingsTab'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const AdminEventReviewsTab = dynamic(() => import('./admin-event-detail/AdminEventReviewsTab'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const EventGuestListAccessPanel = dynamic(() => import('@/components/events/EventGuestListAccessPanel'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const AdminEventRejectModal = dynamic(() => import('./admin-event-detail/AdminEventRejectModal'), { ssr: false })
|
||
const AdminEventCommissionModal = dynamic(() => import('./admin-event-detail/AdminEventCommissionModal'), { ssr: false })
|
||
|
||
const AdminEventDetail = () => {
|
||
const params = useParams<{ id: string }>()
|
||
const searchParams = useSearchParams()
|
||
const router = useRouter()
|
||
const eventId = params.id
|
||
const initialTab = searchParams.get('tab') === 'discounts' ? 'discounts' : 'overview'
|
||
const [event, setEvent] = useState<AdminEventDetailData | null>(null)
|
||
const [categories, setCategories] = useState<EventCategory[]>([])
|
||
const [cities, setCities] = useState<City[]>([])
|
||
const [provinces, setProvinces] = useState<Province[]>([])
|
||
const [media, setMedia] = useState<EventMedia[]>([])
|
||
const [faqs, setFaqs] = useState<EventFaq[]>([])
|
||
const [insights, setInsights] = useState<EventManagementInsights | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [isTogglingDiscoverable, setIsTogglingDiscoverable] = useState(false)
|
||
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false)
|
||
const [isCommissionModalOpen, setIsCommissionModalOpen] = useState(false)
|
||
const [hasOpenedRejectModal, setHasOpenedRejectModal] = useState(false)
|
||
const [hasOpenedCommissionModal, setHasOpenedCommissionModal] = useState(false)
|
||
const [pendingRevision, setPendingRevision] = useState<EventRevision | null>(null)
|
||
const [isRevisionRejectModalOpen, setIsRevisionRejectModalOpen] = useState(false)
|
||
const [hasOpenedRevisionRejectModal, setHasOpenedRevisionRejectModal] = useState(false)
|
||
const { showAlert } = useAlertModal()
|
||
const { pendingId, runAction } = useAdminAction()
|
||
|
||
const mergeEvent = (updated: AdminEventDetailData) => {
|
||
setEvent((prev) => (prev ? { ...prev, ...updated } : prev))
|
||
}
|
||
|
||
const refreshInsights = () => {
|
||
if (!eventId) return
|
||
void fetchEventInsightsAsAdmin(eventId)
|
||
.then(setInsights)
|
||
.catch(() => undefined)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!eventId) return
|
||
|
||
let cancelled = false
|
||
|
||
const loadEvent = async () => {
|
||
try {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
|
||
const [loadedEvent, loadedCategories, loadedCities, loadedProvinces, loadedMedia, loadedFaqs, loadedInsights] = await Promise.all([
|
||
fetchAdminEventDetail(eventId) as Promise<AdminEventDetailData>,
|
||
fetchEventCategories().catch(() => []),
|
||
fetchAllCities().catch(() => []),
|
||
fetchProvinces().catch(() => []),
|
||
fetchEventMedia(eventId).catch(() => []),
|
||
fetchEventFaqs(eventId).catch(() => []),
|
||
fetchEventInsightsAsAdmin(eventId).catch(() => null),
|
||
])
|
||
|
||
if (cancelled) return
|
||
|
||
setEvent(loadedEvent)
|
||
setCategories(loadedCategories)
|
||
setCities(loadedCities)
|
||
setProvinces(loadedProvinces)
|
||
setMedia(loadedMedia)
|
||
setFaqs(loadedFaqs)
|
||
setInsights(loadedInsights)
|
||
} catch {
|
||
if (!cancelled) {
|
||
setError('رویداد یافت نشد')
|
||
setEvent(null)
|
||
}
|
||
} finally {
|
||
if (!cancelled) setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
void loadEvent()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [eventId])
|
||
|
||
useEffect(() => {
|
||
if (!eventId || !event || !isLiveEventStatus(event.status)) {
|
||
setPendingRevision(null)
|
||
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
|
||
const loadPendingRevision = async () => {
|
||
try {
|
||
const revision = await fetchAdminPendingEventRevision(eventId)
|
||
|
||
if (!cancelled) setPendingRevision(revision)
|
||
} catch {
|
||
if (!cancelled) {
|
||
setPendingRevision(null)
|
||
addToast({ title: texts.events.revisionLoadFailed, color: 'danger' })
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadPendingRevision()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [event, eventId])
|
||
|
||
const categoryName = useMemo(() => categories.find((item) => item.id === event?.categoryId)?.name ?? '—', [categories, event?.categoryId])
|
||
const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId])
|
||
const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces])
|
||
|
||
const handleApprove = () =>
|
||
runAction(
|
||
'approve',
|
||
async () => {
|
||
const updated = (await approveEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
},
|
||
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
||
)
|
||
|
||
// Bypasses the whole request/approve flow — publishes immediately
|
||
// regardless of whether the host has requested publication or an admin
|
||
// has approved yet. Kept as a separate action from "approve" on purpose.
|
||
const handleForcePublish = () =>
|
||
runAction(
|
||
'force-publish',
|
||
async () => {
|
||
const updated = (await publishEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
},
|
||
'رویداد فورا منتشر شد'
|
||
)
|
||
|
||
const handleComplete = () => {
|
||
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
||
runAction(
|
||
'complete',
|
||
async () => {
|
||
const updated = (await completeEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
refreshInsights()
|
||
},
|
||
'رویداد بهعنوان پایانیافته علامت خورد'
|
||
)
|
||
)
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
showAlert(
|
||
'آیا از لغو این رویداد مطمئن هستید؟ همهی رزروها لغو خواهند شد.',
|
||
() =>
|
||
runAction(
|
||
'cancel',
|
||
async () => {
|
||
const updated = (await cancelEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
refreshInsights()
|
||
},
|
||
'رویداد لغو شد'
|
||
),
|
||
undefined,
|
||
{ dangerAccept: true }
|
||
)
|
||
}
|
||
|
||
const handleDelete = () => {
|
||
showAlert(
|
||
'آیا از حذف این رویداد مطمئن هستید؟ فقط رویدادهای بدون رزرو فعال قابل حذفاند.',
|
||
() =>
|
||
runAction(
|
||
'delete',
|
||
async () => {
|
||
await deleteEventAsAdmin(eventId)
|
||
router.push(APP_ROUTES.MANAGE_EVENTS)
|
||
},
|
||
'رویداد حذف شد'
|
||
),
|
||
undefined,
|
||
{ dangerAccept: true }
|
||
)
|
||
}
|
||
|
||
const handleApproveRevision = () => {
|
||
if (!pendingRevision) return
|
||
|
||
void runAction(
|
||
'approve-revision',
|
||
async () => {
|
||
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
setPendingRevision(null)
|
||
refreshInsights()
|
||
},
|
||
texts.events.revisionApproveSuccess
|
||
)
|
||
}
|
||
|
||
const handleRejectRevision = (rejectionReason: string) => {
|
||
if (!pendingRevision) return
|
||
|
||
void runAction(
|
||
'reject-revision',
|
||
async () => {
|
||
await rejectEventRevisionAsAdmin(eventId, pendingRevision.id, rejectionReason)
|
||
setPendingRevision(null)
|
||
setIsRevisionRejectModalOpen(false)
|
||
},
|
||
texts.events.revisionRejectSuccess
|
||
)
|
||
}
|
||
|
||
const handleToggleDiscoverable = async (nextValue: boolean) => {
|
||
if (!event || isTogglingDiscoverable) return
|
||
|
||
const previous = event.settings.isDiscoverable
|
||
|
||
setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
|
||
setIsTogglingDiscoverable(true)
|
||
|
||
try {
|
||
await updateEventAsAdmin(eventId, { settings: { isDiscoverable: nextValue } })
|
||
addToast({ title: 'وضعیت جستجوی عمومی بهروزرسانی شد', color: 'success' })
|
||
} catch (err) {
|
||
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: previous } } : prev))
|
||
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
|
||
|
||
addToast({
|
||
title: 'بهروزرسانی ناموفق بود',
|
||
description: detail ?? undefined,
|
||
color: 'danger',
|
||
})
|
||
} finally {
|
||
setIsTogglingDiscoverable(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="w-full h-full">
|
||
<PageNavbar
|
||
endSlot={
|
||
event ? (
|
||
<AdminEventLifecycleActions
|
||
event={event}
|
||
pendingId={pendingId}
|
||
onApprove={() => void handleApprove()}
|
||
onCancel={handleCancel}
|
||
onComplete={handleComplete}
|
||
onDelete={handleDelete}
|
||
onForcePublish={() => void handleForcePublish()}
|
||
onOpenReject={() => {
|
||
setHasOpenedRejectModal(true)
|
||
setIsRejectModalOpen(true)
|
||
}}
|
||
/>
|
||
) : undefined
|
||
}
|
||
pageTitle={event?.title ?? 'جزئیات رویداد'}
|
||
/>
|
||
<div className="admin-page-container flex flex-col gap-5">
|
||
{isLoading && <DetailSkeleton />}
|
||
{!isLoading && error && (
|
||
<div className="admin-surface overflow-hidden">
|
||
<AdminState
|
||
description={error}
|
||
title="دریافت اطلاعات رویداد ناموفق بود"
|
||
variant="error"
|
||
/>
|
||
</div>
|
||
)}
|
||
{!isLoading && event && (
|
||
<>
|
||
<AdminEventSummaryCard
|
||
event={event}
|
||
isTogglingDiscoverable={isTogglingDiscoverable}
|
||
onToggleDiscoverable={handleToggleDiscoverable}
|
||
/>
|
||
|
||
{pendingRevision ? (
|
||
<AdminEventPendingRevisionCard
|
||
pendingId={pendingId}
|
||
revision={pendingRevision}
|
||
onApprove={handleApproveRevision}
|
||
onReject={() => {
|
||
setHasOpenedRevisionRejectModal(true)
|
||
setIsRevisionRejectModalOpen(true)
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<AppTabs
|
||
aria-label="جزئیات رویداد"
|
||
defaultSelectedKey={event.isFree ? 'overview' : initialTab}
|
||
surface="admin"
|
||
>
|
||
<Tab
|
||
key="overview"
|
||
title="خلاصه"
|
||
>
|
||
<AdminEventOverviewTab insights={insights} />
|
||
</Tab>
|
||
|
||
{event.isFree ? null : (
|
||
<Tab
|
||
key="financial"
|
||
title="مالی"
|
||
>
|
||
<AdminEventFinancialTab insights={insights} />
|
||
</Tab>
|
||
)}
|
||
|
||
{event.isFree ? null : (
|
||
<Tab
|
||
key="discounts"
|
||
title="کد تخفیف"
|
||
>
|
||
<EventDiscountsPanel
|
||
isAdmin
|
||
eventId={eventId}
|
||
isEnded={isEventEnded(event.status, event.endsAt)}
|
||
isFree={event.isFree}
|
||
/>
|
||
</Tab>
|
||
)}
|
||
|
||
<Tab
|
||
key="bookings"
|
||
title="رزروها"
|
||
>
|
||
<AdminEventBookingsTab
|
||
eventId={eventId}
|
||
eventStatus={event.status}
|
||
onCheckedIn={refreshInsights}
|
||
/>
|
||
</Tab>
|
||
|
||
<Tab
|
||
key="reviews"
|
||
title={texts.events.reviews}
|
||
>
|
||
<AdminEventReviewsTab eventId={eventId} />
|
||
</Tab>
|
||
|
||
<Tab
|
||
key="guest-lists"
|
||
title="مهمانان ویژه"
|
||
>
|
||
<EventGuestListAccessPanel
|
||
accessMode="admin"
|
||
eventId={eventId}
|
||
surface="admin"
|
||
/>
|
||
</Tab>
|
||
</AppTabs>
|
||
|
||
<AdminEventDetailsSection
|
||
categoryName={categoryName}
|
||
cityName={cityName}
|
||
event={event}
|
||
faqs={faqs}
|
||
media={media}
|
||
provinceName={provinceName}
|
||
onEditCommission={() => {
|
||
setHasOpenedCommissionModal(true)
|
||
setIsCommissionModalOpen(true)
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{hasOpenedRejectModal ? (
|
||
<AdminEventRejectModal
|
||
eventId={eventId}
|
||
isOpen={isRejectModalOpen}
|
||
onOpenChange={setIsRejectModalOpen}
|
||
onRejected={mergeEvent}
|
||
/>
|
||
) : null}
|
||
|
||
{hasOpenedCommissionModal ? (
|
||
<AdminEventCommissionModal
|
||
commissionPercent={event?.commissionPercent}
|
||
eventId={eventId}
|
||
isOpen={isCommissionModalOpen}
|
||
onOpenChange={setIsCommissionModalOpen}
|
||
onUpdated={mergeEvent}
|
||
/>
|
||
) : null}
|
||
|
||
{hasOpenedRevisionRejectModal ? (
|
||
<AdminEventRevisionRejectModal
|
||
isLoading={pendingId === 'reject-revision'}
|
||
isOpen={isRevisionRejectModalOpen}
|
||
onOpenChange={setIsRevisionRejectModalOpen}
|
||
onReject={handleRejectRevision}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default AdminEventDetail
|