585 lines
14 KiB
Vue
585 lines
14 KiB
Vue
<template>
|
|
<BasicModal
|
|
title="جزئیات تیکت"
|
|
title-en="Ticket Details"
|
|
width="95%"
|
|
max-width="64rem"
|
|
min-width="auto"
|
|
:show-close-button="true"
|
|
>
|
|
<template #default>
|
|
<div class="ticket-details">
|
|
<div v-if="ticket" class="ticket-details__date">
|
|
<span>{{ ticketDate }}</span>
|
|
</div>
|
|
|
|
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
|
|
<div
|
|
v-else-if="messages.length > 0 || ticketMedia.length > 0"
|
|
class="ticket-details__thread"
|
|
ref="thread"
|
|
>
|
|
<div v-if="ticketMedia.length > 0" class="ticket-details__row ticket-details__row--user">
|
|
<div class="ticket-details__bubble">
|
|
<MessageAttachments :media="ticketMedia" />
|
|
<span class="ticket-details__time">{{ messageTime(ticket) }}</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-for="message in messages"
|
|
:key="message.id"
|
|
class="ticket-details__row"
|
|
:class="`ticket-details__row--${senderClass(message)}`"
|
|
>
|
|
<div class="ticket-details__bubble">
|
|
<MessageAttachments v-if="message.media?.length" :media="message.media" />
|
|
<p class="ticket-details__text">{{ message.message }}</p>
|
|
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی در این تیکت ثبت نشده است." />
|
|
|
|
<div class="ticket-details__divider" />
|
|
|
|
<div v-if="attachments.length > 0" class="ticket-details__attachments">
|
|
<span
|
|
v-for="(file, index) in attachments"
|
|
:key="file.id ?? index"
|
|
class="ticket-details__attachment"
|
|
>
|
|
<SvgIcon name="file" :size="14" color="currentColor" />
|
|
<span class="ticket-details__attachment-name">{{ file.name }}</span>
|
|
<button
|
|
type="button"
|
|
class="ticket-details__attachment-remove"
|
|
aria-label="حذف فایل"
|
|
@click="removeAttachment(index)"
|
|
>
|
|
<SvgIcon name="close" :size="12" color="var(--color-error)" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<form class="ticket-details__compose" @submit.prevent="onSend">
|
|
<button
|
|
type="submit"
|
|
:disabled="!canSend"
|
|
class="ticket-details__send"
|
|
aria-label="ارسال"
|
|
>
|
|
<SvgIcon name="paper-plane-right" :size="22" color="var(--color-primary)" />
|
|
</button>
|
|
<input
|
|
v-model="text"
|
|
type="text"
|
|
placeholder="ارسال پیام"
|
|
class="ticket-details__input"
|
|
/>
|
|
<div class="ticket-details__compose-actions">
|
|
<button
|
|
ref="emojiButton"
|
|
type="button"
|
|
class="ticket-details__compose-btn"
|
|
:class="{ 'ticket-details__compose-btn--active': emojiOpen }"
|
|
aria-label="ایموجی"
|
|
@click="toggleEmoji"
|
|
>
|
|
<SvgIcon name="mood" :size="30" color="currentColor" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="ticket-details__compose-btn"
|
|
:disabled="uploadMutation.isPending.value"
|
|
aria-label="پیوست فایل"
|
|
@click="fileInput?.click()"
|
|
>
|
|
<SvgIcon name="attach-file" :size="30" color="currentColor" />
|
|
</button>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="ticket-details__file-input"
|
|
accept="image/*,application/pdf"
|
|
multiple
|
|
@change="onFilesPicked"
|
|
/>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
</BasicModal>
|
|
|
|
<Teleport to="body">
|
|
<Transition name="emoji-pop">
|
|
<div v-if="emojiOpen" ref="emojiPanel" class="ticket-emoji-panel" :style="emojiStyle">
|
|
<button
|
|
v-for="emoji in EMOJIS"
|
|
:key="emoji"
|
|
type="button"
|
|
class="ticket-emoji-panel__emoji"
|
|
@click="insertEmoji(emoji)"
|
|
>
|
|
{{ emoji }}
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from 'vue3-toastify'
|
|
import useModal from '@/composables/useModal'
|
|
import { useQueryClient } from '@tanstack/vue-query'
|
|
import BasicModal from '@/components/BasicModal.vue'
|
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|
import NoItems from '@/components/blocks/NoItems.vue'
|
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
import MessageAttachments from '@/components/blocks/MessageAttachments.vue'
|
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
|
import {
|
|
autoUpdate,
|
|
computePosition,
|
|
flip,
|
|
offset as offsetMiddleware,
|
|
shift,
|
|
} from '@floating-ui/dom'
|
|
import {
|
|
adminTicketsKeys,
|
|
useAdminTicketQuery,
|
|
useSendAdminTicketMessageMutation,
|
|
} from '@/services/query/admin-tickets'
|
|
|
|
defineOptions({ name: 'TicketDetailsModal' })
|
|
|
|
const EMOJIS = [
|
|
'😀',
|
|
'😂',
|
|
'😍',
|
|
'🤔',
|
|
'👍',
|
|
'👎',
|
|
'🙏',
|
|
'👏',
|
|
'🎉',
|
|
'🔥',
|
|
'✨',
|
|
'⭐',
|
|
'✅',
|
|
'❌',
|
|
'❤️',
|
|
'💔',
|
|
'😊',
|
|
'😎',
|
|
'😢',
|
|
'😡',
|
|
'🤝',
|
|
'👌',
|
|
'💡',
|
|
'📌',
|
|
]
|
|
|
|
const queryClient = useQueryClient()
|
|
const { getModal } = useModal()
|
|
|
|
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
|
|
const ticketId = computed(() => modalData.value.id ?? null)
|
|
|
|
const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
|
|
enabled: () => !!ticketId.value,
|
|
})
|
|
|
|
const messages = computed(() => ticket.value?.messages ?? [])
|
|
|
|
const ticketMedia = computed(() => ticket.value?.media ?? [])
|
|
|
|
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
|
|
|
// Anyone whose id matches the ticket's student is "the student"; everyone else
|
|
// (admin, counselor) renders on the opposite side of the thread.
|
|
const senderClass = (message) => (message.senderId === ticket.value?.studentId ? 'user' : 'admin')
|
|
|
|
const messageTime = (message) => {
|
|
if (!message.createdAt) return '—'
|
|
const d = new Date(message.createdAt)
|
|
if (Number.isNaN(d.getTime())) return '—'
|
|
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const text = ref('')
|
|
|
|
const MAX_ATTACHMENTS = 5
|
|
|
|
const fileInput = ref(null)
|
|
const attachments = ref([])
|
|
|
|
const uploadMutation = useUploadMediaMutation()
|
|
|
|
const onFilesPicked = async (event) => {
|
|
const picked = [...event.target.files]
|
|
event.target.value = ''
|
|
if (attachments.value.length + picked.length > MAX_ATTACHMENTS) {
|
|
toast.warning(`حداکثر تعداد فایل مجاز ${MAX_ATTACHMENTS} عدد است.`)
|
|
return
|
|
}
|
|
for (const file of picked) {
|
|
try {
|
|
const fd = objectToFormData({ file, purpose: 'attachment', context: 'ticket' })
|
|
const response = await uploadMutation.mutateAsync(fd)
|
|
const payload = response?.data ?? response
|
|
const id = payload?.id ?? payload?.uploadId
|
|
if (id == null) continue
|
|
attachments.value = [
|
|
...attachments.value,
|
|
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
|
|
]
|
|
} catch {
|
|
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const removeAttachment = (index) => {
|
|
const next = [...attachments.value]
|
|
next.splice(index, 1)
|
|
attachments.value = next
|
|
}
|
|
|
|
const canSend = computed(
|
|
() =>
|
|
(text.value.trim().length > 0 || attachments.value.length > 0) &&
|
|
!uploadMutation.isPending.value
|
|
)
|
|
|
|
const emojiOpen = ref(false)
|
|
const emojiButton = ref(null)
|
|
const emojiPanel = ref(null)
|
|
const emojiStyle = ref({ position: 'fixed', top: '0', left: '0' })
|
|
let emojiCleanup = null
|
|
|
|
const toggleEmoji = () => {
|
|
emojiOpen.value = !emojiOpen.value
|
|
}
|
|
const insertEmoji = (emoji) => {
|
|
text.value += emoji
|
|
}
|
|
|
|
const mountEmoji = async () => {
|
|
await nextTick()
|
|
if (!emojiButton.value || !emojiPanel.value) return
|
|
emojiCleanup = autoUpdate(emojiButton.value, emojiPanel.value, () => {
|
|
computePosition(emojiButton.value, emojiPanel.value, {
|
|
placement: 'top-end',
|
|
strategy: 'fixed',
|
|
middleware: [offsetMiddleware(8), flip(), shift({ padding: 8 })],
|
|
}).then(({ x, y }) => {
|
|
emojiStyle.value = { position: 'fixed', top: `${y}px`, left: `${x}px` }
|
|
})
|
|
})
|
|
}
|
|
|
|
const unmountEmoji = () => {
|
|
emojiCleanup?.()
|
|
emojiCleanup = null
|
|
}
|
|
|
|
watch(emojiOpen, (open) => {
|
|
if (open) mountEmoji()
|
|
else unmountEmoji()
|
|
})
|
|
|
|
const onDocumentClick = (event) => {
|
|
if (!emojiOpen.value) return
|
|
if (emojiPanel.value?.contains(event.target)) return
|
|
if (emojiButton.value?.contains(event.target)) return
|
|
emojiOpen.value = false
|
|
}
|
|
document.addEventListener('mousedown', onDocumentClick)
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('mousedown', onDocumentClick)
|
|
unmountEmoji()
|
|
})
|
|
|
|
const sendMutation = useSendAdminTicketMessageMutation()
|
|
|
|
const onSend = async () => {
|
|
if (!canSend.value || !ticketId.value) return
|
|
const value = text.value.trim()
|
|
const mediaIds = attachments.value.map((f) => f.id).filter((id) => id != null)
|
|
text.value = ''
|
|
attachments.value = []
|
|
emojiOpen.value = false
|
|
// Backend POST /admin/tickets/:id/messages — body is { message: string,
|
|
// mediaIds?: number[] } where mediaIds reference previously uploaded media.
|
|
const payload = { message: value }
|
|
if (mediaIds.length > 0) payload.mediaIds = mediaIds
|
|
await sendMutation.mutateAsync({ id: ticketId.value, payload })
|
|
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
|
|
}
|
|
|
|
const thread = ref(null)
|
|
|
|
watch(messages, async () => {
|
|
await nextTick()
|
|
if (thread.value) {
|
|
thread.value.scrollTop = thread.value.scrollHeight
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.ticket-details {
|
|
width: 100%;
|
|
text-align: start;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: calc(100dvh - 8rem);
|
|
min-height: 28rem;
|
|
|
|
&__date {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin: 0.5rem 0 0.875rem;
|
|
}
|
|
|
|
&__date span {
|
|
background: #f7f7f7;
|
|
color: #9c9c9c;
|
|
border-radius: 9999px;
|
|
padding: 0.5rem 1.25rem;
|
|
font-family: var(--font-family-en);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
&__thread {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
padding-block: 0.5rem 1rem;
|
|
padding-inline-end: 0.25rem;
|
|
}
|
|
|
|
&__row {
|
|
display: flex;
|
|
|
|
&--admin {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
&--user {
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
|
|
&__bubble {
|
|
max-width: 72%;
|
|
border-radius: 1rem;
|
|
padding: 0.75rem 1.25rem;
|
|
|
|
.ticket-details__row--admin & {
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-end-start-radius: 0;
|
|
}
|
|
|
|
.ticket-details__row--user & {
|
|
background: #f8f8f8;
|
|
color: #5d5d5d;
|
|
border-end-end-radius: 0;
|
|
}
|
|
}
|
|
|
|
&__text {
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.875rem;
|
|
line-height: 1.7;
|
|
margin: 0;
|
|
}
|
|
|
|
&__time {
|
|
display: block;
|
|
margin-top: 0.375rem;
|
|
font-family: var(--font-family-en);
|
|
font-size: 0.7rem;
|
|
|
|
.ticket-details__row--admin & {
|
|
color: rgba(255, 255, 255, 80%);
|
|
}
|
|
|
|
.ticket-details__row--user & {
|
|
color: #b1b1b1;
|
|
}
|
|
}
|
|
|
|
&__divider {
|
|
border-block-end: 1px solid var(--color-thd-gray);
|
|
margin-block: 0.75rem;
|
|
}
|
|
|
|
&__compose {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
&__send {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 9999px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-primary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.2s ease;
|
|
|
|
&:hover:enabled {
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
&__input {
|
|
flex: 1;
|
|
height: 2.5rem;
|
|
border: 1px solid transparent;
|
|
border-radius: 1.5rem;
|
|
padding: 0 1rem;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.875rem;
|
|
color: #4b4b4b;
|
|
outline: none;
|
|
|
|
&:focus {
|
|
border-color: var(--color-thd-gray);
|
|
}
|
|
}
|
|
|
|
&__compose-actions {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
&__compose-btn {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 9999px;
|
|
border: none;
|
|
background: transparent;
|
|
color: #d1d1d1;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.15s ease, color 0.15s ease;
|
|
|
|
&:hover {
|
|
background: rgba(0, 0, 0, 4%);
|
|
color: var(--color-prim-gray);
|
|
}
|
|
|
|
&--active {
|
|
color: var(--color-primary);
|
|
background: rgba(0, 0, 0, 4%);
|
|
}
|
|
}
|
|
|
|
&__file-input {
|
|
display: none;
|
|
}
|
|
|
|
&__attachments {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
&__attachment {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.375rem 0.625rem;
|
|
margin-bottom: 0.5rem;
|
|
background: #f7f7f7;
|
|
border-radius: 9999px;
|
|
align-self: flex-start;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.75rem;
|
|
color: #4b4b4b;
|
|
max-width: 18rem;
|
|
}
|
|
|
|
&__attachment-name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
&__attachment-remove {
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
.ticket-emoji-panel {
|
|
width: 17rem;
|
|
background: #fff;
|
|
border-radius: 0.875rem;
|
|
box-shadow: 0 10px 25px -8px rgba(0, 0, 0, 12%), 0 4px 10px -4px rgba(0, 0, 0, 8%);
|
|
padding: 0.5rem;
|
|
display: grid;
|
|
grid-template-columns: repeat(8, 1fr);
|
|
gap: 0.125rem;
|
|
z-index: 1000;
|
|
|
|
&__emoji {
|
|
height: 2rem;
|
|
border: none;
|
|
background: transparent;
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
font-size: 1.125rem;
|
|
line-height: 1;
|
|
|
|
&:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
}
|
|
}
|
|
|
|
.emoji-pop-enter-active,
|
|
.emoji-pop-leave-active {
|
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
|
}
|
|
|
|
.emoji-pop-enter-from,
|
|
.emoji-pop-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
}
|
|
</style>
|