admin/app/(dashboard)/chat-oversight/[id]/page.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

239 lines
10 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 { 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