Binary file not shown.
|
After Width: | Height: | Size: 596 B |
Binary file not shown.
|
After Width: | Height: | Size: 759 B |
Binary file not shown.
|
After Width: | Height: | Size: 508 B |
Binary file not shown.
|
After Width: | Height: | Size: 638 B |
Vendored
+3
@@ -13,6 +13,9 @@ export type IconName =
|
|||||||
| 'caret-right'
|
| 'caret-right'
|
||||||
| 'chat'
|
| 'chat'
|
||||||
| 'chat-centered-dots'
|
| 'chat-centered-dots'
|
||||||
|
| 'chat-slash'
|
||||||
|
| 'chat-text'
|
||||||
|
| 'chats'
|
||||||
| 'check'
|
| 'check'
|
||||||
| 'check-square'
|
| 'check-square'
|
||||||
| 'close'
|
| 'close'
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ export const TICKET_STATUS = Object.freeze({
|
|||||||
closed: 'بسته شده',
|
closed: 'بسته شده',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Priority levels for student-created tickets. Sent to the backend on POST
|
||||||
|
// /student/tickets (currently carried via the `category` field).
|
||||||
|
export const TICKET_PRIORITY = Object.freeze({
|
||||||
|
low: 'کم اهمیت',
|
||||||
|
normal: 'عادی',
|
||||||
|
high: 'مهم',
|
||||||
|
urgent: 'فوری',
|
||||||
|
})
|
||||||
|
|
||||||
export const STUDENT_COURSE_STATUS = Object.freeze({
|
export const STUDENT_COURSE_STATUS = Object.freeze({
|
||||||
active: 'در حال گذراندن',
|
active: 'در حال گذراندن',
|
||||||
completed: 'تکمیل شده',
|
completed: 'تکمیل شده',
|
||||||
|
|||||||
+8
-4
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="55rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="جزئیات تکلیف"
|
||||||
|
title-en="Submission Details"
|
||||||
|
width="95%"
|
||||||
|
max-width="55rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default="{ close }">
|
<template #default="{ close }">
|
||||||
<div class="submission-details">
|
<div class="submission-details">
|
||||||
<LineTitleBlock title="جزئیات تکلیف" title-en="Submission Details" />
|
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
||||||
<template v-else-if="submission">
|
<template v-else-if="submission">
|
||||||
<div class="submission-details__person">
|
<div class="submission-details__person">
|
||||||
@@ -136,7 +141,6 @@ import NoItems from '@/components/blocks/NoItems.vue'
|
|||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||||
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
|
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="جزئیات مشاوره"
|
||||||
|
title-en="Consultation Details"
|
||||||
|
width="95%"
|
||||||
|
max-width="64rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="consultation-details">
|
<div class="consultation-details">
|
||||||
<LineTitleBlock title="جزئیات مشاوره" title-en="Consultation Details" />
|
|
||||||
|
|
||||||
<div v-if="consultation" class="consultation-details__date">
|
<div v-if="consultation" class="consultation-details__date">
|
||||||
<span>{{ consultationDate }}</span>
|
<span>{{ consultationDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +60,6 @@ import BasicModal from '@/components/BasicModal.vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
adminConsultationsKeys,
|
adminConsultationsKeys,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
:title="modeTitle"
|
||||||
|
title-en="Offered Course"
|
||||||
|
width="95%"
|
||||||
|
max-width="60rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default="{ close }">
|
<template #default="{ close }">
|
||||||
<form class="offered-course" @submit.prevent="onSubmit(close)">
|
<form class="offered-course" @submit.prevent="onSubmit(close)">
|
||||||
<LineTitleBlock :title="modeTitle" title-en="Offered Course" />
|
|
||||||
|
|
||||||
<div class="offered-course__grid">
|
<div class="offered-course__grid">
|
||||||
<div class="offered-course__image-col">
|
<div class="offered-course__image-col">
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
@@ -115,7 +120,6 @@ import BaseButton from '@/components/BaseButton.vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||||
import ImageCropper from '@/components/form/ImageCropper.vue'
|
import ImageCropper from '@/components/form/ImageCropper.vue'
|
||||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="76rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="شرکت کنندگان"
|
||||||
|
title-en="Participants"
|
||||||
|
width="95%"
|
||||||
|
max-width="76rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default="{ close }">
|
<template #default="{ close }">
|
||||||
<div class="participant-details">
|
<div class="participant-details">
|
||||||
<LineTitleBlock title="شرکت کنندگان" title-en="Participants" />
|
|
||||||
|
|
||||||
<p v-if="participantName" class="participant-details__name">{{ participantName }}</p>
|
<p v-if="participantName" class="participant-details__name">{{ participantName }}</p>
|
||||||
|
|
||||||
<div class="participant-details__card">
|
<div class="participant-details__card">
|
||||||
@@ -104,7 +109,6 @@ import BaseButton from '@/components/BaseButton.vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import { useAdminExamParticipantQuery } from '@/services/query/admin-exams'
|
import { useAdminExamParticipantQuery } from '@/services/query/admin-exams'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="شرکتکنندگان"
|
||||||
|
title-en="Participants"
|
||||||
|
width="95%"
|
||||||
|
max-width="60rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default="{ data, close }">
|
<template #default="{ data, close }">
|
||||||
<div class="exam-participants">
|
<div class="exam-participants">
|
||||||
<LineTitleBlock title="شرکتکنندگان" title-en="Participants" />
|
|
||||||
<p v-if="data?.title" class="exam-participants__exam">{{ data.title }}</p>
|
<p v-if="data?.title" class="exam-participants__exam">{{ data.title }}</p>
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
|
||||||
@@ -72,7 +78,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import { usePagination } from '@/composables/usePagination'
|
import { usePagination } from '@/composables/usePagination'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||||
import { useAdminExamAttemptsQuery } from '@/services/query/admin-exams'
|
import { useAdminExamAttemptsQuery } from '@/services/query/admin-exams'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="جزئیات تیکت"
|
||||||
|
title-en="Ticket Details"
|
||||||
|
width="95%"
|
||||||
|
max-width="64rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="ticket-details">
|
<div class="ticket-details">
|
||||||
<LineTitleBlock title="جزئیات تیکت" title-en="Ticket Details" />
|
|
||||||
|
|
||||||
<div v-if="ticket" class="ticket-details__date">
|
<div v-if="ticket" class="ticket-details__date">
|
||||||
<span>{{ ticketDate }}</span>
|
<span>{{ ticketDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +87,6 @@ import BasicModal from '@/components/BasicModal.vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
<LineInfoBlock title="درخواست" :desc="requestText" />
|
<LineInfoBlock title="درخواست" :desc="requestText" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="service-details__divider" />
|
<!-- <div class="service-details__divider" /> -->
|
||||||
|
|
||||||
<footer class="service-details__footer">
|
<!-- <footer class="service-details__footer">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
text="ارسال پیام"
|
text="ارسال پیام"
|
||||||
custom-class="service-details__send-btn"
|
custom-class="service-details__send-btn"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||||
</template>
|
</template>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</footer>
|
</footer> -->
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,18 +41,17 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { SERVICE_TYPE } from '@/enums'
|
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
// import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
// import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||||
import { useAdminServiceQuery } from '@/services/query/admin-services'
|
import { useAdminServiceQuery } from '@/services/query/admin-services'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ServiceDetailsModal' })
|
defineOptions({ name: 'ServiceDetailsModal' })
|
||||||
|
|
||||||
const { openModal, getModal } = useModal()
|
const { getModal } = useModal()
|
||||||
|
|
||||||
const modalData = computed(() => getModal('ServiceDetailsModal')?.data ?? {})
|
const modalData = computed(() => getModal('ServiceDetailsModal')?.data ?? {})
|
||||||
const serviceId = computed(() => modalData.value.id ?? null)
|
const serviceId = computed(() => modalData.value.id ?? null)
|
||||||
@@ -66,9 +65,7 @@ const serviceTitle = computed(
|
|||||||
() => service.value?.subject || service.value?.title || service.value?.serviceTitle || '—'
|
() => service.value?.subject || service.value?.title || service.value?.serviceTitle || '—'
|
||||||
)
|
)
|
||||||
|
|
||||||
const serviceTypeLabel = computed(
|
const serviceTypeLabel = computed(() => service.value?.category || '—')
|
||||||
() => service.value?.typeLabel || SERVICE_TYPE[service.value?.type] || '—'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pre-migration fixtures used `requestText`/`description`; the new schema puts
|
// Pre-migration fixtures used `requestText`/`description`; the new schema puts
|
||||||
// the body inside the first message.
|
// the body inside the first message.
|
||||||
@@ -80,9 +77,9 @@ const requestText = computed(
|
|||||||
'—'
|
'—'
|
||||||
)
|
)
|
||||||
|
|
||||||
const onSendMessage = () => {
|
// const onSendMessage = () => {
|
||||||
openModal('SendMessageModal', { id: serviceId.value })
|
// openModal('SendMessageModal', { id: serviceId.value })
|
||||||
}
|
// }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -112,7 +109,7 @@ const onSendMessage = () => {
|
|||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__send-btn {
|
&__send-btn {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="جزئیات مشاوره"
|
||||||
|
title-en="Consultation Details"
|
||||||
|
width="95%"
|
||||||
|
max-width="64rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="consultation-details">
|
<div class="consultation-details">
|
||||||
<LineTitleBlock title="جزئیات مشاوره" title-en="Consultation Details" />
|
|
||||||
|
|
||||||
<div v-if="consultation" class="consultation-details__date">
|
<div v-if="consultation" class="consultation-details__date">
|
||||||
<span>{{ consultationDate }}</span>
|
<span>{{ consultationDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +60,6 @@ import BasicModal from '@/components/BasicModal.vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
counselorTicketsKeys,
|
counselorTicketsKeys,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="58rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="جزئیات درخواست"
|
||||||
|
title-en="Request details"
|
||||||
|
width="95%"
|
||||||
|
max-width="58rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="sdm">
|
<div class="sdm">
|
||||||
<header class="sdm__header">
|
|
||||||
<LineTitleBlock title="جزئیات درخواست" title-en="Request details" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="2" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="2" :cols-per-row="1" />
|
||||||
|
|
||||||
<template v-else-if="service">
|
<template v-else-if="service">
|
||||||
<div class="sdm__top">
|
<div class="sdm__top">
|
||||||
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
|
||||||
<span class="sdm__date">{{ requestDate }}</span>
|
<span class="sdm__date">{{ requestDate }}</span>
|
||||||
|
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="messages.length > 0" class="sdm__messages">
|
<div v-if="messages.length > 0" class="sdm__messages">
|
||||||
@@ -36,7 +39,6 @@ import useModal from '@/composables/useModal'
|
|||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import { useStudentTicketQuery } from '@/services/query/student-tickets'
|
import { useStudentTicketQuery } from '@/services/query/student-tickets'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|
||||||
@@ -83,15 +85,9 @@ const messageTime = (message) => {
|
|||||||
padding-block: 0.25rem 0.5rem;
|
padding-block: 0.25rem 0.5rem;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__top {
|
&__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -118,7 +114,7 @@ const messageTime = (message) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: #f6f6f6;
|
background: #f6f6f6;
|
||||||
padding: 1rem 1rem 1.5rem;
|
padding: 1rem 1rem 1.5rem;
|
||||||
border-radius: 0.75rem 0.75rem 0 0.75rem;
|
border-radius: 0.75rem 0.75rem 0;
|
||||||
color: #454545;
|
color: #454545;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="status-tile">
|
<button
|
||||||
<div class="status-tile__icon" :style="{ color: iconColor }">
|
type="button"
|
||||||
<SvgIcon :name="icon" :size="32" />
|
class="status-tile"
|
||||||
</div>
|
:class="{ 'status-tile--active': active }"
|
||||||
|
:aria-pressed="active"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<img :src="image" :alt="label" class="status-tile__image" />
|
||||||
<p class="status-tile__label">{{ label }}</p>
|
<p class="status-tile__label">{{ label }}</p>
|
||||||
<p class="status-tile__count" :style="{ color: countColor }">{{ count }}</p>
|
<p class="status-tile__count" :style="{ color: countColor }">{{ count }}</p>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
icon: { type: String, required: true },
|
/** Resolved PNG / image src (use a build-time import, e.g. `import img from '@/assets/...'`). */
|
||||||
|
image: { type: String, required: true },
|
||||||
label: { type: String, required: true },
|
label: { type: String, required: true },
|
||||||
count: { type: [Number, String], default: 0 },
|
count: { type: [Number, String], default: 0 },
|
||||||
/** Hex color for the count number. Defaults to a neutral gray. */
|
/** Hex color for the count number. Defaults to a neutral gray. */
|
||||||
countColor: { type: String, default: '#4C4C4C' },
|
countColor: { type: String, default: '#4C4C4C' },
|
||||||
/** Hex color for the icon. Defaults to a neutral gray. */
|
active: { type: Boolean, default: false },
|
||||||
iconColor: { type: String, default: '#A7A7A7' },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -34,14 +38,34 @@ defineProps({
|
|||||||
background: rgba(255, 255, 255, 40%);
|
background: rgba(255, 255, 255, 40%);
|
||||||
box-shadow: 0 6.75px 19.4px rgba(0, 0, 0, 4%);
|
box-shadow: 0 6.75px 19.4px rgba(0, 0, 0, 4%);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
min-height: 7rem;
|
min-height: 7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
&__icon {
|
&:hover {
|
||||||
display: inline-flex;
|
background: rgba(255, 255, 255, 70%);
|
||||||
align-items: center;
|
}
|
||||||
justify-content: center;
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: rgba(255, 255, 255, 90%);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 22px rgba(243, 102, 117, 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
@@ -51,7 +75,6 @@ defineProps({
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: #4c4c4c;
|
color: #4c4c4c;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__count {
|
||||||
@@ -60,7 +83,6 @@ defineProps({
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ticket-item__meta">
|
<div class="ticket-item__meta">
|
||||||
<Badge variant="neutral" size="sm" label="تاریخ پیام :" :value="createdAt" />
|
<Badge variant="neutral" size="sm" icon="calendar" label="تاریخ ثبت :" :value="createdAt" />
|
||||||
|
<Badge
|
||||||
|
v-if="priorityLabel"
|
||||||
|
:variant="priorityVariant"
|
||||||
|
size="sm"
|
||||||
|
label="اولویت :"
|
||||||
|
:value="priorityLabel"
|
||||||
|
/>
|
||||||
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,11 +44,11 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { TICKET_STATUS } from '@/enums'
|
|
||||||
import Badge from '@/components/Badge.vue'
|
import Badge from '@/components/Badge.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
|
import { TICKET_PRIORITY, TICKET_STATUS } from '@/enums'
|
||||||
|
|
||||||
const STATUS_VARIANT = {
|
const STATUS_VARIANT = {
|
||||||
open: 'warning',
|
open: 'warning',
|
||||||
@@ -49,6 +56,13 @@ const STATUS_VARIANT = {
|
|||||||
closed: 'info',
|
closed: 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRIORITY_VARIANT = {
|
||||||
|
low: 'neutral',
|
||||||
|
normal: 'info',
|
||||||
|
high: 'warning',
|
||||||
|
urgent: 'danger',
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
ticket: { type: Object, required: true },
|
ticket: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
@@ -60,6 +74,9 @@ const userName = computed(() => props.ticket.student?.name || props.ticket.subje
|
|||||||
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
|
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
|
||||||
const statusVariant = computed(() => STATUS_VARIANT[props.ticket.status] || 'neutral')
|
const statusVariant = computed(() => STATUS_VARIANT[props.ticket.status] || 'neutral')
|
||||||
|
|
||||||
|
const priorityLabel = computed(() => TICKET_PRIORITY[props.ticket.priority] || '')
|
||||||
|
const priorityVariant = computed(() => PRIORITY_VARIANT[props.ticket.priority] || 'neutral')
|
||||||
|
|
||||||
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="58rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="ارسال تیکت جدید"
|
||||||
|
title-en="New ticket"
|
||||||
|
width="95%"
|
||||||
|
max-width="58rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default="{ close }">
|
<template #default="{ close }">
|
||||||
<form class="ctm" @submit.prevent="onSubmit(close)">
|
<form class="ctm" @submit.prevent="onSubmit(close)">
|
||||||
<header class="ctm__header">
|
|
||||||
<LineTitleBlock title="ارسال تیکت جدید" title-en="New ticket" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="ctm__row ctm__row--two">
|
<div class="ctm__row ctm__row--two">
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.subject"
|
v-model="form.subject"
|
||||||
@@ -76,6 +79,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import * as yup from 'yup'
|
import * as yup from 'yup'
|
||||||
import { toast } from 'vue3-toastify'
|
import { toast } from 'vue3-toastify'
|
||||||
|
import { TICKET_PRIORITY } from '@/enums'
|
||||||
import useYup from '@/composables/useYup'
|
import useYup from '@/composables/useYup'
|
||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
@@ -83,7 +87,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import FileUploader from '@/components/form/FileUploader.vue'
|
import FileUploader from '@/components/form/FileUploader.vue'
|
||||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||||
@@ -97,16 +100,14 @@ defineOptions({ name: 'CreateTicketModal' })
|
|||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const priorityOptions = [
|
const priorityOptions = Object.entries(TICKET_PRIORITY).map(([value, label]) => ({
|
||||||
{ value: 'very_urgent', label: 'بسیار فوری' },
|
value,
|
||||||
{ value: 'urgent', label: 'فوری' },
|
label,
|
||||||
{ value: 'normal', label: 'عادی' },
|
}))
|
||||||
{ value: 'low', label: 'کم اهمیت' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
subject: '',
|
subject: '',
|
||||||
priority: 'very_urgent',
|
priority: 'normal',
|
||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,9 +160,7 @@ const onSubmit = async (close) => {
|
|||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync({
|
await createMutation.mutateAsync({
|
||||||
type: 'ticket',
|
type: 'ticket',
|
||||||
// Backend POST /student/tickets accepts `category` — we use it to carry
|
priority: payload.priority,
|
||||||
// the FE-only priority value until the backend adds a dedicated field.
|
|
||||||
category: payload.priority,
|
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
mediaIds: files.value.map((f) => f.id).filter((id) => id != null),
|
mediaIds: files.value.map((f) => f.id).filter((id) => id != null),
|
||||||
@@ -169,7 +168,7 @@ const onSubmit = async (close) => {
|
|||||||
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||||
toast.success('تیکت با موفقیت ثبت شد.')
|
toast.success('تیکت با موفقیت ثبت شد.')
|
||||||
resetErrors()
|
resetErrors()
|
||||||
form.value = { subject: '', priority: 'very_urgent', message: '' }
|
form.value = { subject: '', priority: 'normal', message: '' }
|
||||||
files.value = []
|
files.value = []
|
||||||
close?.()
|
close?.()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -186,12 +185,6 @@ const onSubmit = async (close) => {
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
padding-block: 0.25rem 0.5rem;
|
padding-block: 0.25rem 0.5rem;
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row {
|
&__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -8,48 +8,68 @@
|
|||||||
:show-close-button="true"
|
:show-close-button="true"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="tdm">
|
<div class="ticket-details">
|
||||||
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="2" :cols-per-row="1" />
|
<div v-if="ticket" class="ticket-details__date">
|
||||||
|
<span>{{ ticketDate }}</span>
|
||||||
<template v-else-if="ticket">
|
|
||||||
<div class="tdm__top">
|
|
||||||
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
|
||||||
<span class="tdm__date">{{ requestDate }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="messages.length > 0" class="tdm__messages">
|
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
|
||||||
<div v-for="message in messages" :key="message.id" class="tdm__bubble">
|
<div v-else-if="messages.length > 0" ref="thread" class="ticket-details__thread">
|
||||||
<p class="tdm__text">{{ message.message }}</p>
|
<div
|
||||||
<span class="tdm__time">{{ messageTime(message) }}</span>
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="ticket-details__row"
|
||||||
|
:class="`ticket-details__row--${senderClass(message)}`"
|
||||||
|
>
|
||||||
|
<div class="ticket-details__bubble">
|
||||||
|
<p class="ticket-details__text">{{ message.message }}</p>
|
||||||
|
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی در این تیکت ثبت نشده است." />
|
||||||
|
|
||||||
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی برای این تیکت ثبت نشده است." />
|
<div class="ticket-details__divider" />
|
||||||
</template>
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BasicModal>
|
</BasicModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
|
||||||
import { TICKET_STATUS } from '@/enums'
|
|
||||||
import Badge from '@/components/Badge.vue'
|
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import { useStudentTicketQuery } from '@/services/query/student-tickets'
|
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
import {
|
||||||
|
studentTicketsKeys,
|
||||||
|
useSendStudentTicketMessageMutation,
|
||||||
|
useStudentTicketQuery,
|
||||||
|
} from '@/services/query/student-tickets'
|
||||||
|
|
||||||
defineOptions({ name: 'TicketDetailsModal' })
|
defineOptions({ name: 'TicketDetailsModal' })
|
||||||
|
|
||||||
const STATUS_VARIANT = {
|
const queryClient = useQueryClient()
|
||||||
open: 'warning',
|
|
||||||
answered: 'success',
|
|
||||||
closed: 'info',
|
|
||||||
}
|
|
||||||
|
|
||||||
const { getModal } = useModal()
|
const { getModal } = useModal()
|
||||||
|
|
||||||
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
|
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
|
||||||
@@ -61,84 +81,184 @@ const { data: ticket, isLoading } = useStudentTicketQuery(ticketId, {
|
|||||||
|
|
||||||
const messages = computed(() => ticket.value?.messages ?? [])
|
const messages = computed(() => ticket.value?.messages ?? [])
|
||||||
|
|
||||||
const statusLabel = computed(() => TICKET_STATUS[ticket.value?.status] || '—')
|
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
||||||
const statusVariant = computed(() => STATUS_VARIANT[ticket.value?.status] || 'neutral')
|
|
||||||
|
|
||||||
const requestDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
// Student viewing their own ticket — their own messages get the highlighted
|
||||||
|
// (primary-color) `self` bubble; admin/counselor replies get the muted `other`
|
||||||
|
// bubble. The viewer's id matches the ticket's studentId here.
|
||||||
|
const senderClass = (message) => (message.senderId === ticket.value?.studentId ? 'self' : 'other')
|
||||||
|
|
||||||
const messageTime = (message) => {
|
const messageTime = (message) => {
|
||||||
if (!message.createdAt) return ''
|
if (!message.createdAt) return '—'
|
||||||
const d = new Date(message.createdAt)
|
const d = new Date(message.createdAt)
|
||||||
if (Number.isNaN(d.getTime())) return ''
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const canSend = computed(() => text.value.trim().length > 0)
|
||||||
|
|
||||||
|
const sendMutation = useSendStudentTicketMessageMutation()
|
||||||
|
|
||||||
|
const onSend = async () => {
|
||||||
|
if (!canSend.value || !ticketId.value) return
|
||||||
|
const value = text.value.trim()
|
||||||
|
text.value = ''
|
||||||
|
// Backend POST /student/tickets/:id/messages — body is { message: string }.
|
||||||
|
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = ref(null)
|
||||||
|
|
||||||
|
watch(messages, async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (thread.value) {
|
||||||
|
thread.value.scrollTop = thread.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.tdm {
|
.ticket-details {
|
||||||
|
width: 100%;
|
||||||
|
text-align: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
max-height: calc(100dvh - 8rem);
|
||||||
padding-block: 0.25rem 0.5rem;
|
min-height: 28rem;
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__date {
|
&__date {
|
||||||
background: #f5f5f5;
|
display: flex;
|
||||||
color: #a3a3a3;
|
justify-content: center;
|
||||||
border-radius: 9999px;
|
margin: 0.5rem 0 0.875rem;
|
||||||
padding: 0.3rem 0.875rem;
|
|
||||||
font-family: var(--font-family-fa);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__messages {
|
&__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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
padding-block: 0.5rem 1rem;
|
||||||
|
padding-inline-end: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
// `self` (the viewer / student) sits on the visual end (left in RTL) with
|
||||||
|
// the highlighted primary bubble. `other` sits at the start (right in
|
||||||
|
// RTL) with the muted gray bubble.
|
||||||
|
&--self {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--other {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__bubble {
|
&__bubble {
|
||||||
position: relative;
|
max-width: 72%;
|
||||||
background: #f6f6f6;
|
border-radius: 1rem;
|
||||||
padding: 1rem 1rem 1.5rem;
|
padding: 0.75rem 1.25rem;
|
||||||
border-radius: 0.75rem 0.75rem 0;
|
|
||||||
color: #454545;
|
.ticket-details__row--self & {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-end-end-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-details__row--other & {
|
||||||
|
background: #f8f8f8;
|
||||||
|
color: #5d5d5d;
|
||||||
|
border-end-start-radius: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
font-family: var(--font-family-fa);
|
font-family: var(--font-family-fa);
|
||||||
font-weight: 300;
|
font-size: 0.875rem;
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: justify;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__time {
|
&__time {
|
||||||
position: absolute;
|
display: block;
|
||||||
inset-inline-end: 1rem;
|
margin-top: 0.375rem;
|
||||||
inset-block-end: 0.5rem;
|
font-family: var(--font-family-en);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
|
||||||
|
.ticket-details__row--self & {
|
||||||
|
color: rgba(255, 255, 255, 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-details__row--other & {
|
||||||
|
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-family: var(--font-family-fa);
|
||||||
font-size: 0.65rem;
|
font-size: 0.875rem;
|
||||||
color: #a7a7a7;
|
color: #4b4b4b;
|
||||||
line-height: 1.45;
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-thd-gray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
<StatusTile
|
<StatusTile
|
||||||
v-for="tile in statusTiles"
|
v-for="tile in statusTiles"
|
||||||
:key="tile.key"
|
:key="tile.key"
|
||||||
:icon="tile.icon"
|
:image="tile.image"
|
||||||
:label="tile.label"
|
:label="tile.label"
|
||||||
:count="tile.count"
|
:count="tile.count"
|
||||||
:count-color="tile.countColor"
|
:count-color="tile.countColor"
|
||||||
:icon-color="tile.iconColor"
|
:active="activeTile === tile.key"
|
||||||
|
@click="onTileSelect(tile.key)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,9 +67,12 @@ import useModal from '@/composables/useModal'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
|
import chatsPng from '@/assets/images/tickets/chats.png'
|
||||||
import { usePagination } from '@/composables/usePagination'
|
import { usePagination } from '@/composables/usePagination'
|
||||||
|
import chatTextPng from '@/assets/images/tickets/chat-text.png'
|
||||||
|
import chatDotsPng from '@/assets/images/tickets/chat-dots.png'
|
||||||
|
import chatSlashPng from '@/assets/images/tickets/chat-slash.png'
|
||||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||||
import { useStudentTicketsQuery } from '@/services/query/student-tickets'
|
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import StatusTile from '@/features/student/tickets/components/StatusTile.vue'
|
import StatusTile from '@/features/student/tickets/components/StatusTile.vue'
|
||||||
@@ -76,19 +80,35 @@ import TicketItem from '@/features/student/tickets/components/TicketItem.vue'
|
|||||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||||
import CreateTicketModal from '@/features/student/tickets/components/modals/CreateTicketModal.vue'
|
import CreateTicketModal from '@/features/student/tickets/components/modals/CreateTicketModal.vue'
|
||||||
import TicketDetailsModal from '@/features/student/tickets/components/modals/TicketDetailsModal.vue'
|
import TicketDetailsModal from '@/features/student/tickets/components/modals/TicketDetailsModal.vue'
|
||||||
|
import {
|
||||||
|
useStudentTicketsQuery,
|
||||||
|
useStudentTicketStatsQuery,
|
||||||
|
} from '@/services/query/student-tickets'
|
||||||
|
|
||||||
const { openModal, isModal } = useModal()
|
const { openModal, isModal } = useModal()
|
||||||
|
|
||||||
const filters = ref({ type: 'ticket' })
|
const TILE_STATUS = {
|
||||||
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
|
all: '',
|
||||||
|
in_review: 'open',
|
||||||
|
answered: 'answered',
|
||||||
|
closed: 'closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTile = ref('all')
|
||||||
|
|
||||||
|
const filters = ref({ type: 'ticket', status: '' })
|
||||||
|
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
|
const onTileSelect = (key) => {
|
||||||
|
activeTile.value = key
|
||||||
|
filters.value = { type: 'ticket', status: TILE_STATUS[key] || '' }
|
||||||
|
resetPagination()
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading } = useStudentTicketsQuery(filters, pagination, {
|
const { data, isLoading } = useStudentTicketsQuery(filters, pagination, {
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The query select normalizes to { data: Array, meta }. The page also tolerates
|
|
||||||
// a backend that nests under `data.items` (Laravel-style pagination) until the
|
|
||||||
// backend shape is locked in.
|
|
||||||
const tickets = computed(() => {
|
const tickets = computed(() => {
|
||||||
const raw = data.value?.data
|
const raw = data.value?.data
|
||||||
if (Array.isArray(raw)) return raw
|
if (Array.isArray(raw)) return raw
|
||||||
@@ -102,52 +122,37 @@ const paginationMeta = computed(() => ({
|
|||||||
...data.value?.meta,
|
...data.value?.meta,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const statusTiles = computed(() => {
|
const { data: stats } = useStudentTicketStatsQuery({ type: 'ticket' })
|
||||||
const list = tickets.value
|
const statusTiles = computed(() => [
|
||||||
const countBy = (predicate) => list.filter((_el) => predicate(_el)).length
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
key: 'all',
|
key: 'all',
|
||||||
icon: 'list-bullets',
|
image: chatTextPng,
|
||||||
label: 'همه تیکتها',
|
label: 'همه تیکتها',
|
||||||
count: list.length,
|
count: stats.value?.all ?? 0,
|
||||||
iconColor: '#A7A7A7',
|
countColor: '#B6842D',
|
||||||
countColor: '#4C4C4C',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'open',
|
|
||||||
icon: 'chat',
|
|
||||||
label: 'باز',
|
|
||||||
count: countBy((t) => t.status === 'open'),
|
|
||||||
iconColor: '#CC6F00',
|
|
||||||
countColor: '#CC6F00',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'in_review',
|
key: 'in_review',
|
||||||
icon: 'chat-centered-dots',
|
image: chatDotsPng,
|
||||||
label: 'در حال بررسی',
|
label: 'در حال بررسی',
|
||||||
count: countBy((t) => t.status === 'answered' && t.assignee),
|
count: stats.value?.openTickets ?? 0,
|
||||||
iconColor: '#007074',
|
countColor: '#4C4C4C',
|
||||||
countColor: '#007074',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'answered',
|
key: 'answered',
|
||||||
icon: 'check-square',
|
image: chatsPng,
|
||||||
label: 'پاسخ داده شده',
|
label: 'پاسخ داده شده',
|
||||||
count: countBy((t) => t.status === 'answered'),
|
count: stats.value?.answeredTickets ?? 0,
|
||||||
iconColor: '#33BE65',
|
countColor: '#662BB9',
|
||||||
countColor: '#33BE65',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'closed',
|
key: 'closed',
|
||||||
icon: 'close',
|
image: chatSlashPng,
|
||||||
label: 'بسته شده',
|
label: 'بسته شده',
|
||||||
count: countBy((t) => t.status === 'closed'),
|
count: stats.value?.closedTickets ?? 0,
|
||||||
iconColor: '#CC2831',
|
|
||||||
countColor: '#CC2831',
|
countColor: '#CC2831',
|
||||||
},
|
},
|
||||||
]
|
])
|
||||||
})
|
|
||||||
|
|
||||||
const onShowDetails = (ticket) => {
|
const onShowDetails = (ticket) => {
|
||||||
openModal('TicketDetailsModal', { id: ticket.id })
|
openModal('TicketDetailsModal', { id: ticket.id })
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const endpoints = {
|
|||||||
// Unified student tickets — handles services (type=service), consultants
|
// Unified student tickets — handles services (type=service), consultants
|
||||||
// (type=advise) and inbox (type=ticket) via the same endpoint with a filter.
|
// (type=advise) and inbox (type=ticket) via the same endpoint with a filter.
|
||||||
getStudentTickets: '/student/tickets',
|
getStudentTickets: '/student/tickets',
|
||||||
|
getStudentTicketStats: '/student/tickets/stats',
|
||||||
createStudentTicket: '/student/tickets',
|
createStudentTicket: '/student/tickets',
|
||||||
showStudentTicket: '/student/tickets/:id',
|
showStudentTicket: '/student/tickets/:id',
|
||||||
sendStudentTicketMessage: '/student/tickets/:id/messages',
|
sendStudentTicketMessage: '/student/tickets/:id/messages',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { buildUrl, endpoints } from '@/services/api/endpoints'
|
|||||||
|
|
||||||
export const apiGetStudentTickets = (params) => http.get(endpoints.getStudentTickets, { params })
|
export const apiGetStudentTickets = (params) => http.get(endpoints.getStudentTickets, { params })
|
||||||
|
|
||||||
|
export const apiGetStudentTicketStats = (params) =>
|
||||||
|
http.get(endpoints.getStudentTicketStats, { params })
|
||||||
|
|
||||||
export const apiShowStudentTicket = (id) => http.get(buildUrl(endpoints.showStudentTicket, { id }))
|
export const apiShowStudentTicket = (id) => http.get(buildUrl(endpoints.showStudentTicket, { id }))
|
||||||
|
|
||||||
export const apiCreateStudentTicket = (payload) => http.post(endpoints.createStudentTicket, payload)
|
export const apiCreateStudentTicket = (payload) => http.post(endpoints.createStudentTicket, payload)
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ register('GET', endpoints.getStudentTickets, ({ query }) => {
|
|||||||
return { success: true, message: 'OK', data: items, meta }
|
return { success: true, message: 'OK', data: items, meta }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
register('GET', endpoints.getStudentTicketStats, ({ query }) => {
|
||||||
|
const scoped = query.type ? myPool().filter((t) => t.type === query.type) : myPool()
|
||||||
|
const countBy = (status) => scoped.filter((t) => t.status === status).length
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'OK',
|
||||||
|
data: {
|
||||||
|
openTickets: countBy('open'),
|
||||||
|
answeredTickets: countBy('answered'),
|
||||||
|
closedTickets: countBy('closed'),
|
||||||
|
all: scoped.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
register('GET', endpoints.showStudentTicket, ({ params }) => ({
|
register('GET', endpoints.showStudentTicket, ({ params }) => ({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'OK',
|
message: 'OK',
|
||||||
@@ -32,6 +47,7 @@ register('POST', endpoints.createStudentTicket, ({ data }) => {
|
|||||||
assignedToUserId: null,
|
assignedToUserId: null,
|
||||||
type: data.type || 'ticket',
|
type: data.type || 'ticket',
|
||||||
category: data.category || null,
|
category: data.category || null,
|
||||||
|
priority: data.priority || null,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
subject: data.subject || '',
|
subject: data.subject || '',
|
||||||
createdAt: isoNow(),
|
createdAt: isoNow(),
|
||||||
@@ -60,6 +76,7 @@ register('POST', endpoints.createStudentTicket, ({ data }) => {
|
|||||||
assignedToUserId: ticket.assignedToUserId,
|
assignedToUserId: ticket.assignedToUserId,
|
||||||
type: ticket.type,
|
type: ticket.type,
|
||||||
category: ticket.category,
|
category: ticket.category,
|
||||||
|
priority: ticket.priority,
|
||||||
status: ticket.status,
|
status: ticket.status,
|
||||||
subject: ticket.subject,
|
subject: ticket.subject,
|
||||||
createdAt: ticket.createdAt,
|
createdAt: ticket.createdAt,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from '@tanstack/vue-query'
|
|||||||
import {
|
import {
|
||||||
apiCreateStudentTicket,
|
apiCreateStudentTicket,
|
||||||
apiGetStudentTickets,
|
apiGetStudentTickets,
|
||||||
|
apiGetStudentTicketStats,
|
||||||
apiSendStudentTicketMessage,
|
apiSendStudentTicketMessage,
|
||||||
apiShowStudentTicket,
|
apiShowStudentTicket,
|
||||||
} from '@/services/api/student-tickets'
|
} from '@/services/api/student-tickets'
|
||||||
@@ -12,6 +13,7 @@ export const studentTicketsKeys = {
|
|||||||
byType: (type) => ['student', 'tickets', 'type', type],
|
byType: (type) => ['student', 'tickets', 'type', type],
|
||||||
list: (filters, pagination) => ['student', 'tickets', 'list', filters, pagination],
|
list: (filters, pagination) => ['student', 'tickets', 'list', filters, pagination],
|
||||||
detail: (id) => ['student', 'tickets', 'detail', id],
|
detail: (id) => ['student', 'tickets', 'detail', id],
|
||||||
|
stats: (params) => ['student', 'tickets', 'stats', params],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStudentTicketsQuery = (filtersRef, paginationRef, options = {}) =>
|
export const useStudentTicketsQuery = (filtersRef, paginationRef, options = {}) =>
|
||||||
@@ -34,6 +36,17 @@ export const useStudentTicketQuery = (idRef, options = {}) =>
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Backend returns `{ open_tickets, answered_tickets, closed_tickets, all }` —
|
||||||
|
// camelized on the wire to `{ openTickets, answeredTickets, closedTickets, all }`.
|
||||||
|
// Pass `{ type: 'ticket' | 'service' | 'advise' }` to scope the counts.
|
||||||
|
export const useStudentTicketStatsQuery = (params = {}, options = {}) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: studentTicketsKeys.stats(params),
|
||||||
|
queryFn: () => apiGetStudentTicketStats(params),
|
||||||
|
select: (response) => response?.data ?? response,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
export const useCreateStudentTicketMutation = () =>
|
export const useCreateStudentTicketMutation = () =>
|
||||||
useMutation({ mutationFn: (payload) => apiCreateStudentTicket(payload) })
|
useMutation({ mutationFn: (payload) => apiCreateStudentTicket(payload) })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user