@@ -14,7 +14,17 @@
|
||||
</div>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="messages.length > 0" class="ticket-details__thread" ref="thread">
|
||||
<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"
|
||||
@@ -22,6 +32,7 @@
|
||||
: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>
|
||||
@@ -31,6 +42,25 @@
|
||||
|
||||
<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"
|
||||
@@ -57,6 +87,23 @@
|
||||
>
|
||||
<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>
|
||||
@@ -81,13 +128,17 @@
|
||||
</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,
|
||||
@@ -143,6 +194,8 @@ const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
|
||||
|
||||
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
|
||||
@@ -157,7 +210,49 @@ const messageTime = (message) => {
|
||||
}
|
||||
|
||||
const text = ref('')
|
||||
const canSend = computed(() => text.value.trim().length > 0)
|
||||
|
||||
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)
|
||||
@@ -213,10 +308,15 @@ 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 }.
|
||||
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -405,6 +505,12 @@ watch(messages, async () => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
&__attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user