admin/app/(dashboard)/support-tickets/[id]/page.tsx
alisaza 9254a8b487 feat(support-tickets): add close action and turn-based reply guidance
Let admins close tickets with confirmation and surface closedBy so consumer reopen rules stay clear.
2026-09-11 21:31:16 +03:30

183 lines
7.0 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 { 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