Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
239 lines
10 KiB
TypeScript
239 lines
10 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useMemo, useState } from 'react'
|
||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||
import { useParams } from 'next/navigation'
|
||
|
||
import DateSeparator from '@/app/(dashboard)/chat-oversight/[id]/_components/DateSeparator'
|
||
import Button from '@/components/formElements/Button'
|
||
import AdminState from '@/components/feedback/AdminState'
|
||
import { DetailSkeleton, ListSkeleton } from '@/components/feedback/LoadingState'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import StatusChip from '@/components/ui/StatusChip'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import { groupMessagesForDisplay } from '@/features/chat/groupMessagesForDisplay'
|
||
import { formatPersonName } from '@/helpers'
|
||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||
import useAdminChatSocket from '@/hooks/useAdminChatSocket'
|
||
import {
|
||
GET_ADMIN_CONVERSATION,
|
||
GET_ADMIN_CONVERSATION_MESSAGES,
|
||
GET_ADMIN_CONVERSATION_PARTICIPANTS,
|
||
type AdminChatMessage,
|
||
} from '@/services/adminChat'
|
||
|
||
import AdminMessageBubble from './_components/AdminMessageBubble'
|
||
|
||
const ChatOversightDetailPage = () => {
|
||
const params = useParams<{ id: string }>()
|
||
const conversationId = params.id
|
||
const [liveMessages, setLiveMessages] = useState<AdminChatMessage[]>([])
|
||
const handleLiveMessage = useCallback((message: AdminChatMessage) => {
|
||
setLiveMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message]))
|
||
}, [])
|
||
const socket = useAdminChatSocket(conversationId, handleLiveMessage)
|
||
const conversationQuery = useQuery({
|
||
queryKey: ['admin', 'chat-oversight', conversationId],
|
||
queryFn: () => GET_ADMIN_CONVERSATION(conversationId),
|
||
enabled: Boolean(conversationId),
|
||
})
|
||
const participantsQuery = useQuery({
|
||
queryKey: ['admin', 'chat-oversight', conversationId, 'participants'],
|
||
queryFn: () => GET_ADMIN_CONVERSATION_PARTICIPANTS(conversationId),
|
||
enabled: Boolean(conversationId),
|
||
})
|
||
const messagesQuery = useInfiniteQuery({
|
||
queryKey: ['admin', 'chat-oversight', conversationId, 'messages'],
|
||
initialPageParam: undefined as string | undefined,
|
||
queryFn: ({ pageParam }) => GET_ADMIN_CONVERSATION_MESSAGES(conversationId, pageParam ? { before: pageParam } : undefined),
|
||
getNextPageParam: (lastPage) => (lastPage.hasMoreBefore ? (lastPage.olderCursor ?? undefined) : undefined),
|
||
enabled: Boolean(conversationId),
|
||
})
|
||
|
||
const messages = useMemo(() => {
|
||
const seen = new Set<string>()
|
||
|
||
const history = [...(messagesQuery.data?.pages ?? [])].reverse().flatMap((page) => page.items)
|
||
|
||
return [...history, ...liveMessages].filter((message) => {
|
||
if (seen.has(message.id)) return false
|
||
seen.add(message.id)
|
||
|
||
return true
|
||
})
|
||
}, [liveMessages, messagesQuery.data])
|
||
|
||
const displayItems = useMemo(() => groupMessagesForDisplay(messages, { showSenderNames: true }), [messages])
|
||
|
||
const conversation = conversationQuery.data
|
||
const participants = useMemo(() => participantsQuery.data ?? [], [participantsQuery.data])
|
||
const participantSideById = useMemo(
|
||
() => new Map(participants.map((participant, index) => [participant.userId, index % 2 === 0 ? 'primary' : 'secondary'] as const)),
|
||
[participants]
|
||
)
|
||
const title = conversation
|
||
? conversation.type === 'event_group'
|
||
? conversation.eventTitle || 'گروه رویداد'
|
||
: participants
|
||
.map((participant) => formatPersonName(participant.firstName, participant.lastName, formatIranianMobile(participant.mobile)))
|
||
.join(' و ') || 'گفتگوی خصوصی'
|
||
: 'جزئیات گفتگو'
|
||
const loadError = conversationQuery.error ?? participantsQuery.error ?? messagesQuery.error
|
||
const isInitialLoading = conversationQuery.isLoading || participantsQuery.isLoading || messagesQuery.isLoading
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar
|
||
description="حالت نظارت مخفی و فقطخواندنی"
|
||
endSlot={
|
||
<Button
|
||
size="sm"
|
||
to={APP_ROUTES.CHAT_OVERSIGHT}
|
||
variant="flat"
|
||
>
|
||
بازگشت به گفتگوها
|
||
</Button>
|
||
}
|
||
pageTitle={title}
|
||
/>
|
||
|
||
<div className="admin-page-container flex flex-col gap-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-fourth-100 bg-fourth-100 px-4 py-3">
|
||
<div>
|
||
<p className="font-bold text-fourth-900">حالت نظارت — فقط خواندنی</p>
|
||
<p className="mt-1 text-xs leading-6 text-fourth-900">
|
||
مشاهده شما عضویت، اعلان یا تغییر وضعیت خواندهشدن برای کاربران ایجاد نمیکند و در گزارش فعالیت ادمین ثبت میشود.
|
||
</p>
|
||
</div>
|
||
{conversation ? (
|
||
<div className="flex items-center gap-2">
|
||
<StatusChip
|
||
chipColor={socket.status === 'connected' ? 'success' : socket.status === 'error' ? 'danger' : 'warning'}
|
||
label={socket.status === 'connected' ? 'اتصال زنده' : socket.status === 'error' ? 'خطای سوکت' : 'در حال اتصال'}
|
||
/>
|
||
<StatusChip
|
||
chipColor={conversation.type === 'event_group' ? 'warning' : 'default'}
|
||
label={conversation.type === 'event_group' ? 'گروه رویداد' : 'گفتگوی خصوصی'}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{socket.error ? (
|
||
<p className="rounded-xl border border-fourth-100 bg-fourth-100 px-3 py-2 text-xs text-fourth-900">
|
||
{socket.error} پیامهای ذخیرهشده همچنان از طریق API قابل مشاهدهاند.
|
||
</p>
|
||
) : null}
|
||
|
||
{isInitialLoading ? (
|
||
<>
|
||
<DetailSkeleton />
|
||
<ListSkeleton count={6} />
|
||
</>
|
||
) : null}
|
||
|
||
{!isInitialLoading && loadError ? (
|
||
<div className="admin-surface">
|
||
<AdminState
|
||
actionLabel="تلاش دوباره"
|
||
description={loadError instanceof Error ? loadError.message : 'دریافت اطلاعات گفتگو ناموفق بود.'}
|
||
title="خطا در دریافت گفتگو"
|
||
variant="error"
|
||
onAction={() => {
|
||
void conversationQuery.refetch()
|
||
void participantsQuery.refetch()
|
||
void messagesQuery.refetch()
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{!isInitialLoading && !loadError && conversation ? (
|
||
<>
|
||
<section className="admin-surface p-4">
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||
<h2 className="text-base font-bold text-foreground">اعضای گفتگو</h2>
|
||
<span className="text-xs text-muted">{conversation.memberCount.toLocaleString('fa-IR')} نفر</span>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
{participants.map((participant) => (
|
||
<div
|
||
key={participant.userId}
|
||
className="rounded-xl border border-border bg-surface-secondary px-3 py-2"
|
||
>
|
||
<p className="text-sm font-semibold">{formatPersonName(participant.firstName, participant.lastName, 'کاربر')}</p>
|
||
<p
|
||
className="mt-1 text-xs text-muted"
|
||
dir="ltr"
|
||
>
|
||
{formatIranianMobile(participant.mobile)}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="admin-surface p-4">
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||
<h2 className="text-base font-bold text-foreground">پیامهای گفتگو</h2>
|
||
{conversation.lastMessageAt ? (
|
||
<span className="text-xs text-muted">آخرین فعالیت: {formatPersianDate(conversation.lastMessageAt)}</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{messagesQuery.hasNextPage ? (
|
||
<div className="mb-4 flex justify-center">
|
||
<Button
|
||
isLoading={messagesQuery.isFetchingNextPage}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => void messagesQuery.fetchNextPage()}
|
||
>
|
||
بارگذاری پیامهای قدیمیتر
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{messages.length === 0 ? (
|
||
<AdminState
|
||
description="هنوز پیامی در این گفتگو ثبت نشده است."
|
||
title="گفتگو بدون پیام است"
|
||
/>
|
||
) : (
|
||
<div className="rounded-2xl border border-consumer-border bg-consumer-canvas px-3 py-4 sm:px-6">
|
||
<div className="mx-auto flex w-full max-w-3xl flex-col">
|
||
{displayItems.map((item) => {
|
||
if (item.kind === 'date') {
|
||
return (
|
||
<DateSeparator
|
||
key={item.key}
|
||
label={item.label}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<AdminMessageBubble
|
||
key={item.key}
|
||
isClusterEnd={item.isClusterEnd}
|
||
isClusterStart={item.isClusterStart}
|
||
message={item.message}
|
||
showAvatar={item.showAvatar}
|
||
showSenderName={item.showSenderName}
|
||
side={participantSideById.get(item.message.senderId) ?? 'primary'}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default ChatOversightDetailPage
|