fix: tickets
Deploy banu-front / deploy (push) Failing after 16s

This commit is contained in:
sajjadtalkhabi
2026-06-05 19:42:09 +03:30
parent ea562aeefa
commit b8ccbcc030
24 changed files with 447 additions and 222 deletions
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

+3
View File
@@ -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'
+9
View File
@@ -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: 'تکمیل شده',
@@ -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>
</div>
<template v-else-if="ticket"> <SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
<div class="tdm__top"> <div v-else-if="messages.length > 0" ref="thread" class="ticket-details__thread">
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" /> <div
<span class="tdm__date">{{ requestDate }}</span> v-for="message in messages"
</div> :key="message.id"
class="ticket-details__row"
<div v-if="messages.length > 0" class="tdm__messages"> :class="`ticket-details__row--${senderClass(message)}`"
<div v-for="message in messages" :key="message.id" class="tdm__bubble"> >
<p class="tdm__text">{{ message.message }}</p> <div class="ticket-details__bubble">
<span class="tdm__time">{{ messageTime(message) }}</span> <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',
{ image: chatTextPng,
key: 'all', label: 'همه تیکت‌ها',
icon: 'list-bullets', count: stats.value?.all ?? 0,
label: 'همه تیکت‌ها', countColor: '#B6842D',
count: list.length, },
iconColor: '#A7A7A7', {
countColor: '#4C4C4C', key: 'in_review',
}, image: chatDotsPng,
{ label: 'در حال بررسی',
key: 'open', count: stats.value?.openTickets ?? 0,
icon: 'chat', countColor: '#4C4C4C',
label: 'باز', },
count: countBy((t) => t.status === 'open'), {
iconColor: '#CC6F00', key: 'answered',
countColor: '#CC6F00', image: chatsPng,
}, label: 'پاسخ داده شده',
{ count: stats.value?.answeredTickets ?? 0,
key: 'in_review', countColor: '#662BB9',
icon: 'chat-centered-dots', },
label: 'در حال بررسی', {
count: countBy((t) => t.status === 'answered' && t.assignee), key: 'closed',
iconColor: '#007074', image: chatSlashPng,
countColor: '#007074', label: 'بسته شده',
}, count: stats.value?.closedTickets ?? 0,
{ countColor: '#CC2831',
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 onShowDetails = (ticket) => { const onShowDetails = (ticket) => {
openModal('TicketDetailsModal', { id: ticket.id }) openModal('TicketDetailsModal', { id: ticket.id })
+1
View File
@@ -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
View File
@@ -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,
+13
View File
@@ -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) })