Files
banu-front/src/features/admin/messages/components/modals/TicketDetailsModal.vue
T
sajjadtalkhabi 38a396ba21
Deploy banu-front / deploy (push) Successful in 1m27s
fix
2026-07-07 17:36:43 +03:30

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>