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'
|
||||
| 'chat'
|
||||
| 'chat-centered-dots'
|
||||
| 'chat-slash'
|
||||
| 'chat-text'
|
||||
| 'chats'
|
||||
| 'check'
|
||||
| 'check-square'
|
||||
| 'close'
|
||||
|
||||
@@ -175,6 +175,15 @@ export const TICKET_STATUS = Object.freeze({
|
||||
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({
|
||||
active: 'در حال گذراندن',
|
||||
completed: 'تکمیل شده',
|
||||
|
||||
+8
-4
@@ -1,9 +1,14 @@
|
||||
<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 }">
|
||||
<div class="submission-details">
|
||||
<LineTitleBlock title="جزئیات تکلیف" title-en="Submission Details" />
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
||||
<template v-else-if="submission">
|
||||
<div class="submission-details__person">
|
||||
@@ -136,7 +141,6 @@ import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<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>
|
||||
<div class="consultation-details">
|
||||
<LineTitleBlock title="جزئیات مشاوره" title-en="Consultation Details" />
|
||||
|
||||
<div v-if="consultation" class="consultation-details__date">
|
||||
<span>{{ consultationDate }}</span>
|
||||
</div>
|
||||
@@ -55,7 +60,6 @@ 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 LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
adminConsultationsKeys,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<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 }">
|
||||
<form class="offered-course" @submit.prevent="onSubmit(close)">
|
||||
<LineTitleBlock :title="modeTitle" title-en="Offered Course" />
|
||||
|
||||
<div class="offered-course__grid">
|
||||
<div class="offered-course__image-col">
|
||||
<ImageCropper
|
||||
@@ -115,7 +120,6 @@ import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||
import ImageCropper from '@/components/form/ImageCropper.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<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 }">
|
||||
<div class="participant-details">
|
||||
<LineTitleBlock title="شرکت کنندگان" title-en="Participants" />
|
||||
|
||||
<p v-if="participantName" class="participant-details__name">{{ participantName }}</p>
|
||||
|
||||
<div class="participant-details__card">
|
||||
@@ -104,7 +109,6 @@ import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { useAdminExamParticipantQuery } from '@/services/query/admin-exams'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<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 }">
|
||||
<div class="exam-participants">
|
||||
<LineTitleBlock title="شرکتکنندگان" title-en="Participants" />
|
||||
<p v-if="data?.title" class="exam-participants__exam">{{ data.title }}</p>
|
||||
|
||||
<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 { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import { useAdminExamAttemptsQuery } from '@/services/query/admin-exams'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<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>
|
||||
<div class="ticket-details">
|
||||
<LineTitleBlock title="جزئیات تیکت" title-en="Ticket Details" />
|
||||
|
||||
<div v-if="ticket" class="ticket-details__date">
|
||||
<span>{{ ticketDate }}</span>
|
||||
</div>
|
||||
@@ -82,7 +87,6 @@ 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 LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<LineInfoBlock title="درخواست" :desc="requestText" />
|
||||
</div>
|
||||
|
||||
<div class="service-details__divider" />
|
||||
<!-- <div class="service-details__divider" /> -->
|
||||
|
||||
<footer class="service-details__footer">
|
||||
<!-- <footer class="service-details__footer">
|
||||
<BaseButton
|
||||
text="ارسال پیام"
|
||||
custom-class="service-details__send-btn"
|
||||
@@ -32,7 +32,7 @@
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</footer>
|
||||
</footer> -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,18 +41,17 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { SERVICE_TYPE } from '@/enums'
|
||||
import useModal from '@/composables/useModal'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
// import BaseButton from '@/components/BaseButton.vue'
|
||||
// import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
import { useAdminServiceQuery } from '@/services/query/admin-services'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
|
||||
defineOptions({ name: 'ServiceDetailsModal' })
|
||||
|
||||
const { openModal, getModal } = useModal()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('ServiceDetailsModal')?.data ?? {})
|
||||
const serviceId = computed(() => modalData.value.id ?? null)
|
||||
@@ -66,9 +65,7 @@ const serviceTitle = computed(
|
||||
() => service.value?.subject || service.value?.title || service.value?.serviceTitle || '—'
|
||||
)
|
||||
|
||||
const serviceTypeLabel = computed(
|
||||
() => service.value?.typeLabel || SERVICE_TYPE[service.value?.type] || '—'
|
||||
)
|
||||
const serviceTypeLabel = computed(() => service.value?.category || '—')
|
||||
|
||||
// Pre-migration fixtures used `requestText`/`description`; the new schema puts
|
||||
// the body inside the first message.
|
||||
@@ -80,9 +77,9 @@ const requestText = computed(
|
||||
'—'
|
||||
)
|
||||
|
||||
const onSendMessage = () => {
|
||||
openModal('SendMessageModal', { id: serviceId.value })
|
||||
}
|
||||
// const onSendMessage = () => {
|
||||
// openModal('SendMessageModal', { id: serviceId.value })
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -112,7 +109,7 @@ const onSendMessage = () => {
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__send-btn {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<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>
|
||||
<div class="consultation-details">
|
||||
<LineTitleBlock title="جزئیات مشاوره" title-en="Consultation Details" />
|
||||
|
||||
<div v-if="consultation" class="consultation-details__date">
|
||||
<span>{{ consultationDate }}</span>
|
||||
</div>
|
||||
@@ -55,7 +60,6 @@ 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 LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
counselorTicketsKeys,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<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>
|
||||
<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" />
|
||||
|
||||
<template v-else-if="service">
|
||||
<div class="sdm__top">
|
||||
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||
<span class="sdm__date">{{ requestDate }}</span>
|
||||
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||
</div>
|
||||
|
||||
<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 NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { useStudentTicketQuery } from '@/services/query/student-tickets'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
|
||||
@@ -83,15 +85,9 @@ const messageTime = (message) => {
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
text-align: start;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
@@ -118,7 +114,7 @@ const messageTime = (message) => {
|
||||
position: relative;
|
||||
background: #f6f6f6;
|
||||
padding: 1rem 1rem 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0.75rem;
|
||||
border-radius: 0.75rem 0.75rem 0;
|
||||
color: #454545;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<template>
|
||||
<div class="status-tile">
|
||||
<div class="status-tile__icon" :style="{ color: iconColor }">
|
||||
<SvgIcon :name="icon" :size="32" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="status-tile"
|
||||
: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__count" :style="{ color: countColor }">{{ count }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
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 },
|
||||
count: { type: [Number, String], default: 0 },
|
||||
/** Hex color for the count number. Defaults to a neutral gray. */
|
||||
countColor: { type: String, default: '#4C4C4C' },
|
||||
/** Hex color for the icon. Defaults to a neutral gray. */
|
||||
iconColor: { type: String, default: '#A7A7A7' },
|
||||
active: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -34,14 +38,34 @@ defineProps({
|
||||
background: rgba(255, 255, 255, 40%);
|
||||
box-shadow: 0 6.75px 19.4px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 70%);
|
||||
}
|
||||
|
||||
&: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;
|
||||
height: 2.5rem;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
@@ -51,7 +75,6 @@ defineProps({
|
||||
line-height: 1.25;
|
||||
color: #4c4c4c;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__count {
|
||||
@@ -60,7 +83,6 @@ defineProps({
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
@@ -37,11 +44,11 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { TICKET_PRIORITY, TICKET_STATUS } from '@/enums'
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'warning',
|
||||
@@ -49,6 +56,13 @@ const STATUS_VARIANT = {
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const PRIORITY_VARIANT = {
|
||||
low: 'neutral',
|
||||
normal: 'info',
|
||||
high: 'warning',
|
||||
urgent: 'danger',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
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 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) || '—')
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<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 }">
|
||||
<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">
|
||||
<TextField
|
||||
v-model="form.subject"
|
||||
@@ -76,6 +79,7 @@
|
||||
import { ref } from 'vue'
|
||||
import * as yup from 'yup'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { TICKET_PRIORITY } from '@/enums'
|
||||
import useYup from '@/composables/useYup'
|
||||
import BasicModal from '@/components/BasicModal.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 TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import FileUploader from '@/components/form/FileUploader.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
@@ -97,16 +100,14 @@ defineOptions({ name: 'CreateTicketModal' })
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'very_urgent', label: 'بسیار فوری' },
|
||||
{ value: 'urgent', label: 'فوری' },
|
||||
{ value: 'normal', label: 'عادی' },
|
||||
{ value: 'low', label: 'کم اهمیت' },
|
||||
]
|
||||
const priorityOptions = Object.entries(TICKET_PRIORITY).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
const form = ref({
|
||||
subject: '',
|
||||
priority: 'very_urgent',
|
||||
priority: 'normal',
|
||||
message: '',
|
||||
})
|
||||
|
||||
@@ -159,9 +160,7 @@ const onSubmit = async (close) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
type: 'ticket',
|
||||
// Backend POST /student/tickets accepts `category` — we use it to carry
|
||||
// the FE-only priority value until the backend adds a dedicated field.
|
||||
category: payload.priority,
|
||||
priority: payload.priority,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
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 })
|
||||
toast.success('تیکت با موفقیت ثبت شد.')
|
||||
resetErrors()
|
||||
form.value = { subject: '', priority: 'very_urgent', message: '' }
|
||||
form.value = { subject: '', priority: 'normal', message: '' }
|
||||
files.value = []
|
||||
close?.()
|
||||
} catch {
|
||||
@@ -186,12 +185,6 @@ const onSubmit = async (close) => {
|
||||
text-align: start;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -8,48 +8,68 @@
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default>
|
||||
<div class="tdm">
|
||||
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="2" :cols-per-row="1" />
|
||||
<div class="ticket-details">
|
||||
<div v-if="ticket" class="ticket-details__date">
|
||||
<span>{{ ticketDate }}</span>
|
||||
</div>
|
||||
|
||||
<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 v-if="messages.length > 0" class="tdm__messages">
|
||||
<div v-for="message in messages" :key="message.id" class="tdm__bubble">
|
||||
<p class="tdm__text">{{ message.message }}</p>
|
||||
<span class="tdm__time">{{ messageTime(message) }}</span>
|
||||
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="messages.length > 0" ref="thread" class="ticket-details__thread">
|
||||
<div
|
||||
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>
|
||||
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی در این تیکت ثبت نشده است." />
|
||||
|
||||
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی برای این تیکت ثبت نشده است." />
|
||||
</template>
|
||||
<div class="ticket-details__divider" />
|
||||
|
||||
<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>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
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 SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { useStudentTicketQuery } from '@/services/query/student-tickets'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
studentTicketsKeys,
|
||||
useSendStudentTicketMessageMutation,
|
||||
useStudentTicketQuery,
|
||||
} from '@/services/query/student-tickets'
|
||||
|
||||
defineOptions({ name: 'TicketDetailsModal' })
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'warning',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
|
||||
@@ -61,84 +81,184 @@ const { data: ticket, isLoading } = useStudentTicketQuery(ticketId, {
|
||||
|
||||
const messages = computed(() => ticket.value?.messages ?? [])
|
||||
|
||||
const statusLabel = computed(() => TICKET_STATUS[ticket.value?.status] || '—')
|
||||
const statusVariant = computed(() => STATUS_VARIANT[ticket.value?.status] || 'neutral')
|
||||
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
||||
|
||||
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) => {
|
||||
if (!message.createdAt) return ''
|
||||
if (!message.createdAt) return '—'
|
||||
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' })
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tdm {
|
||||
.ticket-details {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
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;
|
||||
}
|
||||
max-height: calc(100dvh - 8rem);
|
||||
min-height: 28rem;
|
||||
|
||||
&__date {
|
||||
background: #f5f5f5;
|
||||
color: #a3a3a3;
|
||||
border-radius: 9999px;
|
||||
padding: 0.3rem 0.875rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.5rem 0 0.875rem;
|
||||
}
|
||||
|
||||
&__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;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
position: relative;
|
||||
background: #f6f6f6;
|
||||
padding: 1rem 1rem 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0;
|
||||
color: #454545;
|
||||
max-width: 72%;
|
||||
border-radius: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
|
||||
.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 {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
text-align: justify;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
&__time {
|
||||
position: absolute;
|
||||
inset-inline-end: 1rem;
|
||||
inset-block-end: 0.5rem;
|
||||
display: block;
|
||||
margin-top: 0.375rem;
|
||||
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-size: 0.65rem;
|
||||
color: #a7a7a7;
|
||||
line-height: 1.45;
|
||||
font-size: 0.875rem;
|
||||
color: #4b4b4b;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
<StatusTile
|
||||
v-for="tile in statusTiles"
|
||||
:key="tile.key"
|
||||
:icon="tile.icon"
|
||||
:image="tile.image"
|
||||
:label="tile.label"
|
||||
:count="tile.count"
|
||||
:count-color="tile.countColor"
|
||||
:icon-color="tile.iconColor"
|
||||
:active="activeTile === tile.key"
|
||||
@click="onTileSelect(tile.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,9 +67,12 @@ import useModal from '@/composables/useModal'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import chatsPng from '@/assets/images/tickets/chats.png'
|
||||
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 { useStudentTicketsQuery } from '@/services/query/student-tickets'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.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 CreateTicketModal from '@/features/student/tickets/components/modals/CreateTicketModal.vue'
|
||||
import TicketDetailsModal from '@/features/student/tickets/components/modals/TicketDetailsModal.vue'
|
||||
import {
|
||||
useStudentTicketsQuery,
|
||||
useStudentTicketStatsQuery,
|
||||
} from '@/services/query/student-tickets'
|
||||
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const filters = ref({ type: 'ticket' })
|
||||
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
|
||||
const TILE_STATUS = {
|
||||
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, {
|
||||
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 raw = data.value?.data
|
||||
if (Array.isArray(raw)) return raw
|
||||
@@ -102,52 +122,37 @@ const paginationMeta = computed(() => ({
|
||||
...data.value?.meta,
|
||||
}))
|
||||
|
||||
const statusTiles = computed(() => {
|
||||
const list = tickets.value
|
||||
const countBy = (predicate) => list.filter((_el) => predicate(_el)).length
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
icon: 'list-bullets',
|
||||
label: 'همه تیکتها',
|
||||
count: list.length,
|
||||
iconColor: '#A7A7A7',
|
||||
countColor: '#4C4C4C',
|
||||
},
|
||||
{
|
||||
key: 'open',
|
||||
icon: 'chat',
|
||||
label: 'باز',
|
||||
count: countBy((t) => t.status === 'open'),
|
||||
iconColor: '#CC6F00',
|
||||
countColor: '#CC6F00',
|
||||
},
|
||||
{
|
||||
key: 'in_review',
|
||||
icon: 'chat-centered-dots',
|
||||
label: 'در حال بررسی',
|
||||
count: countBy((t) => t.status === 'answered' && t.assignee),
|
||||
iconColor: '#007074',
|
||||
countColor: '#007074',
|
||||
},
|
||||
{
|
||||
key: 'answered',
|
||||
icon: 'check-square',
|
||||
label: 'پاسخ داده شده',
|
||||
count: countBy((t) => t.status === 'answered'),
|
||||
iconColor: '#33BE65',
|
||||
countColor: '#33BE65',
|
||||
},
|
||||
{
|
||||
key: 'closed',
|
||||
icon: 'close',
|
||||
label: 'بسته شده',
|
||||
count: countBy((t) => t.status === 'closed'),
|
||||
iconColor: '#CC2831',
|
||||
countColor: '#CC2831',
|
||||
},
|
||||
]
|
||||
})
|
||||
const { data: stats } = useStudentTicketStatsQuery({ type: 'ticket' })
|
||||
const statusTiles = computed(() => [
|
||||
{
|
||||
key: 'all',
|
||||
image: chatTextPng,
|
||||
label: 'همه تیکتها',
|
||||
count: stats.value?.all ?? 0,
|
||||
countColor: '#B6842D',
|
||||
},
|
||||
{
|
||||
key: 'in_review',
|
||||
image: chatDotsPng,
|
||||
label: 'در حال بررسی',
|
||||
count: stats.value?.openTickets ?? 0,
|
||||
countColor: '#4C4C4C',
|
||||
},
|
||||
{
|
||||
key: 'answered',
|
||||
image: chatsPng,
|
||||
label: 'پاسخ داده شده',
|
||||
count: stats.value?.answeredTickets ?? 0,
|
||||
countColor: '#662BB9',
|
||||
},
|
||||
{
|
||||
key: 'closed',
|
||||
image: chatSlashPng,
|
||||
label: 'بسته شده',
|
||||
count: stats.value?.closedTickets ?? 0,
|
||||
countColor: '#CC2831',
|
||||
},
|
||||
])
|
||||
|
||||
const onShowDetails = (ticket) => {
|
||||
openModal('TicketDetailsModal', { id: ticket.id })
|
||||
|
||||
@@ -38,6 +38,7 @@ export const endpoints = {
|
||||
// Unified student tickets — handles services (type=service), consultants
|
||||
// (type=advise) and inbox (type=ticket) via the same endpoint with a filter.
|
||||
getStudentTickets: '/student/tickets',
|
||||
getStudentTicketStats: '/student/tickets/stats',
|
||||
createStudentTicket: '/student/tickets',
|
||||
showStudentTicket: '/student/tickets/:id',
|
||||
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 apiGetStudentTicketStats = (params) =>
|
||||
http.get(endpoints.getStudentTicketStats, { params })
|
||||
|
||||
export const apiShowStudentTicket = (id) => http.get(buildUrl(endpoints.showStudentTicket, { id }))
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
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 }) => ({
|
||||
success: true,
|
||||
message: 'OK',
|
||||
@@ -32,6 +47,7 @@ register('POST', endpoints.createStudentTicket, ({ data }) => {
|
||||
assignedToUserId: null,
|
||||
type: data.type || 'ticket',
|
||||
category: data.category || null,
|
||||
priority: data.priority || null,
|
||||
status: 'open',
|
||||
subject: data.subject || '',
|
||||
createdAt: isoNow(),
|
||||
@@ -60,6 +76,7 @@ register('POST', endpoints.createStudentTicket, ({ data }) => {
|
||||
assignedToUserId: ticket.assignedToUserId,
|
||||
type: ticket.type,
|
||||
category: ticket.category,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
subject: ticket.subject,
|
||||
createdAt: ticket.createdAt,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
apiCreateStudentTicket,
|
||||
apiGetStudentTickets,
|
||||
apiGetStudentTicketStats,
|
||||
apiSendStudentTicketMessage,
|
||||
apiShowStudentTicket,
|
||||
} from '@/services/api/student-tickets'
|
||||
@@ -12,6 +13,7 @@ export const studentTicketsKeys = {
|
||||
byType: (type) => ['student', 'tickets', 'type', type],
|
||||
list: (filters, pagination) => ['student', 'tickets', 'list', filters, pagination],
|
||||
detail: (id) => ['student', 'tickets', 'detail', id],
|
||||
stats: (params) => ['student', 'tickets', 'stats', params],
|
||||
}
|
||||
|
||||
export const useStudentTicketsQuery = (filtersRef, paginationRef, options = {}) =>
|
||||
@@ -34,6 +36,17 @@ export const useStudentTicketQuery = (idRef, 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 = () =>
|
||||
useMutation({ mutationFn: (payload) => apiCreateStudentTicket(payload) })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user