Let admins close tickets with confirmation and surface closedBy so consumer reopen rules stay clear.
183 lines
7.0 KiB
TypeScript
183 lines
7.0 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useParams } from 'next/navigation'
|
||
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import Button from '@/components/formElements/Button'
|
||
import Input from '@/components/formElements/Input'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import { addToast } from '@/lib/toast'
|
||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||
import {
|
||
adminReplyToSupportTicket,
|
||
getAdminSupportTicket,
|
||
SUPPORT_ADMIN_STATUS_LABELS,
|
||
SUPPORT_CATEGORY_LABELS,
|
||
SUPPORT_PRIORITY_LABELS,
|
||
updateSupportTicketStatus,
|
||
type SupportTicket,
|
||
} from '@/services/supportTickets'
|
||
|
||
const AdminSupportTicketDetail = () => {
|
||
const { id } = useParams<{ id: string }>()
|
||
const { showAlert } = useAlertModal()
|
||
const [ticket, setTicket] = useState<SupportTicket | null>(null)
|
||
const [reply, setReply] = useState('')
|
||
const [pending, setPending] = useState(false)
|
||
|
||
useEffect(() => {
|
||
void getAdminSupportTicket(id)
|
||
.then(setTicket)
|
||
.catch(() => addToast({ title: 'دریافت تیکت ناموفق بود', color: 'danger' }))
|
||
}, [id])
|
||
|
||
const send = async () => {
|
||
if (!reply.trim()) return
|
||
try {
|
||
setPending(true)
|
||
const updated = await adminReplyToSupportTicket(id, reply)
|
||
|
||
setTicket(updated)
|
||
setReply('')
|
||
addToast({ title: 'پاسخ ثبت شد و پیامک کاربر در صف ارسال قرار گرفت', color: 'success' })
|
||
} catch {
|
||
addToast({ title: 'ثبت پاسخ ناموفق بود', color: 'danger' })
|
||
} finally {
|
||
setPending(false)
|
||
}
|
||
}
|
||
|
||
const changeStatus = async (status: string) => {
|
||
try {
|
||
setTicket(await updateSupportTicketStatus(id, status))
|
||
addToast({ title: 'وضعیت تیکت تغییر کرد', color: 'success' })
|
||
} catch {
|
||
addToast({ title: 'تغییر وضعیت ناموفق بود', color: 'danger' })
|
||
}
|
||
}
|
||
|
||
const confirmClose = () => {
|
||
showAlert(
|
||
'این تیکت بسته شود؟ کاربر دیگر نمیتواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمیتواند دوباره باز کند.',
|
||
() => {
|
||
void changeStatus('closed')
|
||
},
|
||
undefined,
|
||
{ dangerAccept: true }
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
|
||
<div className="admin-page-container mx-auto max-w-4xl space-y-5">
|
||
<Button
|
||
size="sm"
|
||
to={APP_ROUTES.SUPPORT_TICKETS}
|
||
variant="light"
|
||
>
|
||
بازگشت به تیکتها
|
||
</Button>
|
||
{!ticket ? (
|
||
<p className="py-12 text-center text-text-muted">در حال دریافت…</p>
|
||
) : (
|
||
<>
|
||
<div className="rounded-2xl border border-default-200 bg-white p-5">
|
||
<div className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||
<div>
|
||
<p className="text-text-muted">کاربر</p>
|
||
<strong>{ticket.user.displayName || 'کاربر'}</strong>
|
||
<p dir="ltr">{formatIranianMobile(ticket.user.mobile)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-text-muted">دستهبندی</p>
|
||
<strong>{SUPPORT_CATEGORY_LABELS[ticket.category]}</strong>
|
||
</div>
|
||
<div>
|
||
<p className="text-text-muted">اولویت</p>
|
||
<strong>{SUPPORT_PRIORITY_LABELS[ticket.priority]}</strong>
|
||
</div>
|
||
<label>
|
||
<span className="text-text-muted">وضعیت</span>
|
||
<select
|
||
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
||
value={ticket.status}
|
||
onChange={(event) => void changeStatus(event.target.value)}
|
||
>
|
||
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
||
<option
|
||
key={value}
|
||
value={value}
|
||
>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{ticket.status !== 'closed' ? (
|
||
<div className="mt-4 flex justify-end">
|
||
<Button
|
||
color="danger"
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={confirmClose}
|
||
>
|
||
بستن تیکت
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="mt-4 text-sm text-text-muted">
|
||
{ticket.closedBy === 'user'
|
||
? 'این تیکت توسط کاربر بسته شده است.'
|
||
: 'این تیکت توسط پشتیبانی بسته شده است؛ کاربر نمیتواند دوباره باز کند.'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
|
||
{ticket.messages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={`max-w-[82%] rounded-2xl p-4 ${message.isAdmin ? 'mr-auto bg-primary text-white' : 'ml-auto bg-default-100'}`}
|
||
>
|
||
<p className="text-xs opacity-70">{message.isAdmin ? 'پشتیبانی' : ticket.user.displayName || 'کاربر'}</p>
|
||
<p className="mt-1 whitespace-pre-wrap leading-7">{message.body}</p>
|
||
<p className="mt-2 text-xs opacity-60">{formatPersianDate(message.createdAt)}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{ticket.status !== 'closed' ? (
|
||
<div className="rounded-2xl border border-default-200 bg-white p-5">
|
||
<Input
|
||
generalType="textarea"
|
||
label="پاسخ پشتیبانی"
|
||
name="adminReply"
|
||
textAreaMinRows={5}
|
||
value={reply}
|
||
onValueChange={(value) => {
|
||
setReply(String(value))
|
||
}}
|
||
/>
|
||
<p className="my-3 text-xs text-text-muted">
|
||
میتوانید چند پیام پشتسرهم بفرستید. کاربر فقط بعد از پاسخ شما یک پیام میتواند بفرستد. پس از ثبت پاسخ، برای کاربر پیامک
|
||
اطلاعرسانی ارسال میشود.
|
||
</p>
|
||
<Button
|
||
isLoading={pending}
|
||
onClick={() => void send()}
|
||
>
|
||
ثبت پاسخ و ارسال پیامک
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default AdminSupportTicketDetail
|