Compare commits
2 Commits
1c665830b0
...
b8ccbcc030
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ccbcc030 | |||
| ea562aeefa |
@@ -15,6 +15,7 @@ import { useGetMeQuery } from '@/services/query/auth'
|
||||
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||
import StudentLayout from '@/layouts/StudentLayout.vue'
|
||||
import ConfirmModal from '@/components/ConfirmModal.vue'
|
||||
import CounselorLayout from '@/layouts/CounselorLayout.vue'
|
||||
import { tokenService } from '@/services/api/token-service'
|
||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||
|
||||
@@ -24,6 +25,7 @@ const layouts = {
|
||||
public: PublicLayout,
|
||||
admin: AdminLayout,
|
||||
student: StudentLayout,
|
||||
counselor: CounselorLayout,
|
||||
}
|
||||
|
||||
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
|
||||
|
||||
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'
|
||||
|
||||
+13
-13
@@ -165,12 +165,25 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
|
||||
optional: 'اختیاری',
|
||||
})
|
||||
|
||||
// Single ticket-lifecycle vocabulary. Used by every ticket-like resource —
|
||||
// admin /messages, admin /services, admin /consultations, counselor, student
|
||||
// /my-tickets, /user/services, /user/consultants. Backend exposes exactly
|
||||
// these three values across all of them.
|
||||
export const TICKET_STATUS = Object.freeze({
|
||||
open: 'باز',
|
||||
answered: 'پاسخ داده شده',
|
||||
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: 'تکمیل شده',
|
||||
@@ -182,22 +195,9 @@ export const PRIORITY_STATUS = Object.freeze({
|
||||
low: 'پایین',
|
||||
})
|
||||
|
||||
export const SERVICE_STATUS = Object.freeze({
|
||||
approved: 'تایید شده',
|
||||
pending: 'در انتظار بررسی',
|
||||
in_progress: 'در حال انجام',
|
||||
completed: 'تکمیل شده',
|
||||
})
|
||||
|
||||
export const SERVICE_TYPE = Object.freeze({
|
||||
loan: 'وام',
|
||||
consultation: 'مشاوره',
|
||||
educational: 'آموزشی',
|
||||
other: 'سایر',
|
||||
})
|
||||
|
||||
export const CONSULTATION_STATUS = Object.freeze({
|
||||
open: 'در حال گفتگو',
|
||||
answered: 'پاسخ داده شده',
|
||||
closed: 'بسته شده',
|
||||
})
|
||||
|
||||
+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'
|
||||
|
||||
@@ -17,19 +17,15 @@
|
||||
</div>
|
||||
|
||||
<div class="consultation-item__meta">
|
||||
<div class="consultation-item__pill">
|
||||
<span class="consultation-item__pill-label">تاریخ پیام :</span>
|
||||
<span class="consultation-item__pill-value">{{ createdAt }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="consultation-item__status"
|
||||
:class="`consultation-item__status--${consultation.status || 'open'}`"
|
||||
<Badge variant="neutral" size="sm" label="تاریخ پیام :" :value="createdAt" />
|
||||
<Badge
|
||||
:variant="statusVariant"
|
||||
size="sm"
|
||||
dot
|
||||
clickable
|
||||
:value="statusLabel"
|
||||
@click="onStatusClick"
|
||||
>
|
||||
<span class="consultation-item__status-dot" />
|
||||
<span>{{ statusLabel }}</span>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="consultation-item__actions">
|
||||
@@ -55,12 +51,19 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { CONSULTATION_STATUS } from '@/enums'
|
||||
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 DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'primary',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
consultation: { type: Object, required: true },
|
||||
})
|
||||
@@ -69,7 +72,9 @@ const emit = defineEmits(['show-details', 'change-status'])
|
||||
|
||||
const userName = computed(() => props.consultation.student?.name || '—')
|
||||
|
||||
const statusLabel = computed(() => CONSULTATION_STATUS[props.consultation.status] || '—')
|
||||
const statusLabel = computed(() => TICKET_STATUS[props.consultation.status] || '—')
|
||||
|
||||
const statusVariant = computed(() => STATUS_VARIANT[props.consultation.status] || 'neutral')
|
||||
|
||||
const createdAt = computed(() => formatJalaaliDate(props.consultation.createdAt) || '—')
|
||||
|
||||
@@ -87,7 +92,7 @@ const onStatusClick = (event) => {
|
||||
}
|
||||
|
||||
const menuItems = computed(() =>
|
||||
Object.entries(CONSULTATION_STATUS)
|
||||
Object.entries(TICKET_STATUS)
|
||||
.filter(([value]) => value !== props.consultation.status)
|
||||
.map(([value, label]) => ({
|
||||
key: `status-${value}`,
|
||||
@@ -183,72 +188,6 @@ const menuItems = computed(() =>
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.7rem;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
border: none;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
&--answered {
|
||||
background: rgba(0, 154, 18, 8%);
|
||||
color: #009a12;
|
||||
}
|
||||
|
||||
&--open {
|
||||
background: rgba(0, 112, 116, 10%);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: rgba(104, 104, 104, 10%);
|
||||
color: #686868;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CONSULTATION_STATUS } from '@/enums'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
@@ -76,7 +76,7 @@ watch(
|
||||
|
||||
const todayIso = new Date().toISOString()
|
||||
|
||||
const statusOptions = Object.entries(CONSULTATION_STATUS).map(([value, label]) => ({
|
||||
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,14 +62,14 @@ import {
|
||||
const { openModal, isModal } = useModal()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const filters = ref({ type: 'advise', userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data, isLoading } = useAdminConsultationsListQuery(filters, pagination, {
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
const consultations = computed(() => data.value?.data ?? [])
|
||||
const consultations = computed(() => data.value?.data?.items ?? [])
|
||||
const paginationMeta = computed(() => ({
|
||||
page: pagination.value.page,
|
||||
perPage: pagination.value.perPage,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -14,17 +14,9 @@
|
||||
</div>
|
||||
|
||||
<div class="ticket-item__meta">
|
||||
<span class="ticket-item__status" :class="`ticket-item__status--${ticket.status || 'open'}`">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<div class="ticket-item__pill">
|
||||
<span class="ticket-item__pill-label">تاریخ ثبت:</span>
|
||||
<span class="ticket-item__pill-value">{{ createdAt }}</span>
|
||||
</div>
|
||||
<div v-if="createdTime" class="ticket-item__pill">
|
||||
<span class="ticket-item__pill-label">ساعت:</span>
|
||||
<span class="ticket-item__pill-value">{{ createdTime }}</span>
|
||||
</div>
|
||||
<Badge :variant="statusVariant" size="sm" :value="statusLabel" />
|
||||
<Badge variant="neutral" size="sm" label="تاریخ ثبت:" :value="createdAt" />
|
||||
<Badge v-if="createdTime" variant="neutral" size="sm" label="ساعت:" :value="createdTime" />
|
||||
</div>
|
||||
|
||||
<div class="ticket-item__actions">
|
||||
@@ -44,10 +36,17 @@
|
||||
<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'
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'warning',
|
||||
answered: 'primary',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
ticket: { type: Object, required: true },
|
||||
})
|
||||
@@ -58,6 +57,8 @@ const userName = computed(() => props.ticket.student?.name || '—')
|
||||
|
||||
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
|
||||
|
||||
const statusVariant = computed(() => STATUS_VARIANT[props.ticket.status] || 'neutral')
|
||||
|
||||
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
||||
|
||||
const createdTime = computed(() => {
|
||||
@@ -147,53 +148,6 @@ const createdTime = computed(() => {
|
||||
flex: 1 1 41%;
|
||||
}
|
||||
|
||||
&__status {
|
||||
padding: 0.25rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&--open {
|
||||
background: rgba(204, 154, 40, 8%);
|
||||
color: #cc6f00;
|
||||
}
|
||||
|
||||
&--answered {
|
||||
background: rgba(0, 112, 116, 8%);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: rgba(104, 104, 104, 8%);
|
||||
color: #686868;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill {
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.7rem;
|
||||
color: #535353;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -52,7 +52,7 @@ import TicketDetailsModal from '@/features/admin/messages/components/modals/Tick
|
||||
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const filters = ref({ type: 'ticket', userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data, isLoading } = useAdminTicketsListQuery(filters, pagination, {
|
||||
|
||||
@@ -17,25 +17,16 @@
|
||||
</div>
|
||||
|
||||
<div class="service-item__meta">
|
||||
<div class="service-item__pill">
|
||||
<SvgIcon name="file" :size="14" color="#535353" />
|
||||
<span class="service-item__pill-label">نوع خدمت :</span>
|
||||
<span class="service-item__pill-value">{{ typeLabel }}</span>
|
||||
</div>
|
||||
<div class="service-item__pill">
|
||||
<SvgIcon name="calendar" :size="14" color="#535353" />
|
||||
<span class="service-item__pill-label">تاریخ پیام :</span>
|
||||
<span class="service-item__pill-value">{{ createdAt }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="service-item__status"
|
||||
:class="`service-item__status--${service.status || 'pending'}`"
|
||||
<Badge variant="neutral" size="sm" icon="file" label="نوع خدمت :" :value="typeLabel" />
|
||||
<Badge variant="neutral" size="sm" icon="calendar" label="تاریخ پیام :" :value="createdAt" />
|
||||
<Badge
|
||||
:variant="statusVariant"
|
||||
size="sm"
|
||||
dot
|
||||
clickable
|
||||
:value="statusLabel"
|
||||
@click="onStatusClick"
|
||||
>
|
||||
<span class="service-item__status-dot" />
|
||||
<span>{{ statusLabel }}</span>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="service-item__actions">
|
||||
@@ -61,12 +52,23 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums'
|
||||
import { SERVICE_TYPE, TICKET_STATUS } from '@/enums'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'warning',
|
||||
answered: 'primary',
|
||||
closed: 'info',
|
||||
approved: 'success',
|
||||
pending: 'warning',
|
||||
in_progress: 'primary',
|
||||
completed: 'info',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
service: { type: Object, required: true },
|
||||
})
|
||||
@@ -82,9 +84,11 @@ const userName = computed(() => {
|
||||
const typeLabel = computed(() => props.service.typeLabel || SERVICE_TYPE[props.service.type] || '—')
|
||||
|
||||
const statusLabel = computed(
|
||||
() => props.service.statusLabel || SERVICE_STATUS[props.service.status] || '—'
|
||||
() => props.service.statusLabel || TICKET_STATUS[props.service.status] || '—'
|
||||
)
|
||||
|
||||
const statusVariant = computed(() => STATUS_VARIANT[props.service.status] || 'neutral')
|
||||
|
||||
const createdAt = computed(() => {
|
||||
if (props.service.faCreatedAt) {
|
||||
return props.service.faCreatedTime
|
||||
@@ -108,7 +112,7 @@ const onStatusClick = (event) => {
|
||||
}
|
||||
|
||||
const menuItems = computed(() =>
|
||||
Object.entries(SERVICE_STATUS)
|
||||
Object.entries(TICKET_STATUS)
|
||||
.filter(([value]) => value !== props.service.status)
|
||||
.map(([value, label]) => ({
|
||||
key: `status-${value}`,
|
||||
@@ -204,77 +208,6 @@ const menuItems = computed(() =>
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.7rem;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
border: none;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: rgba(0, 153, 76, 10%);
|
||||
color: #00994c;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: rgba(204, 154, 40, 10%);
|
||||
color: #cc6f00;
|
||||
}
|
||||
|
||||
&--in_progress {
|
||||
background: rgba(0, 112, 116, 10%);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: rgba(104, 104, 104, 10%);
|
||||
color: #686868;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SERVICE_STATUS } from '@/enums'
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
@@ -76,7 +76,7 @@ watch(
|
||||
|
||||
const todayIso = new Date().toISOString()
|
||||
|
||||
const statusOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
|
||||
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
text="ارسال"
|
||||
custom-class="send-message__submit"
|
||||
:disabled="!canSubmit"
|
||||
:loading="sendMutation.isPending.value"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
@@ -66,19 +67,34 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { SERVICE_STATUS } from '@/enums'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import {
|
||||
adminServicesKeys,
|
||||
useSendAdminServiceMessageMutation,
|
||||
} from '@/services/query/admin-services'
|
||||
|
||||
defineOptions({ name: 'SendMessageModal' })
|
||||
|
||||
const categoryOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
const { getModal } = useModal()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modalData = computed(() => getModal('SendMessageModal')?.data ?? {})
|
||||
const serviceId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
// FE-only — the message endpoint doesn't accept category. Keeping the dropdown
|
||||
// visible per the Figma until the backend supports it.
|
||||
const categoryOptions = [
|
||||
{ value: 'general', label: 'عمومی' },
|
||||
{ value: 'financial', label: 'مالی' },
|
||||
{ value: 'technical', label: 'فنی' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'very_urgent', label: 'بسیار فوری' },
|
||||
@@ -97,8 +113,18 @@ const canSubmit = computed(
|
||||
() => form.value.category && form.value.priority && form.value.description.trim().length > 0
|
||||
)
|
||||
|
||||
const onSubmit = (close) => {
|
||||
if (!canSubmit.value) return
|
||||
const sendMutation = useSendAdminServiceMessageMutation()
|
||||
|
||||
const onSubmit = async (close) => {
|
||||
if (!canSubmit.value || !serviceId.value) return
|
||||
// Backend POST /admin/tickets/:id/messages accepts only { message }.
|
||||
// category + priority are collected in the form but not yet supported
|
||||
// server-side (see backend-vs-ui-gaps).
|
||||
await sendMutation.mutateAsync({
|
||||
id: serviceId.value,
|
||||
payload: { message: form.value.description.trim() },
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
|
||||
toast.success('پیام با موفقیت ارسال شد')
|
||||
close?.()
|
||||
}
|
||||
|
||||
@@ -7,56 +7,79 @@
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<template #default>
|
||||
<div class="service-details">
|
||||
<div class="service-details__row service-details__row--two">
|
||||
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle(data)" />
|
||||
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel(data)" />
|
||||
</div>
|
||||
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="3" :cols-per-row="1" />
|
||||
<template v-else>
|
||||
<div class="service-details__row service-details__row--two">
|
||||
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle" />
|
||||
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel" />
|
||||
</div>
|
||||
|
||||
<div class="service-details__row">
|
||||
<LineInfoBlock title="درخواست" :desc="requestText(data)" />
|
||||
</div>
|
||||
<div class="service-details__row">
|
||||
<LineInfoBlock title="درخواست" :desc="requestText" />
|
||||
</div>
|
||||
|
||||
<div class="service-details__divider" />
|
||||
<!-- <div class="service-details__divider" /> -->
|
||||
|
||||
<footer class="service-details__footer">
|
||||
<BaseButton
|
||||
text="ارسال پیام"
|
||||
custom-class="service-details__send-btn"
|
||||
@click="onSendMessage(data)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</footer>
|
||||
<!-- <footer class="service-details__footer">
|
||||
<BaseButton
|
||||
text="ارسال پیام"
|
||||
custom-class="service-details__send-btn"
|
||||
@click="onSendMessage"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</footer> -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SERVICE_TYPE } from '@/enums'
|
||||
import { computed } from 'vue'
|
||||
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 } = useModal()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const serviceTitle = (data) => data?.title || data?.serviceTitle || '—'
|
||||
const modalData = computed(() => getModal('ServiceDetailsModal')?.data ?? {})
|
||||
const serviceId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
const serviceTypeLabel = (data) => data?.typeLabel || SERVICE_TYPE[data?.type] || '—'
|
||||
const { data: service, isLoading } = useAdminServiceQuery(serviceId, {
|
||||
enabled: () => !!serviceId.value,
|
||||
})
|
||||
|
||||
const requestText = (data) => data?.requestText || data?.description || '—'
|
||||
// Subject is the canonical title on the new schema; older fixtures used `title`.
|
||||
const serviceTitle = computed(
|
||||
() => service.value?.subject || service.value?.title || service.value?.serviceTitle || '—'
|
||||
)
|
||||
|
||||
const onSendMessage = (data) => {
|
||||
openModal('SendMessageModal', { service: data })
|
||||
}
|
||||
const serviceTypeLabel = computed(() => service.value?.category || '—')
|
||||
|
||||
// Pre-migration fixtures used `requestText`/`description`; the new schema puts
|
||||
// the body inside the first message.
|
||||
const requestText = computed(
|
||||
() =>
|
||||
service.value?.requestText ||
|
||||
service.value?.description ||
|
||||
service.value?.messages?.[0]?.message ||
|
||||
'—'
|
||||
)
|
||||
|
||||
// const onSendMessage = () => {
|
||||
// openModal('SendMessageModal', { id: serviceId.value })
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -86,7 +109,7 @@ const onSendMessage = (data) => {
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__send-btn {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
</template>
|
||||
</SimpleTitleIconBlock>
|
||||
|
||||
<div v-if="services.length > 0">
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="services.length > 0">
|
||||
<ServiceItem
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
@@ -29,6 +30,8 @@
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="درخواست خدمتی برای نمایش وجود ندارد." />
|
||||
|
||||
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
|
||||
|
||||
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
|
||||
<SendMessageModal v-if="isModal('SendMessageModal')" />
|
||||
</div>
|
||||
@@ -37,56 +40,73 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
|
||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
|
||||
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
|
||||
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.vue'
|
||||
import {
|
||||
adminServicesKeys,
|
||||
useAdminServicesListQuery,
|
||||
useChangeAdminServiceStatusMutation,
|
||||
} from '@/services/query/admin-services'
|
||||
|
||||
const { openModal, isModal } = useModal()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const filters = ref({ type: 'service', userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const allServices = ref([
|
||||
{
|
||||
id: 1,
|
||||
requestId: '387',
|
||||
user: { firstName: 'علیرضا', lastName: 'محمدی' },
|
||||
title: 'گفتگو درباره وام',
|
||||
type: 'loan',
|
||||
status: 'approved',
|
||||
requestText:
|
||||
'من علاقهمند به دنیای تکنولوژی، طراحی رابط کاربری و توسعه نرمافزار هستم. در کنار کار حرفهای، به یادگیری موضوعات جدید، مطالعه و موسیقی هم علاقه دارم. هدفم ساختن محصولاتی است.',
|
||||
faCreatedAt: '1404/05/12',
|
||||
faCreatedTime: '13:54',
|
||||
},
|
||||
])
|
||||
|
||||
const services = computed(() => {
|
||||
const f = filters.value
|
||||
return allServices.value.filter((s) => {
|
||||
if (f.status && s.status !== f.status) return false
|
||||
if (f.userName) {
|
||||
const fullName = `${s.user?.firstName || ''} ${s.user?.lastName || ''}`.trim()
|
||||
if (!fullName.includes(f.userName)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const { data, isLoading } = useAdminServicesListQuery(filters, pagination, {
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
const onFilterApply = () => {}
|
||||
const onFilterReset = () => {}
|
||||
// Backend payload doesn't include the UI's legacy fields (requestId, faCreatedAt,
|
||||
// user.firstName/lastName, typeLabel/statusLabel). Bridge them here so ServiceItem
|
||||
// and the modals can stay untouched.
|
||||
const services = computed(() =>
|
||||
(data.value?.data?.items ?? []).map((s) => ({
|
||||
...s,
|
||||
requestId: s.requestId ?? s.id,
|
||||
user:
|
||||
s.user ??
|
||||
(s.student
|
||||
? {
|
||||
id: s.student.id,
|
||||
firstName: s.student.name?.split(' ')[0] || '',
|
||||
lastName: s.student.name?.split(' ').slice(1).join(' ') || '',
|
||||
fullName: s.student.name,
|
||||
avatarUrl: s.student.avatarUrl,
|
||||
}
|
||||
: null),
|
||||
}))
|
||||
)
|
||||
|
||||
const paginationMeta = computed(() => ({
|
||||
page: pagination.value.page,
|
||||
perPage: pagination.value.perPage,
|
||||
...data.value?.meta,
|
||||
}))
|
||||
|
||||
const onFilterApply = () => resetPagination()
|
||||
const onFilterReset = () => resetPagination()
|
||||
|
||||
const onShowDetails = (service) => {
|
||||
openModal('ServiceDetailsModal', service)
|
||||
openModal('ServiceDetailsModal', { id: service.id })
|
||||
}
|
||||
|
||||
const onChangeStatus = ({ service, status }) => {
|
||||
const idx = allServices.value.findIndex((s) => s.id === service.id)
|
||||
if (idx !== -1) allServices.value[idx] = { ...allServices.value[idx], status }
|
||||
const changeStatusMutation = useChangeAdminServiceStatusMutation()
|
||||
|
||||
const onChangeStatus = async ({ service, status }) => {
|
||||
await changeStatusMutation.mutateAsync({ id: service.id, payload: { status } })
|
||||
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
title="جزئیات مشاوره"
|
||||
title-en="Consultation Details"
|
||||
width="95%"
|
||||
max-width="64rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default>
|
||||
<div class="consultation-details">
|
||||
<div v-if="consultation" class="consultation-details__date">
|
||||
<span>{{ consultationDate }}</span>
|
||||
</div>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && !consultation" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="messages.length > 0" ref="thread" class="consultation-details__thread">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="consultation-details__row"
|
||||
:class="`consultation-details__row--${senderClass(message)}`"
|
||||
>
|
||||
<div class="consultation-details__bubble">
|
||||
<p class="consultation-details__text">{{ message.message }}</p>
|
||||
<span class="consultation-details__time">{{ messageTime(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی در این مشاوره ثبت نشده است." />
|
||||
|
||||
<div class="consultation-details__divider" />
|
||||
|
||||
<form class="consultation-details__compose" @submit.prevent="onSend">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canSend"
|
||||
class="consultation-details__send"
|
||||
aria-label="ارسال"
|
||||
>
|
||||
<SvgIcon name="paper-plane-right" :size="22" color="var(--color-primary)" />
|
||||
</button>
|
||||
<input
|
||||
v-model="text"
|
||||
type="text"
|
||||
placeholder="ارسال پیام"
|
||||
class="consultation-details__input"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
counselorTicketsKeys,
|
||||
useCounselorTicketQuery,
|
||||
useSendCounselorTicketMessageMutation,
|
||||
} from '@/services/query/counselor-tickets'
|
||||
|
||||
defineOptions({ name: 'CounselorConsultationDetailsModal' })
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('CounselorConsultationDetailsModal')?.data ?? {})
|
||||
const consultationId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
const { data: consultation, isLoading } = useCounselorTicketQuery(consultationId, {
|
||||
enabled: () => !!consultationId.value,
|
||||
})
|
||||
|
||||
const messages = computed(() => consultation.value?.messages ?? [])
|
||||
|
||||
const consultationDate = computed(() => formatJalaaliDate(consultation.value?.createdAt) || '—')
|
||||
|
||||
const senderClass = (message) =>
|
||||
message.senderId === consultation.value?.studentId ? 'user' : 'admin'
|
||||
|
||||
const messageTime = (message) => {
|
||||
if (!message.createdAt) return '—'
|
||||
const d = new Date(message.createdAt)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const text = ref('')
|
||||
const canSend = computed(() => text.value.trim().length > 0)
|
||||
|
||||
const sendMutation = useSendCounselorTicketMessageMutation()
|
||||
|
||||
const onSend = async () => {
|
||||
if (!canSend.value || !consultationId.value) return
|
||||
const value = text.value.trim()
|
||||
text.value = ''
|
||||
await sendMutation.mutateAsync({ id: consultationId.value, payload: { message: value } })
|
||||
await queryClient.invalidateQueries({ queryKey: counselorTicketsKeys.all })
|
||||
}
|
||||
|
||||
const thread = ref(null)
|
||||
|
||||
watch(messages, async () => {
|
||||
await nextTick()
|
||||
if (thread.value) {
|
||||
thread.value.scrollTop = thread.value.scrollHeight
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consultation-details {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 8rem);
|
||||
min-height: 28rem;
|
||||
|
||||
&__date {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.5rem 0 0.875rem;
|
||||
}
|
||||
|
||||
&__date span {
|
||||
background: #f7f7f7;
|
||||
color: #9c9c9c;
|
||||
border-radius: 9999px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__thread {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
padding-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
|
||||
&--admin {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
max-width: 72%;
|
||||
border-radius: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
|
||||
.consultation-details__row--admin & {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.consultation-details__row--user & {
|
||||
background: #f8f8f8;
|
||||
color: #5d5d5d;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
margin-top: 0.375rem;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
|
||||
.consultation-details__row--admin & {
|
||||
color: rgba(255, 255, 255, 80%);
|
||||
}
|
||||
|
||||
.consultation-details__row--user & {
|
||||
color: #b1b1b1;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-block-end: 1px solid var(--color-thd-gray);
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
&__compose {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__send {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover:enabled {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
height: 2.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
color: #4b4b4b;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="consultations-page">
|
||||
<BoxedIconTitleBlock
|
||||
class="consultations-page__heading"
|
||||
title="مدیریت مشاوره"
|
||||
desc="در این قسمت میتوانید مشاوره های خواسته شده را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="chat" :size="24" color="var(--color-primary)" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
<ConsultationsFilters v-model="filters" @apply="onFilterApply" @reset="onFilterReset" />
|
||||
|
||||
<SimpleTitleIconBlock
|
||||
title="لیست تمام مشاوره های خواسته شده"
|
||||
class="consultations-page__list-title"
|
||||
>
|
||||
<template #header-icon>
|
||||
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
|
||||
</template>
|
||||
</SimpleTitleIconBlock>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="consultations.length > 0">
|
||||
<ConsultationItem
|
||||
v-for="consultation in consultations"
|
||||
:key="consultation.id"
|
||||
:consultation="consultation"
|
||||
@show-details="onShowDetails"
|
||||
@change-status="onChangeStatus"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="درخواست مشاورهای برای نمایش وجود ندارد." />
|
||||
|
||||
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
|
||||
|
||||
<CounselorConsultationDetailsModal v-if="isModal('CounselorConsultationDetailsModal')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||
import ConsultationItem from '@/features/admin/consultations/components/ConsultationItem.vue'
|
||||
import ConsultationsFilters from '@/features/admin/consultations/components/ConsultationsFilters.vue'
|
||||
import CounselorConsultationDetailsModal from '@/features/counselor/components/modals/CounselorConsultationDetailsModal.vue'
|
||||
import {
|
||||
counselorTicketsKeys,
|
||||
useChangeCounselorTicketStatusMutation,
|
||||
useCounselorTicketsListQuery,
|
||||
} from '@/services/query/counselor-tickets'
|
||||
|
||||
const { openModal, isModal } = useModal()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const filters = ref({ type: 'advise', userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data, isLoading } = useCounselorTicketsListQuery(filters, pagination, {
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
const consultations = computed(() => data.value?.data ?? [])
|
||||
const paginationMeta = computed(() => ({
|
||||
page: pagination.value.page,
|
||||
perPage: pagination.value.perPage,
|
||||
...data.value?.meta,
|
||||
}))
|
||||
|
||||
const onFilterApply = () => resetPagination()
|
||||
const onFilterReset = () => resetPagination()
|
||||
|
||||
const onShowDetails = (consultation) => {
|
||||
openModal('CounselorConsultationDetailsModal', { id: consultation.id })
|
||||
}
|
||||
|
||||
const changeStatusMutation = useChangeCounselorTicketStatusMutation()
|
||||
|
||||
const onChangeStatus = async ({ consultation, status }) => {
|
||||
await changeStatusMutation.mutateAsync({ id: consultation.id, payload: { status } })
|
||||
await queryClient.invalidateQueries({ queryKey: counselorTicketsKeys.all })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consultations-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&__list-title {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
export default [
|
||||
{
|
||||
path: '/counselor/consultations',
|
||||
name: 'counselor-consultations',
|
||||
component: () => import('@/features/counselor/pages/CounselorConsultationsPage.vue'),
|
||||
meta: { layout: 'counselor', role: 'counselor', title: 'مشاوره' },
|
||||
},
|
||||
]
|
||||
@@ -22,10 +22,14 @@
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
|
||||
// Backend statuses are open/answered/closed. Legacy keys stay in place for any
|
||||
// pre-migration cached data.
|
||||
const TONE_MAP = {
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
pending: 'neutral',
|
||||
open: 'neutral',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
<form class="rcf__form" @submit.prevent="onSubmit">
|
||||
<div class="rcf__row">
|
||||
<div class="rcf__cell">
|
||||
<SelectField
|
||||
v-model="form.title"
|
||||
name="title"
|
||||
<TextField
|
||||
v-model="form.subject"
|
||||
name="subject"
|
||||
label="عنوان مشاوره درخواستی"
|
||||
placeholder="انتخاب کنید"
|
||||
:options="titleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:error="errors.title"
|
||||
placeholder="عنوان مشاوره را وارد کنید"
|
||||
:error="errors.subject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,12 +16,12 @@
|
||||
<div class="rcf__row">
|
||||
<div class="rcf__cell rcf__cell--full">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
v-model="form.message"
|
||||
name="message"
|
||||
label="توضیحات خود را وارد نمایید"
|
||||
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
||||
:row="5"
|
||||
:error="errors.description"
|
||||
:error="errors.message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,14 +45,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import * as yup from 'yup'
|
||||
import { computed, ref } from 'vue'
|
||||
import useYup from '@/composables/useYup'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import { useStudentConsultantTitlesQuery } from '@/services/query/student-consultants'
|
||||
|
||||
defineProps({
|
||||
submitting: { type: Boolean, default: false },
|
||||
@@ -64,13 +60,13 @@ defineProps({
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const schema = yup.object({
|
||||
title: yup.string().required('عنوان مشاوره را انتخاب کنید'),
|
||||
description: yup
|
||||
subject: yup.string().trim().required('عنوان مشاوره را وارد کنید'),
|
||||
message: yup
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
||||
@@ -79,13 +75,10 @@ const schema = yup.object({
|
||||
|
||||
const { validate, errors } = useYup(schema)
|
||||
|
||||
const { data: titles } = useStudentConsultantTitlesQuery()
|
||||
const titleOptions = computed(() => titles.value ?? [])
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
emit('submit', { ...payload })
|
||||
emit('submit', { subject: payload.subject, message: payload.message })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -35,9 +35,11 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
@@ -46,10 +48,10 @@ import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import ConsultantItem from '@/features/student/consultants/components/ConsultantItem.vue'
|
||||
import RequestConsultantForm from '@/features/student/consultants/components/RequestConsultantForm.vue'
|
||||
import {
|
||||
studentConsultantsKeys,
|
||||
useCreateStudentConsultantMutation,
|
||||
useStudentConsultantsQuery,
|
||||
} from '@/services/query/student-consultants'
|
||||
studentTicketsKeys,
|
||||
useCreateStudentTicketMutation,
|
||||
useStudentTicketsQuery,
|
||||
} from '@/services/query/student-tickets'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -59,13 +61,13 @@ const tabs = [
|
||||
]
|
||||
const activeTab = ref('create')
|
||||
|
||||
const historyFilters = ref({})
|
||||
const historyFilters = ref({ type: 'advise' })
|
||||
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQuery(
|
||||
const { data: historyData, isLoading: historyLoading } = useStudentTicketsQuery(
|
||||
historyFilters,
|
||||
historyPagination,
|
||||
{
|
||||
@@ -74,20 +76,42 @@ const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQu
|
||||
}
|
||||
)
|
||||
|
||||
const consultants = computed(() => historyData.value?.data ?? [])
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// ConsultantItem expects title/requestNumber/dateLabel/timeLabel/statusLabel.
|
||||
// Bridge the new backend payload without touching the item template.
|
||||
const consultants = computed(() =>
|
||||
(historyData.value?.data?.items ?? []).map((t) => ({
|
||||
...t,
|
||||
title: t.subject ?? '',
|
||||
requestNumber: t.id,
|
||||
dateLabel: formatJalaaliDate(t.createdAt) || '',
|
||||
timeLabel: formatTime(t.createdAt),
|
||||
statusLabel: TICKET_STATUS[t.status] ?? '',
|
||||
}))
|
||||
)
|
||||
const historyMeta = computed(() => ({
|
||||
page: historyPagination.value.page,
|
||||
perPage: historyPagination.value.perPage,
|
||||
...historyData.value?.meta,
|
||||
}))
|
||||
|
||||
const createMutation = useCreateStudentConsultantMutation()
|
||||
const createMutation = useCreateStudentTicketMutation()
|
||||
|
||||
const onSubmit = async (payload) => {
|
||||
const onSubmit = async (formPayload) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(payload)
|
||||
await createMutation.mutateAsync({
|
||||
type: 'advise',
|
||||
subject: formPayload.subject,
|
||||
message: formPayload.message,
|
||||
})
|
||||
toast.success('درخواست مشاوره با موفقیت ثبت شد.')
|
||||
await queryClient.invalidateQueries({ queryKey: studentConsultantsKeys.all })
|
||||
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||
activeTab.value = 'history'
|
||||
} catch {
|
||||
toast.error('ثبت درخواست با خطا مواجه شد.')
|
||||
|
||||
@@ -83,4 +83,10 @@ export default [
|
||||
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' },
|
||||
},
|
||||
{
|
||||
path: '/my-tickets',
|
||||
name: 'student-tickets',
|
||||
component: () => import('@/features/student/tickets/pages/StudentTicketsPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'تیکتهای من' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,29 +3,21 @@
|
||||
<form class="csf__form" @submit.prevent="onSubmit">
|
||||
<div class="csf__row">
|
||||
<div class="csf__cell">
|
||||
<SelectField
|
||||
v-model="form.typeKey"
|
||||
name="typeKey"
|
||||
<TextField
|
||||
v-model="form.category"
|
||||
name="category"
|
||||
label="نوع خدمت"
|
||||
placeholder="انتخاب کنید"
|
||||
:options="typeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:error="errors.typeKey"
|
||||
@change="onTypeChange"
|
||||
placeholder="نوع خدمت را وارد کنید"
|
||||
:error="errors.category"
|
||||
/>
|
||||
</div>
|
||||
<div class="csf__cell">
|
||||
<SelectField
|
||||
v-model="form.title"
|
||||
name="title"
|
||||
<TextField
|
||||
v-model="form.subject"
|
||||
name="subject"
|
||||
label="عنوان خدمت"
|
||||
placeholder="انتخاب کنید"
|
||||
:options="titleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:disabled="!form.typeKey"
|
||||
:error="errors.title"
|
||||
placeholder="عنوان خدمت را وارد کنید"
|
||||
:error="errors.subject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,12 +25,12 @@
|
||||
<div class="csf__row">
|
||||
<div class="csf__cell csf__cell--full">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
v-model="form.message"
|
||||
name="message"
|
||||
label="توضیحات خود را وارد نمایید"
|
||||
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
||||
:row="5"
|
||||
:error="errors.description"
|
||||
:error="errors.message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,6 +40,7 @@
|
||||
v-model="files"
|
||||
accept="image/*,application/pdf"
|
||||
:max-files="3"
|
||||
@select="onFilesSelect"
|
||||
@error="onFileError"
|
||||
/>
|
||||
</div>
|
||||
@@ -60,6 +53,7 @@
|
||||
text="ثبت درخواست"
|
||||
custom-class="csf__submit"
|
||||
:loading="submitting"
|
||||
:disabled="uploadMutation.isPending.value"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
||||
@@ -71,18 +65,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import * as yup from 'yup'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import useYup from '@/composables/useYup'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import FileUploader from '@/components/form/FileUploader.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import {
|
||||
useStudentServiceTitlesQuery,
|
||||
useStudentServiceTypesQuery,
|
||||
} from '@/services/query/student-services'
|
||||
|
||||
defineProps({
|
||||
submitting: { type: Boolean, default: false },
|
||||
@@ -91,17 +84,20 @@ defineProps({
|
||||
const emit = defineEmits(['submit', 'file-error'])
|
||||
|
||||
const form = ref({
|
||||
typeKey: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
// Items here are uploaded-media descriptors { id, name, size, url } — not raw
|
||||
// File objects. We upload immediately on select (same pattern as
|
||||
// AddAssignmentModal / SessionFormPage) and just collect the IDs at submit.
|
||||
const files = ref([])
|
||||
|
||||
const schema = yup.object({
|
||||
typeKey: yup.string().required('نوع خدمت را انتخاب کنید'),
|
||||
title: yup.string().required('عنوان خدمت را انتخاب کنید'),
|
||||
description: yup
|
||||
category: yup.string().trim().required('نوع خدمت را وارد کنید'),
|
||||
subject: yup.string().trim().required('عنوان خدمت را وارد کنید'),
|
||||
message: yup
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
||||
@@ -110,32 +106,38 @@ const schema = yup.object({
|
||||
|
||||
const { validate, errors } = useYup(schema)
|
||||
|
||||
const { data: types } = useStudentServiceTypesQuery()
|
||||
const typeOptions = computed(() => types.value ?? [])
|
||||
const uploadMutation = useUploadMediaMutation()
|
||||
|
||||
const typeKeyRef = computed(() => form.value.typeKey)
|
||||
const { data: titles } = useStudentServiceTitlesQuery(typeKeyRef, {
|
||||
enabled: () => !!form.value.typeKey,
|
||||
})
|
||||
const titleOptions = computed(() => titles.value ?? [])
|
||||
|
||||
const onTypeChange = () => {
|
||||
form.value.title = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.value.typeKey,
|
||||
() => {
|
||||
form.value.title = ''
|
||||
const onFilesSelect = async (newFiles) => {
|
||||
for (const file of newFiles) {
|
||||
try {
|
||||
const fd = objectToFormData({ file, purpose: 'attachment', context: 'ticket' })
|
||||
const response = await uploadMutation.mutateAsync(fd)
|
||||
const payload = response?.data ?? response
|
||||
const id = payload?.id ?? payload?.uploadId
|
||||
if (id == null) continue
|
||||
files.value = [
|
||||
...files.value,
|
||||
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
|
||||
]
|
||||
} catch {
|
||||
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const onFileError = (msg) => emit('file-error', msg)
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
emit('submit', { ...payload, files: files.value })
|
||||
|
||||
emit('submit', {
|
||||
category: payload.category,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
mediaIds: files.value.map((f) => f.id).filter((id) => id != null),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ const TONE_MAP = {
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
pending: 'neutral',
|
||||
open: 'neutral',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
title="جزئیات درخواست"
|
||||
title-en="Request details"
|
||||
width="95%"
|
||||
max-width="58rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default>
|
||||
<div class="sdm">
|
||||
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="2" :cols-per-row="1" />
|
||||
|
||||
<template v-else-if="service">
|
||||
<div class="sdm__top">
|
||||
<span class="sdm__date">{{ requestDate }}</span>
|
||||
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length > 0" class="sdm__messages">
|
||||
<div v-for="message in messages" :key="message.id" class="sdm__bubble">
|
||||
<p class="sdm__text">{{ message.message }}</p>
|
||||
<span class="sdm__time">{{ messageTime(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی برای این درخواست ثبت نشده است." />
|
||||
</template>
|
||||
</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 BasicModal from '@/components/BasicModal.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'
|
||||
|
||||
defineOptions({ name: 'ServiceDetailsModal' })
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'neutral',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
pending: 'neutral',
|
||||
}
|
||||
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('ServiceDetailsModal')?.data ?? {})
|
||||
const ticketId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
const { data: service, isLoading } = useStudentTicketQuery(ticketId, {
|
||||
enabled: () => !!ticketId.value,
|
||||
})
|
||||
|
||||
const messages = computed(() => service.value?.messages ?? [])
|
||||
|
||||
const statusLabel = computed(() => TICKET_STATUS[service.value?.status] || '—')
|
||||
const statusVariant = computed(() => STATUS_VARIANT[service.value?.status] || 'neutral')
|
||||
|
||||
const requestDate = computed(() => formatJalaaliDate(service.value?.createdAt) || '—')
|
||||
|
||||
const messageTime = (message) => {
|
||||
if (!message.createdAt) return ''
|
||||
const d = new Date(message.createdAt)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sdm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
text-align: start;
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
&__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
position: relative;
|
||||
background: #f6f6f6;
|
||||
padding: 1rem 1rem 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0;
|
||||
color: #454545;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.8rem;
|
||||
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;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.65rem;
|
||||
color: #a7a7a7;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,12 +33,16 @@
|
||||
<PaginationBlock :pagination="historyMeta" @update:page="setHistoryPage" />
|
||||
</template>
|
||||
</TabsBlock>
|
||||
|
||||
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
@@ -49,13 +53,15 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import ServiceItem from '@/features/student/services/components/ServiceItem.vue'
|
||||
import CreateServiceForm from '@/features/student/services/components/CreateServiceForm.vue'
|
||||
import ServiceDetailsModal from '@/features/student/services/components/modals/ServiceDetailsModal.vue'
|
||||
import {
|
||||
studentServicesKeys,
|
||||
useCreateStudentServiceMutation,
|
||||
useStudentServicesQuery,
|
||||
} from '@/services/query/student-services'
|
||||
studentTicketsKeys,
|
||||
useCreateStudentTicketMutation,
|
||||
useStudentTicketsQuery,
|
||||
} from '@/services/query/student-tickets'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'create', label: 'درخواست خدمت', icon: 'paper-plane' },
|
||||
@@ -63,13 +69,13 @@ const tabs = [
|
||||
]
|
||||
const activeTab = ref('create')
|
||||
|
||||
const historyFilters = ref({})
|
||||
const historyFilters = ref({ type: 'service' })
|
||||
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery(
|
||||
const { data: historyData, isLoading: historyLoading } = useStudentTicketsQuery(
|
||||
historyFilters,
|
||||
historyPagination,
|
||||
{
|
||||
@@ -78,20 +84,37 @@ const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery
|
||||
}
|
||||
)
|
||||
|
||||
const services = computed(() => historyData.value?.data ?? [])
|
||||
// ServiceItem expects title/requestNumber/typeLabel/statusLabel. Bridge the
|
||||
// new backend payload (subject/id/category/status) without changing the item
|
||||
// template.
|
||||
const services = computed(() =>
|
||||
(historyData.value?.data?.items ?? []).map((t) => ({
|
||||
...t,
|
||||
title: t.subject ?? '',
|
||||
requestNumber: t.id,
|
||||
typeLabel: t.category ?? '',
|
||||
statusLabel: TICKET_STATUS[t.status] ?? '',
|
||||
}))
|
||||
)
|
||||
const historyMeta = computed(() => ({
|
||||
page: historyPagination.value.page,
|
||||
perPage: historyPagination.value.perPage,
|
||||
...historyData.value?.meta,
|
||||
}))
|
||||
|
||||
const createMutation = useCreateStudentServiceMutation()
|
||||
const createMutation = useCreateStudentTicketMutation()
|
||||
|
||||
const onSubmit = async (payload) => {
|
||||
const onSubmit = async (formPayload) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(payload)
|
||||
await createMutation.mutateAsync({
|
||||
type: 'service',
|
||||
category: formPayload.category,
|
||||
subject: formPayload.subject,
|
||||
message: formPayload.message,
|
||||
mediaIds: formPayload.mediaIds,
|
||||
})
|
||||
toast.success('درخواست خدمت با موفقیت ثبت شد.')
|
||||
await queryClient.invalidateQueries({ queryKey: studentServicesKeys.all })
|
||||
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||
activeTab.value = 'history'
|
||||
} catch {
|
||||
toast.error('ثبت درخواست با خطا مواجه شد.')
|
||||
@@ -100,7 +123,9 @@ const onSubmit = async (payload) => {
|
||||
|
||||
const onFileError = (msg) => toast.warning(msg)
|
||||
|
||||
const onShowDetails = () => {}
|
||||
const onShowDetails = (service) => {
|
||||
openModal('ServiceDetailsModal', { id: service.id })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 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' },
|
||||
active: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-tile {
|
||||
flex: 0 0 12.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 0.75rem 1rem;
|
||||
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;
|
||||
|
||||
&: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 {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.25;
|
||||
color: #4c4c4c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="ticket-item">
|
||||
<div class="ticket-item__user">
|
||||
<div v-if="ticket.student?.avatarUrl" class="ticket-item__avatar">
|
||||
<img :src="ticket.student.avatarUrl" :alt="userName" />
|
||||
</div>
|
||||
<div v-else class="ticket-item__avatar ticket-item__avatar--placeholder">
|
||||
<SvgIcon name="user" :size="24" color="#bcbcbc" />
|
||||
</div>
|
||||
<div class="ticket-item__info">
|
||||
<p class="ticket-item__name">{{ userName }}</p>
|
||||
<p class="ticket-item__request-id">
|
||||
<span class="ticket-item__request-id-label">شماره درخواست :</span>
|
||||
<span class="ticket-item__request-id-value">{{ ticket.id || '—' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ticket-item__meta">
|
||||
<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>
|
||||
|
||||
<div class="ticket-item__actions">
|
||||
<BaseButton
|
||||
text="جزئیات تیکت"
|
||||
custom-class="ticket-item__details-btn"
|
||||
@click="emit('show-details', ticket)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="caret-left" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
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',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const PRIORITY_VARIANT = {
|
||||
low: 'neutral',
|
||||
normal: 'info',
|
||||
high: 'warning',
|
||||
urgent: 'danger',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
ticket: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['show-details'])
|
||||
|
||||
const userName = computed(() => props.ticket.student?.name || props.ticket.subject || '—')
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ticket-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 50%);
|
||||
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
|
||||
border-radius: 0.875rem;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1 1 30%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&--placeholder {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.95rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0 0 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__request-id {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
color: #8f8f8f;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__request-id-label {
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__request-id-value {
|
||||
font-family: var(--font-family-en);
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 100%;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-btn {
|
||||
min-width: 9rem;
|
||||
padding: 0 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<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)">
|
||||
<div class="ctm__row ctm__row--two">
|
||||
<TextField
|
||||
v-model="form.subject"
|
||||
name="subject"
|
||||
label="موضوع را وارد کنید"
|
||||
placeholder="موضوع تیکت"
|
||||
:error="errors.subject"
|
||||
@blur="validateAt('subject', form.subject)"
|
||||
/>
|
||||
<SelectField
|
||||
v-model="form.priority"
|
||||
name="priority"
|
||||
label="اولویت تیکت"
|
||||
placeholder="انتخاب کنید"
|
||||
:options="priorityOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:error="errors.priority"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ctm__row">
|
||||
<TextareaField
|
||||
v-model="form.message"
|
||||
name="message"
|
||||
label="توضیحات خودتان را وارد کنید"
|
||||
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
||||
:row="4"
|
||||
:error="errors.message"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ctm__upload">
|
||||
<FileUploader
|
||||
v-model="files"
|
||||
accept="image/*,application/pdf"
|
||||
:max-files="5"
|
||||
@select="onFilesSelect"
|
||||
@error="onFileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ctm__divider" />
|
||||
|
||||
<footer class="ctm__footer">
|
||||
<button type="button" class="ctm__cancel" @click="close">
|
||||
<span>منصرف شدم</span>
|
||||
<SvgIcon name="caret-left" :size="16" color="currentColor" />
|
||||
</button>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
text="ارسال تیکت"
|
||||
custom-class="ctm__submit"
|
||||
:loading="createMutation.isPending.value"
|
||||
:disabled="uploadMutation.isPending.value"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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'
|
||||
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 FileUploader from '@/components/form/FileUploader.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import {
|
||||
studentTicketsKeys,
|
||||
useCreateStudentTicketMutation,
|
||||
} from '@/services/query/student-tickets'
|
||||
|
||||
defineOptions({ name: 'CreateTicketModal' })
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const priorityOptions = Object.entries(TICKET_PRIORITY).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
const form = ref({
|
||||
subject: '',
|
||||
priority: 'normal',
|
||||
message: '',
|
||||
})
|
||||
|
||||
// Uploaded-media descriptors { id, name, size, url }, populated as files are
|
||||
// picked. Submit just collects the IDs (same pattern as CreateServiceForm /
|
||||
// AddAssignmentModal).
|
||||
const files = ref([])
|
||||
|
||||
const schema = yup.object({
|
||||
subject: yup.string().trim().required('موضوع تیکت را وارد کنید'),
|
||||
priority: yup.string().required('اولویت را انتخاب کنید'),
|
||||
message: yup
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
||||
.required('توضیحات را وارد کنید'),
|
||||
})
|
||||
|
||||
const { validate, validateAt, errors, resetErrors } = useYup(schema)
|
||||
|
||||
const uploadMutation = useUploadMediaMutation()
|
||||
const createMutation = useCreateStudentTicketMutation()
|
||||
|
||||
// FileUploader emits `select` on add (we upload + push the result) and
|
||||
// `update:modelValue` on remove (handled by v-model directly).
|
||||
const onFilesSelect = async (picked) => {
|
||||
for (const file of picked) {
|
||||
try {
|
||||
const fd = objectToFormData({ file, purpose: 'attachment', context: 'ticket' })
|
||||
const response = await uploadMutation.mutateAsync(fd)
|
||||
const payload = response?.data ?? response
|
||||
const id = payload?.id ?? payload?.uploadId
|
||||
if (id == null) continue
|
||||
files.value = [
|
||||
...files.value,
|
||||
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
|
||||
]
|
||||
} catch {
|
||||
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onFileError = (msg) => toast.warning(msg)
|
||||
|
||||
const onSubmit = async (close) => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
type: 'ticket',
|
||||
priority: payload.priority,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
mediaIds: files.value.map((f) => f.id).filter((id) => id != null),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||
toast.success('تیکت با موفقیت ثبت شد.')
|
||||
resetErrors()
|
||||
form.value = { subject: '', priority: 'normal', message: '' }
|
||||
files.value = []
|
||||
close?.()
|
||||
} catch {
|
||||
toast.error('ارسال تیکت با خطا مواجه شد.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ctm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: start;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.875rem;
|
||||
|
||||
&--two {
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-block-end: 1px solid #ddd;
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #969696;
|
||||
|
||||
&:hover {
|
||||
color: #6b6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
&__submit {
|
||||
min-width: 16rem;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
title="جزئیات تیکت"
|
||||
title-en="Ticket details"
|
||||
width="95%"
|
||||
max-width="58rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default>
|
||||
<div class="ticket-details">
|
||||
<div v-if="ticket" class="ticket-details__date">
|
||||
<span>{{ ticketDate }}</span>
|
||||
</div>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="messages.length > 0" 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="هنوز پیامی در این تیکت ثبت نشده است." />
|
||||
|
||||
<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 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 SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
studentTicketsKeys,
|
||||
useSendStudentTicketMessageMutation,
|
||||
useStudentTicketQuery,
|
||||
} from '@/services/query/student-tickets'
|
||||
|
||||
defineOptions({ name: 'TicketDetailsModal' })
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
|
||||
const ticketId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
const { data: ticket, isLoading } = useStudentTicketQuery(ticketId, {
|
||||
enabled: () => !!ticketId.value,
|
||||
})
|
||||
|
||||
const messages = computed(() => ticket.value?.messages ?? [])
|
||||
|
||||
const ticketDate = 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 '—'
|
||||
const d = new Date(message.createdAt)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const text = ref('')
|
||||
const 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>
|
||||
.ticket-details {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 8rem);
|
||||
min-height: 28rem;
|
||||
|
||||
&__date {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.5rem 0 0.875rem;
|
||||
}
|
||||
|
||||
&__date span {
|
||||
background: #f7f7f7;
|
||||
color: #9c9c9c;
|
||||
border-radius: 9999px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__thread {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
padding-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
|
||||
// `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 {
|
||||
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-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
margin-top: 0.375rem;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
|
||||
.ticket-details__row--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.875rem;
|
||||
color: #4b4b4b;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="tickets-page">
|
||||
<BoxedIconTitleBlock
|
||||
class="tickets-page__heading"
|
||||
title="تیکتهای من"
|
||||
desc="در این قسمت میتوانید تیکتهای خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="chat" :size="24" color="var(--color-primary)" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
<div class="tickets-page__tiles-scroll">
|
||||
<div class="tickets-page__tiles">
|
||||
<StatusTile
|
||||
v-for="tile in statusTiles"
|
||||
:key="tile.key"
|
||||
:image="tile.image"
|
||||
:label="tile.label"
|
||||
:count="tile.count"
|
||||
:count-color="tile.countColor"
|
||||
:active="activeTile === tile.key"
|
||||
@click="onTileSelect(tile.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tickets-page__title-bar">
|
||||
<SimpleTitleIconBlock title="لیست تمام تیکتها" class="tickets-page__list-title">
|
||||
<template #header-icon>
|
||||
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
|
||||
</template>
|
||||
</SimpleTitleIconBlock>
|
||||
<BaseButton
|
||||
type="button"
|
||||
text="ارسال تیکت جدید"
|
||||
custom-class="tickets-page__new-btn"
|
||||
@click="onNewTicket"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="tickets.length > 0">
|
||||
<TicketItem
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
:ticket="ticket"
|
||||
@show-details="onShowDetails"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="موردی نیست" desc="هنوز تیکتی ثبت نکردهاید." />
|
||||
|
||||
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
|
||||
|
||||
<TicketDetailsModal v-if="isModal('TicketDetailsModal')" />
|
||||
<CreateTicketModal v-if="isModal('CreateTicketModal')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
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 BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import StatusTile from '@/features/student/tickets/components/StatusTile.vue'
|
||||
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 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,
|
||||
})
|
||||
|
||||
const tickets = computed(() => {
|
||||
const raw = data.value?.data
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (Array.isArray(raw?.items)) return raw.items
|
||||
return []
|
||||
})
|
||||
|
||||
const paginationMeta = computed(() => ({
|
||||
page: pagination.value.page,
|
||||
perPage: pagination.value.perPage,
|
||||
...data.value?.meta,
|
||||
}))
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const onNewTicket = () => {
|
||||
openModal('CreateTicketModal')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tickets-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
// Horizontal scroll for the 5 status tiles on small screens.
|
||||
&__tiles-scroll {
|
||||
overflow: auto hidden;
|
||||
margin-inline: -0.25rem;
|
||||
padding-inline: 0.25rem;
|
||||
padding-block: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
&__tiles {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.75rem;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
&__title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-block: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
&__list-title {
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__new-btn {
|
||||
min-width: 11rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="counselor-layout">
|
||||
<aside v-if="!isMobile" class="counselor-layout__sidebar">
|
||||
<CounselorSidebar :menu-items="menuItems" />
|
||||
</aside>
|
||||
|
||||
<div class="counselor-layout__content">
|
||||
<div class="counselor-layout__inner">
|
||||
<HeaderBlock :page-title="pageTitle" class="counselor-layout__header" />
|
||||
<div class="counselor-layout__page">
|
||||
<RouterView />
|
||||
</div>
|
||||
<div class="counselor-layout__footer">
|
||||
<Copyright />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isMobile && ui.isSidebarOpen"
|
||||
class="counselor-layout__backdrop"
|
||||
@click="ui.setSidebarState(false)"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="slide-drawer">
|
||||
<aside
|
||||
v-if="isMobile && ui.isSidebarOpen"
|
||||
class="counselor-layout__sidebar counselor-layout__sidebar--mobile"
|
||||
>
|
||||
<CounselorSidebar :menu-items="menuItems" />
|
||||
</aside>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUIStore } from '@/store/ui'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import Copyright from '@/components/Copyright.vue'
|
||||
import HeaderBlock from '@/components/HeaderBlock.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import CounselorSidebar from '@/layouts/sidebars/CounselorSidebar.vue'
|
||||
|
||||
const ui = useUIStore()
|
||||
const route = useRoute()
|
||||
|
||||
const isMobile = ref(window.innerWidth < 1024)
|
||||
const onResize = () => {
|
||||
isMobile.value = window.innerWidth < 1024
|
||||
}
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
const pageTitle = computed(() => route.meta?.title || 'مشاوره')
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
title: 'مشاوره',
|
||||
icon: 'chat',
|
||||
to: { name: 'counselor-consultations' },
|
||||
active: route.name === 'counselor-consultations',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.counselor-layout {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background-color: #f3f3f3;
|
||||
|
||||
&__sidebar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 16.6667%;
|
||||
min-height: 100vh;
|
||||
z-index: 30;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&--mobile {
|
||||
display: block;
|
||||
width: 70%;
|
||||
max-width: 18rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 83.3333%;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__page {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 2.5rem;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: fit-content;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background-color: rgba(0, 0, 0, 20%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -95,6 +95,12 @@ const menuItems = computed(() => [
|
||||
to: { name: 'student-consultants' },
|
||||
active: route.name === 'student-consultants',
|
||||
},
|
||||
{
|
||||
title: 'تیکتهای من',
|
||||
icon: 'chat-centered-dots',
|
||||
to: { name: 'student-tickets' },
|
||||
active: route.name === 'student-tickets',
|
||||
},
|
||||
{
|
||||
title: 'گواهینامه ها',
|
||||
icon: 'scroll',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<aside class="counselor-sidebar">
|
||||
<div class="counselor-sidebar__logo">
|
||||
<img :src="logo" alt="banu" />
|
||||
</div>
|
||||
<nav class="counselor-sidebar__nav">
|
||||
<ul>
|
||||
<li v-for="item in menuItems" :key="item.title" class="counselor-sidebar__item">
|
||||
<component
|
||||
:is="item.to ? 'RouterLink' : 'div'"
|
||||
:to="item.to"
|
||||
class="counselor-sidebar__link"
|
||||
:class="{ 'counselor-sidebar__link--active': item.active }"
|
||||
>
|
||||
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
|
||||
<span>{{ item.title }}</span>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
menuItems: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const logo = gallery.logoPinkishRed
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.counselor-sidebar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
padding: 1.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
&__logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
height: 3.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
// Admin services UI hits the same /admin/tickets endpoints as the messages
|
||||
// UI, scoped by ?type=service. Show/send/status reuse the admin-tickets
|
||||
// helpers since the URL pattern is identical.
|
||||
import { http } from '@/services/api/http'
|
||||
import { buildUrl, endpoints } from '@/services/api/endpoints'
|
||||
|
||||
export const apiGetAdminServices = (params) =>
|
||||
http.get(endpoints.getTicketsList, { params: { ...params, type: 'service' } })
|
||||
|
||||
export const apiShowAdminService = (id) => http.get(buildUrl(endpoints.showTicket, { id }))
|
||||
|
||||
export const apiSendAdminServiceMessage = (id, payload) =>
|
||||
http.post(buildUrl(endpoints.sendTicketMessage, { id }), payload)
|
||||
|
||||
export const apiChangeAdminServiceStatus = (id, payload) =>
|
||||
http.patch(buildUrl(endpoints.changeTicketStatus, { id }), payload)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { http } from '@/services/api/http'
|
||||
import { buildUrl, endpoints } from '@/services/api/endpoints'
|
||||
|
||||
export const apiGetCounselorTickets = (params) =>
|
||||
http.get(endpoints.getCounselorTickets, { params })
|
||||
|
||||
export const apiShowCounselorTicket = (id) =>
|
||||
http.get(buildUrl(endpoints.showCounselorTicket, { id }))
|
||||
|
||||
export const apiSendCounselorTicketMessage = (id, payload) =>
|
||||
http.post(buildUrl(endpoints.sendCounselorTicketMessage, { id }), payload)
|
||||
|
||||
export const apiChangeCounselorTicketStatus = (id, payload) =>
|
||||
http.patch(buildUrl(endpoints.changeCounselorTicketStatus, { id }), payload)
|
||||
@@ -35,14 +35,13 @@ export const endpoints = {
|
||||
getMissionaryDispatches: '/student/missionary/dispatches',
|
||||
getMissionaryNarratives: '/student/missionary/narratives',
|
||||
|
||||
getStudentServices: '/student/services',
|
||||
createStudentService: '/student/services',
|
||||
getStudentServiceTypes: '/student/service-types',
|
||||
getStudentServiceTitles: '/student/service-titles',
|
||||
|
||||
getStudentConsultants: '/student/consultants',
|
||||
createStudentConsultant: '/student/consultants',
|
||||
getStudentConsultantTitles: '/student/consultant-titles',
|
||||
// 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',
|
||||
|
||||
getStudentCertificates: '/student/certificates',
|
||||
downloadStudentCertificate: '/student/certificates/:id/download',
|
||||
@@ -140,18 +139,25 @@ export const endpoints = {
|
||||
showAssignmentSubmission: '/homework-submissions/:submissionId',
|
||||
|
||||
// ─── Backend-aligned: Tickets ─────────────────────────────────────────────
|
||||
// Admin "messages" UI talks to /admin/tickets.
|
||||
// Admin "consultations" UI talks to /counselor/tickets (same schema; the
|
||||
// counselor scope is the consultation pool).
|
||||
// Admin sees every type via /admin/tickets, filtered by ?type=:
|
||||
// - messages page → ?type=ticket
|
||||
// - services page → ?type=service
|
||||
// - consultations page → ?type=advise
|
||||
// Counselor is restricted to /counselor/tickets (advise-only at the backend).
|
||||
getTicketsList: '/admin/tickets',
|
||||
showTicket: '/admin/tickets/:id',
|
||||
sendTicketMessage: '/admin/tickets/:id/messages',
|
||||
changeTicketStatus: '/admin/tickets/:id/status',
|
||||
|
||||
getConsultationsList: '/counselor/tickets',
|
||||
showConsultation: '/counselor/tickets/:id',
|
||||
sendConsultationMessage: '/counselor/tickets/:id/messages',
|
||||
changeConsultationStatus: '/counselor/tickets/:id/status',
|
||||
getConsultationsList: '/admin/tickets',
|
||||
showConsultation: '/admin/tickets/:id',
|
||||
sendConsultationMessage: '/admin/tickets/:id/messages',
|
||||
changeConsultationStatus: '/admin/tickets/:id/status',
|
||||
|
||||
getCounselorTickets: '/counselor/tickets',
|
||||
showCounselorTicket: '/counselor/tickets/:id',
|
||||
sendCounselorTicketMessage: '/counselor/tickets/:id/messages',
|
||||
changeCounselorTicketStatus: '/counselor/tickets/:id/status',
|
||||
}
|
||||
|
||||
export const buildUrl = (template, params = {}) =>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { http } from '@/services/api/http'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
|
||||
export const apiGetStudentConsultants = (params) =>
|
||||
http.get(endpoints.getStudentConsultants, { params })
|
||||
|
||||
export const apiCreateStudentConsultant = (payload) =>
|
||||
http.post(endpoints.createStudentConsultant, payload)
|
||||
|
||||
export const apiGetStudentConsultantTitles = () => http.get(endpoints.getStudentConsultantTitles)
|
||||
@@ -1,12 +0,0 @@
|
||||
import { http } from '@/services/api/http'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
|
||||
export const apiGetStudentServices = (params) => http.get(endpoints.getStudentServices, { params })
|
||||
|
||||
export const apiCreateStudentService = (payload) =>
|
||||
http.post(endpoints.createStudentService, payload)
|
||||
|
||||
export const apiGetStudentServiceTypes = () => http.get(endpoints.getStudentServiceTypes)
|
||||
|
||||
export const apiGetStudentServiceTitles = (params) =>
|
||||
http.get(endpoints.getStudentServiceTitles, { params })
|
||||
@@ -0,0 +1,14 @@
|
||||
import { http } from '@/services/api/http'
|
||||
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)
|
||||
|
||||
export const apiSendStudentTicketMessage = (id, payload) =>
|
||||
http.post(buildUrl(endpoints.sendStudentTicketMessage, { id }), payload)
|
||||
@@ -1,86 +0,0 @@
|
||||
export const adminConsultations = [
|
||||
{
|
||||
id: 387,
|
||||
requestId: '387',
|
||||
user: {
|
||||
id: 201,
|
||||
firstName: 'علیرضا',
|
||||
lastName: 'محمدی',
|
||||
fullName: 'علیرضا محمدی',
|
||||
avatarUrl: '',
|
||||
},
|
||||
status: 'answered',
|
||||
statusLabel: 'پاسخ داده شده',
|
||||
createdAt: '2026-05-12T13:54:00.000Z',
|
||||
faCreatedAt: '۱۴۰۵/۰۲/۲۲',
|
||||
faCreatedTime: '۱۳:۵۴',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'user',
|
||||
text: 'سلام، در خصوص انتخاب رشته مشاوره میخواستم.',
|
||||
time: '۱۳:۵۴',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: 'admin',
|
||||
text: 'سلام و وقت بخیر، لطفا رشته فعلی و علاقهمندیهای خود را بفرمایید.',
|
||||
time: '۱۴:۰۲',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 388,
|
||||
requestId: '388',
|
||||
user: {
|
||||
id: 202,
|
||||
firstName: 'زهرا',
|
||||
lastName: 'کریمی',
|
||||
fullName: 'زهرا کریمی',
|
||||
avatarUrl: '',
|
||||
},
|
||||
status: 'in_progress',
|
||||
statusLabel: 'در حال گفتگو',
|
||||
createdAt: '2026-05-11T09:30:00.000Z',
|
||||
faCreatedAt: '۱۴۰۵/۰۲/۲۱',
|
||||
faCreatedTime: '۰۹:۳۰',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'user',
|
||||
text: 'برای ادامه تحصیل در سطح ۳ راهنمایی نیاز دارم.',
|
||||
time: '۰۹:۳۰',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 389,
|
||||
requestId: '389',
|
||||
user: {
|
||||
id: 203,
|
||||
firstName: 'محمد',
|
||||
lastName: 'حسینی',
|
||||
fullName: 'محمد حسینی',
|
||||
avatarUrl: '',
|
||||
},
|
||||
status: 'closed',
|
||||
statusLabel: 'بسته شده',
|
||||
createdAt: '2026-05-09T16:10:00.000Z',
|
||||
faCreatedAt: '۱۴۰۵/۰۲/۱۹',
|
||||
faCreatedTime: '۱۶:۱۰',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'user',
|
||||
text: 'مشاوره دریافت شد، با تشکر.',
|
||||
time: '۱۶:۱۰',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: 'admin',
|
||||
text: 'موفق باشید.',
|
||||
time: '۱۶:۱۲',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,9 +1,10 @@
|
||||
// Shape mirrors backend Postman doc for /admin/tickets:
|
||||
// ticket: { id, student_id, assigned_to_user_id, target_role, status, subject,
|
||||
// created_at, student, assignee, messages: [{ id, ticket_id,
|
||||
// sender_id, message, created_at, sender }] }
|
||||
// Field names use camelCase here (snake_case bridge happens at the HTTP layer
|
||||
// when we swap the mock for the real backend).
|
||||
// ticket: { id, student_id, assigned_to_user_id, type, category, status,
|
||||
// subject, created_at, media: [],
|
||||
// student, assignee,
|
||||
// messages: [{ id, ticket_id, sender_id, message, created_at, sender }] }
|
||||
// Fixture uses camelCase — the mock adapter returns it as-is, mimicking what
|
||||
// the FE sees post-camelize when wired to the real backend.
|
||||
|
||||
const makeUser = (overrides) => ({
|
||||
id: overrides.id,
|
||||
@@ -46,6 +47,26 @@ const studentAli = makeUser({
|
||||
createdAt: '2026-02-10T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const studentReza = makeUser({
|
||||
id: 104,
|
||||
name: 'رضا حسینی',
|
||||
email: 'reza@example.com',
|
||||
phone: '+989124444444',
|
||||
roles: ['student'],
|
||||
avatarUrl: '',
|
||||
createdAt: '2026-02-12T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const studentZahra = makeUser({
|
||||
id: 105,
|
||||
name: 'زهرا کریمی',
|
||||
email: 'zahra@example.com',
|
||||
phone: '+989125555555',
|
||||
roles: ['student'],
|
||||
avatarUrl: '',
|
||||
createdAt: '2026-02-15T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const adminUser = makeUser({
|
||||
id: 1,
|
||||
name: 'مدیر سامانه',
|
||||
@@ -54,15 +75,25 @@ const adminUser = makeUser({
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
const counselorUser = makeUser({
|
||||
id: 7,
|
||||
name: 'مشاور سامانه',
|
||||
email: 'counselor@example.com',
|
||||
roles: ['counselor'],
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
})
|
||||
|
||||
export const adminTickets = [
|
||||
{
|
||||
id: 401,
|
||||
studentId: studentJane.id,
|
||||
assignedToUserId: adminUser.id,
|
||||
targetRole: 'admin',
|
||||
type: 'ticket',
|
||||
category: 'enrollment',
|
||||
status: 'answered',
|
||||
subject: 'پیگیری وضعیت ثبتنام',
|
||||
createdAt: '2026-05-09T10:30:00.000Z',
|
||||
media: [],
|
||||
student: studentJane,
|
||||
assignee: adminUser,
|
||||
messages: [
|
||||
@@ -96,10 +127,12 @@ export const adminTickets = [
|
||||
id: 402,
|
||||
studentId: studentMaryam.id,
|
||||
assignedToUserId: null,
|
||||
targetRole: 'admin',
|
||||
type: 'ticket',
|
||||
category: 'schedule',
|
||||
status: 'open',
|
||||
subject: 'سوال درباره جلسه آموزشی',
|
||||
createdAt: '2026-05-08T09:15:00.000Z',
|
||||
media: [],
|
||||
student: studentMaryam,
|
||||
assignee: null,
|
||||
messages: [
|
||||
@@ -117,10 +150,12 @@ export const adminTickets = [
|
||||
id: 403,
|
||||
studentId: studentAli.id,
|
||||
assignedToUserId: adminUser.id,
|
||||
targetRole: 'admin',
|
||||
type: 'ticket',
|
||||
category: 'billing',
|
||||
status: 'closed',
|
||||
subject: 'درخواست بستن تیکت',
|
||||
createdAt: '2026-05-07T14:10:00.000Z',
|
||||
media: [],
|
||||
student: studentAli,
|
||||
assignee: adminUser,
|
||||
messages: [
|
||||
@@ -142,7 +177,138 @@ export const adminTickets = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 501,
|
||||
studentId: studentReza.id,
|
||||
assignedToUserId: adminUser.id,
|
||||
type: 'service',
|
||||
category: 'loan',
|
||||
status: 'open',
|
||||
subject: 'درخواست وام تحصیلی',
|
||||
createdAt: '2026-05-12T13:54:00.000Z',
|
||||
media: [],
|
||||
student: studentReza,
|
||||
assignee: adminUser,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
ticketId: 501,
|
||||
senderId: studentReza.id,
|
||||
message: 'برای ادامه تحصیل به وام نیاز دارم.',
|
||||
createdAt: '2026-05-12T13:54:00.000Z',
|
||||
sender: studentReza,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 502,
|
||||
studentId: studentJane.id,
|
||||
assignedToUserId: null,
|
||||
type: 'service',
|
||||
category: 'insurance',
|
||||
status: 'answered',
|
||||
subject: 'درخواست بیمه دانشجویی',
|
||||
createdAt: '2026-05-10T08:20:00.000Z',
|
||||
media: [],
|
||||
student: studentJane,
|
||||
assignee: null,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
ticketId: 502,
|
||||
senderId: studentJane.id,
|
||||
message: 'لطفا اطلاعات بیمه را ارسال کنید.',
|
||||
createdAt: '2026-05-10T08:20:00.000Z',
|
||||
sender: studentJane,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 601,
|
||||
studentId: studentZahra.id,
|
||||
assignedToUserId: counselorUser.id,
|
||||
type: 'advise',
|
||||
category: 'education',
|
||||
status: 'answered',
|
||||
subject: 'مشاوره انتخاب رشته',
|
||||
createdAt: '2026-05-12T13:54:00.000Z',
|
||||
media: [],
|
||||
student: studentZahra,
|
||||
assignee: counselorUser,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
ticketId: 601,
|
||||
senderId: studentZahra.id,
|
||||
message: 'سلام، در خصوص انتخاب رشته مشاوره میخواستم.',
|
||||
createdAt: '2026-05-12T13:54:00.000Z',
|
||||
sender: studentZahra,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ticketId: 601,
|
||||
senderId: counselorUser.id,
|
||||
message: 'سلام و وقت بخیر، لطفا رشته فعلی و علاقهمندیهای خود را بفرمایید.',
|
||||
createdAt: '2026-05-12T14:02:00.000Z',
|
||||
sender: counselorUser,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 602,
|
||||
studentId: studentMaryam.id,
|
||||
assignedToUserId: null,
|
||||
type: 'advise',
|
||||
category: 'career',
|
||||
status: 'open',
|
||||
subject: 'مشاوره مسیر شغلی',
|
||||
createdAt: '2026-05-11T09:30:00.000Z',
|
||||
media: [],
|
||||
student: studentMaryam,
|
||||
assignee: null,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
ticketId: 602,
|
||||
senderId: studentMaryam.id,
|
||||
message: 'برای ادامه تحصیل در سطح ۳ راهنمایی نیاز دارم.',
|
||||
createdAt: '2026-05-11T09:30:00.000Z',
|
||||
sender: studentMaryam,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 603,
|
||||
studentId: studentAli.id,
|
||||
assignedToUserId: counselorUser.id,
|
||||
type: 'advise',
|
||||
category: 'personal',
|
||||
status: 'closed',
|
||||
subject: 'مشاوره فردی',
|
||||
createdAt: '2026-05-09T16:10:00.000Z',
|
||||
media: [],
|
||||
student: studentAli,
|
||||
assignee: counselorUser,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
ticketId: 603,
|
||||
senderId: studentAli.id,
|
||||
message: 'مشاوره دریافت شد، با تشکر.',
|
||||
createdAt: '2026-05-09T16:10:00.000Z',
|
||||
sender: studentAli,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ticketId: 603,
|
||||
senderId: counselorUser.id,
|
||||
message: 'موفق باشید.',
|
||||
createdAt: '2026-05-09T16:12:00.000Z',
|
||||
sender: counselorUser,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// The signed-in admin used as `sender` when a message is posted from the FE.
|
||||
export const currentAdminUser = adminUser
|
||||
export const currentCounselorUser = counselorUser
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export const studentConsultantTitles = [
|
||||
{ value: 'course-talk', label: 'گفتگو درباره دوره' },
|
||||
{ value: 'academic', label: 'مشاوره تحصیلی' },
|
||||
{ value: 'career', label: 'مشاوره شغلی' },
|
||||
{ value: 'psychology', label: 'مشاوره روانشناسی' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
]
|
||||
|
||||
const STATUS_LABELS = {
|
||||
pending: 'در حال بررسی',
|
||||
approved: 'پاسخ داده شده',
|
||||
rejected: 'رد شده',
|
||||
closed: 'بسته شده',
|
||||
}
|
||||
|
||||
export const studentConsultants = [
|
||||
{
|
||||
id: 'c-1',
|
||||
requestNumber: 215,
|
||||
title: 'درخواست مشاوره',
|
||||
topicLabel: 'گفتگو درباره دوره',
|
||||
status: 'pending',
|
||||
statusLabel: STATUS_LABELS.pending,
|
||||
description: 'سوال در مورد محتوای دوره',
|
||||
timeLabel: '12:53',
|
||||
dateLabel: '1404/12/21',
|
||||
},
|
||||
{
|
||||
id: 'c-2',
|
||||
requestNumber: 198,
|
||||
title: 'درخواست مشاوره',
|
||||
topicLabel: 'مشاوره شغلی',
|
||||
status: 'approved',
|
||||
statusLabel: STATUS_LABELS.approved,
|
||||
description: 'راهنمایی برای انتخاب مسیر شغلی',
|
||||
timeLabel: '09:12',
|
||||
dateLabel: '1404/11/05',
|
||||
},
|
||||
{
|
||||
id: 'c-3',
|
||||
requestNumber: 174,
|
||||
title: 'درخواست مشاوره',
|
||||
topicLabel: 'مشاوره تحصیلی',
|
||||
status: 'rejected',
|
||||
statusLabel: STATUS_LABELS.rejected,
|
||||
description: 'برنامهریزی درسی',
|
||||
timeLabel: '16:40',
|
||||
dateLabel: '1404/10/02',
|
||||
},
|
||||
]
|
||||
@@ -1,61 +0,0 @@
|
||||
export const studentServiceTypes = [
|
||||
{ value: 'loan', label: 'وام' },
|
||||
{ value: 'consultation', label: 'مشاوره' },
|
||||
{ value: 'insurance', label: 'بیمه' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
]
|
||||
|
||||
export const studentServiceTitlesByType = {
|
||||
loan: [
|
||||
{ value: 'tuition-loan', label: 'وام شهریه' },
|
||||
{ value: 'living-loan', label: 'وام معیشت' },
|
||||
],
|
||||
consultation: [
|
||||
{ value: 'course-talk', label: 'گفتگو درباره دوره' },
|
||||
{ value: 'career-talk', label: 'مشاوره شغلی' },
|
||||
],
|
||||
insurance: [{ value: 'student-insurance', label: 'بیمه دانشجویی' }],
|
||||
other: [{ value: 'other-issue', label: 'موضوع دیگر' }],
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
approved: 'تاییــد شـــــده',
|
||||
pending: 'در انتظار بررسی',
|
||||
rejected: 'رد شده',
|
||||
}
|
||||
|
||||
export const studentServices = [
|
||||
{
|
||||
id: 's-1',
|
||||
requestNumber: 387,
|
||||
title: 'درخواست وام',
|
||||
typeKey: 'loan',
|
||||
typeLabel: 'وام',
|
||||
status: 'approved',
|
||||
statusLabel: STATUS_LABELS.approved,
|
||||
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ',
|
||||
createdAt: '1404/05/12',
|
||||
},
|
||||
{
|
||||
id: 's-2',
|
||||
requestNumber: 44,
|
||||
title: 'درخواست وام',
|
||||
typeKey: 'loan',
|
||||
typeLabel: 'وام',
|
||||
status: 'pending',
|
||||
statusLabel: STATUS_LABELS.pending,
|
||||
description: 'درخواست شهریه ترم جدید',
|
||||
createdAt: '1404/06/02',
|
||||
},
|
||||
{
|
||||
id: 's-3',
|
||||
requestNumber: 18,
|
||||
title: 'گفتگو درباره دوره',
|
||||
typeKey: 'consultation',
|
||||
typeLabel: 'مشاوره',
|
||||
status: 'rejected',
|
||||
statusLabel: STATUS_LABELS.rejected,
|
||||
description: 'سوال در مورد محتوای دوره',
|
||||
createdAt: '1404/04/22',
|
||||
},
|
||||
]
|
||||
@@ -1,56 +0,0 @@
|
||||
import { register } from '@/services/mock/registry'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
import { adminConsultations } from '@/services/mock/fixtures/admin-consultations'
|
||||
import {
|
||||
filterDateRange,
|
||||
filterItems,
|
||||
findOrThrow,
|
||||
isoNow,
|
||||
makeId,
|
||||
paginate,
|
||||
updateById,
|
||||
} from '@/services/mock/helpers'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
in_progress: 'در حال گفتگو',
|
||||
answered: 'پاسخ داده شده',
|
||||
closed: 'بسته شده',
|
||||
}
|
||||
|
||||
register('GET', endpoints.getConsultationsList, ({ query }) => {
|
||||
let list = filterItems(adminConsultations, query, {
|
||||
status: 'eq',
|
||||
userName: (item, v) =>
|
||||
`${item.user?.firstName || ''} ${item.user?.lastName || ''}`
|
||||
.toLowerCase()
|
||||
.includes(String(v).toLowerCase()),
|
||||
})
|
||||
list = filterDateRange(list, query)
|
||||
return paginate(list, query)
|
||||
})
|
||||
|
||||
register('GET', endpoints.showConsultation, ({ params }) => ({
|
||||
data: findOrThrow(adminConsultations, params.id),
|
||||
}))
|
||||
|
||||
register('POST', endpoints.sendConsultationMessage, ({ params, data }) => {
|
||||
const consultation = findOrThrow(adminConsultations, params.id)
|
||||
const message = {
|
||||
id: makeId(),
|
||||
sender: 'admin',
|
||||
text: data.text || '',
|
||||
time: 'همین الان',
|
||||
sentAt: isoNow(),
|
||||
}
|
||||
consultation.messages = [...(consultation.messages || []), message]
|
||||
consultation.status = 'answered'
|
||||
consultation.statusLabel = STATUS_LABELS.answered
|
||||
return { data: consultation }
|
||||
})
|
||||
|
||||
register('POST', endpoints.changeConsultationStatus, ({ params, data }) => ({
|
||||
data: updateById(adminConsultations, params.id, {
|
||||
status: data.status,
|
||||
statusLabel: STATUS_LABELS[data.status] || data.status,
|
||||
}),
|
||||
}))
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
register('GET', endpoints.getTicketsList, ({ query }) => {
|
||||
let list = filterItems(adminTickets, query, {
|
||||
type: 'eq',
|
||||
status: 'eq',
|
||||
subject: (item, v) =>
|
||||
String(item.subject || '')
|
||||
@@ -51,7 +52,7 @@ register('POST', endpoints.sendTicketMessage, ({ params, data }) => {
|
||||
}
|
||||
ticket.messages = [...(ticket.messages || []), message]
|
||||
ticket.status = 'answered'
|
||||
ticket.assigneeId = currentAdminUser.id
|
||||
ticket.assignedToUserId = currentAdminUser.id
|
||||
ticket.assignee = currentAdminUser
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { register } from '@/services/mock/registry'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
import { adminTickets, currentCounselorUser } from '@/services/mock/fixtures/admin-tickets'
|
||||
import {
|
||||
filterDateRange,
|
||||
filterItems,
|
||||
findOrThrow,
|
||||
isoNow,
|
||||
makeId,
|
||||
paginate,
|
||||
updateById,
|
||||
} from '@/services/mock/helpers'
|
||||
|
||||
// Counselor scope: backend hides every ticket whose type !== 'advise'.
|
||||
const counselorPool = () => adminTickets.filter((t) => t.type === 'advise')
|
||||
|
||||
register('GET', endpoints.getCounselorTickets, ({ query }) => {
|
||||
let list = filterItems(counselorPool(), query, {
|
||||
type: 'eq',
|
||||
status: 'eq',
|
||||
userName: (item, v) =>
|
||||
String(item.student?.name || '')
|
||||
.toLowerCase()
|
||||
.includes(String(v).toLowerCase()),
|
||||
})
|
||||
list = filterDateRange(list, query)
|
||||
const { data: items, meta } = paginate(list, query)
|
||||
return { success: true, message: 'OK', data: items, meta }
|
||||
})
|
||||
|
||||
register('GET', endpoints.showCounselorTicket, ({ params }) => ({
|
||||
success: true,
|
||||
message: 'OK',
|
||||
data: findOrThrow(counselorPool(), params.id),
|
||||
}))
|
||||
|
||||
register('POST', endpoints.sendCounselorTicketMessage, ({ params, data }) => {
|
||||
const ticket = findOrThrow(counselorPool(), params.id)
|
||||
const message = {
|
||||
id: makeId(),
|
||||
ticketId: ticket.id,
|
||||
senderId: currentCounselorUser.id,
|
||||
message: data.message || '',
|
||||
createdAt: isoNow(),
|
||||
sender: currentCounselorUser,
|
||||
}
|
||||
ticket.messages = [...(ticket.messages || []), message]
|
||||
ticket.status = 'answered'
|
||||
ticket.assignedToUserId = currentCounselorUser.id
|
||||
ticket.assignee = currentCounselorUser
|
||||
return { success: true, message: 'Message posted.', data: message }
|
||||
})
|
||||
|
||||
register('PATCH', endpoints.changeCounselorTicketStatus, ({ params, data }) => ({
|
||||
success: true,
|
||||
message: 'Ticket status updated.',
|
||||
data: updateById(adminTickets, params.id, { status: data.status }),
|
||||
}))
|
||||
@@ -1,29 +0,0 @@
|
||||
import { paginate } from '@/services/mock/helpers'
|
||||
import { register } from '@/services/mock/registry'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
import {
|
||||
studentConsultantTitles,
|
||||
studentConsultants,
|
||||
} from '@/services/mock/fixtures/student-consultants'
|
||||
|
||||
register('GET', endpoints.getStudentConsultants, ({ query }) => paginate(studentConsultants, query))
|
||||
|
||||
register('POST', endpoints.createStudentConsultant, ({ data }) => {
|
||||
const id = `c-${studentConsultants.length + 1}`
|
||||
const next = {
|
||||
id,
|
||||
requestNumber: 500 + studentConsultants.length + 1,
|
||||
title: 'درخواست مشاوره',
|
||||
topicLabel:
|
||||
studentConsultantTitles.find((t) => t.value === data?.title)?.label || data?.title || '—',
|
||||
status: 'pending',
|
||||
statusLabel: 'در حال بررسی',
|
||||
description: data?.description || '',
|
||||
timeLabel: '12:00',
|
||||
dateLabel: '1404/06/12',
|
||||
}
|
||||
studentConsultants.unshift(next)
|
||||
return { data: next }
|
||||
})
|
||||
|
||||
register('GET', endpoints.getStudentConsultantTitles, () => ({ data: studentConsultantTitles }))
|
||||
@@ -1,33 +0,0 @@
|
||||
import { paginate } from '@/services/mock/helpers'
|
||||
import { register } from '@/services/mock/registry'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
import {
|
||||
studentServiceTitlesByType,
|
||||
studentServiceTypes,
|
||||
studentServices,
|
||||
} from '@/services/mock/fixtures/student-services'
|
||||
|
||||
register('GET', endpoints.getStudentServices, ({ query }) => paginate(studentServices, query))
|
||||
|
||||
register('POST', endpoints.createStudentService, ({ data }) => {
|
||||
const id = `s-${studentServices.length + 1}`
|
||||
const next = {
|
||||
id,
|
||||
requestNumber: 500 + studentServices.length + 1,
|
||||
title: data?.title || '—',
|
||||
typeKey: data?.typeKey || 'other',
|
||||
typeLabel: studentServiceTypes.find((t) => t.value === data?.typeKey)?.label || 'سایر',
|
||||
status: 'pending',
|
||||
statusLabel: 'در انتظار بررسی',
|
||||
description: data?.description || '',
|
||||
createdAt: '1404/06/12',
|
||||
}
|
||||
studentServices.unshift(next)
|
||||
return { data: next }
|
||||
})
|
||||
|
||||
register('GET', endpoints.getStudentServiceTypes, () => ({ data: studentServiceTypes }))
|
||||
|
||||
register('GET', endpoints.getStudentServiceTitles, ({ query }) => ({
|
||||
data: studentServiceTitlesByType[query.type] || [],
|
||||
}))
|
||||
@@ -0,0 +1,101 @@
|
||||
import { register } from '@/services/mock/registry'
|
||||
import { endpoints } from '@/services/api/endpoints'
|
||||
import { adminTickets } from '@/services/mock/fixtures/admin-tickets'
|
||||
import { filterItems, findOrThrow, isoNow, makeId, paginate } from '@/services/mock/helpers'
|
||||
|
||||
// Student scope: backend scopes results to the signed-in student. The mock
|
||||
// pretends every ticket whose student is studentJane (id=100) belongs to "me".
|
||||
const SELF_STUDENT_ID = 100
|
||||
|
||||
const myPool = () => adminTickets.filter((t) => t.studentId === SELF_STUDENT_ID)
|
||||
|
||||
register('GET', endpoints.getStudentTickets, ({ query }) => {
|
||||
const list = filterItems(myPool(), query, {
|
||||
type: 'eq',
|
||||
status: 'eq',
|
||||
})
|
||||
const { data: items, meta } = paginate(list, 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',
|
||||
data: findOrThrow(myPool(), params.id),
|
||||
}))
|
||||
|
||||
register('POST', endpoints.createStudentTicket, ({ data }) => {
|
||||
const me = myPool()[0]?.student || adminTickets[0].student
|
||||
const ticket = {
|
||||
id: makeId(),
|
||||
studentId: me.id,
|
||||
assignedToUserId: null,
|
||||
type: data.type || 'ticket',
|
||||
category: data.category || null,
|
||||
priority: data.priority || null,
|
||||
status: 'open',
|
||||
subject: data.subject || '',
|
||||
createdAt: isoNow(),
|
||||
media: [],
|
||||
student: me,
|
||||
assignee: null,
|
||||
messages: [
|
||||
{
|
||||
id: makeId(),
|
||||
ticketId: 0,
|
||||
senderId: me.id,
|
||||
message: data.message || '',
|
||||
createdAt: isoNow(),
|
||||
sender: me,
|
||||
},
|
||||
],
|
||||
}
|
||||
ticket.messages[0].ticketId = ticket.id
|
||||
adminTickets.unshift(ticket)
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ticket created.',
|
||||
data: {
|
||||
id: ticket.id,
|
||||
studentId: ticket.studentId,
|
||||
assignedToUserId: ticket.assignedToUserId,
|
||||
type: ticket.type,
|
||||
category: ticket.category,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
subject: ticket.subject,
|
||||
createdAt: ticket.createdAt,
|
||||
media: ticket.media,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
register('POST', endpoints.sendStudentTicketMessage, ({ params, data }) => {
|
||||
const ticket = findOrThrow(myPool(), params.id)
|
||||
const me = ticket.student
|
||||
const message = {
|
||||
id: makeId(),
|
||||
ticketId: ticket.id,
|
||||
senderId: me.id,
|
||||
message: data.message || '',
|
||||
createdAt: isoNow(),
|
||||
sender: me,
|
||||
}
|
||||
ticket.messages = [...(ticket.messages || []), message]
|
||||
return { success: true, message: 'Message posted.', data: message }
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cleanFilters } from '@/utils/clean-filters'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
apiChangeAdminServiceStatus,
|
||||
apiGetAdminServices,
|
||||
apiSendAdminServiceMessage,
|
||||
apiShowAdminService,
|
||||
} from '@/services/api/admin-services'
|
||||
|
||||
export const adminServicesKeys = {
|
||||
all: ['admin', 'services'],
|
||||
list: (filters, pagination) => ['admin', 'services', 'list', filters, pagination],
|
||||
detail: (id) => ['admin', 'services', 'detail', id],
|
||||
}
|
||||
|
||||
export const useAdminServicesListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['admin', 'services', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetAdminServices({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||
select: (response) => ({
|
||||
data: response?.data ?? [],
|
||||
meta: response?.meta,
|
||||
}),
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useAdminServiceQuery = (idRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['admin', 'services', 'detail', idRef],
|
||||
queryFn: () => apiShowAdminService(idRef.value),
|
||||
select: (response) => response?.data ?? response,
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useSendAdminServiceMessageMutation = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ id, payload }) => apiSendAdminServiceMessage(id, payload),
|
||||
})
|
||||
|
||||
export const useChangeAdminServiceStatusMutation = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ id, payload }) => apiChangeAdminServiceStatus(id, payload),
|
||||
})
|
||||
@@ -13,15 +13,21 @@ export const adminTicketsKeys = {
|
||||
detail: (id) => ['admin', 'tickets', 'detail', id],
|
||||
}
|
||||
|
||||
// Backend may wrap the list as `{success, data: [...]}` (flat) or
|
||||
// `{data: {data: [...], meta}}` (Laravel default). Normalize.
|
||||
const selectList = (response) => {
|
||||
const inner = response?.data
|
||||
if (Array.isArray(inner)) return { data: inner, meta: response?.meta }
|
||||
if (Array.isArray(inner?.data)) return { data: inner.data, meta: inner?.meta ?? response?.meta }
|
||||
return { data: [], meta: response?.meta }
|
||||
}
|
||||
|
||||
export const useAdminTicketsListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||
select: (response) => ({
|
||||
data: response?.data ?? [],
|
||||
meta: response?.meta,
|
||||
}),
|
||||
select: selectList,
|
||||
...options,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cleanFilters } from '@/utils/clean-filters'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
apiChangeCounselorTicketStatus,
|
||||
apiGetCounselorTickets,
|
||||
apiSendCounselorTicketMessage,
|
||||
apiShowCounselorTicket,
|
||||
} from '@/services/api/counselor-tickets'
|
||||
|
||||
export const counselorTicketsKeys = {
|
||||
all: ['counselor', 'tickets'],
|
||||
list: (filters, pagination) => ['counselor', 'tickets', 'list', filters, pagination],
|
||||
detail: (id) => ['counselor', 'tickets', 'detail', id],
|
||||
}
|
||||
|
||||
export const useCounselorTicketsListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['counselor', 'tickets', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetCounselorTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||
select: (response) => ({
|
||||
data: response?.data ?? [],
|
||||
meta: response?.meta,
|
||||
}),
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useCounselorTicketQuery = (idRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['counselor', 'tickets', 'detail', idRef],
|
||||
queryFn: () => apiShowCounselorTicket(idRef.value),
|
||||
select: (response) => response?.data ?? response,
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useSendCounselorTicketMessageMutation = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ id, payload }) => apiSendCounselorTicketMessage(id, payload),
|
||||
})
|
||||
|
||||
export const useChangeCounselorTicketStatusMutation = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ id, payload }) => apiChangeCounselorTicketStatus(id, payload),
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { cleanFilters } from '@/utils/clean-filters'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
apiCreateStudentConsultant,
|
||||
apiGetStudentConsultantTitles,
|
||||
apiGetStudentConsultants,
|
||||
} from '@/services/api/student-consultants'
|
||||
|
||||
export const studentConsultantsKeys = {
|
||||
all: ['student', 'consultants'],
|
||||
list: (filters, pagination) => ['student', 'consultants', 'list', filters, pagination],
|
||||
titles: ['student', 'consultant-titles'],
|
||||
}
|
||||
|
||||
export const useStudentConsultantsQuery = (filtersRef, paginationRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['student', 'consultants', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetStudentConsultants({
|
||||
...cleanFilters(filtersRef.value),
|
||||
...paginationRef.value,
|
||||
}),
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useStudentConsultantTitlesQuery = (options = {}) =>
|
||||
useQuery({
|
||||
queryKey: studentConsultantsKeys.titles,
|
||||
queryFn: () => apiGetStudentConsultantTitles(),
|
||||
select: (response) => response?.data ?? [],
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useCreateStudentConsultantMutation = () =>
|
||||
useMutation({ mutationFn: (payload) => apiCreateStudentConsultant(payload) })
|
||||
@@ -1,45 +0,0 @@
|
||||
import { cleanFilters } from '@/utils/clean-filters'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
apiCreateStudentService,
|
||||
apiGetStudentServiceTitles,
|
||||
apiGetStudentServiceTypes,
|
||||
apiGetStudentServices,
|
||||
} from '@/services/api/student-services'
|
||||
|
||||
export const studentServicesKeys = {
|
||||
all: ['student', 'services'],
|
||||
list: (filters, pagination) => ['student', 'services', 'list', filters, pagination],
|
||||
types: ['student', 'service-types'],
|
||||
titles: (typeKey) => ['student', 'service-titles', typeKey],
|
||||
}
|
||||
|
||||
export const useStudentServicesQuery = (filtersRef, paginationRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['student', 'services', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetStudentServices({
|
||||
...cleanFilters(filtersRef.value),
|
||||
...paginationRef.value,
|
||||
}),
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useStudentServiceTypesQuery = (options = {}) =>
|
||||
useQuery({
|
||||
queryKey: studentServicesKeys.types,
|
||||
queryFn: () => apiGetStudentServiceTypes(),
|
||||
select: (response) => response?.data ?? [],
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useStudentServiceTitlesQuery = (typeKeyRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['student', 'service-titles', typeKeyRef],
|
||||
queryFn: () => apiGetStudentServiceTitles({ type: typeKeyRef.value }),
|
||||
select: (response) => response?.data ?? [],
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useCreateStudentServiceMutation = () =>
|
||||
useMutation({ mutationFn: (payload) => apiCreateStudentService(payload) })
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cleanFilters } from '@/utils/clean-filters'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
apiCreateStudentTicket,
|
||||
apiGetStudentTickets,
|
||||
apiGetStudentTicketStats,
|
||||
apiSendStudentTicketMessage,
|
||||
apiShowStudentTicket,
|
||||
} from '@/services/api/student-tickets'
|
||||
|
||||
export const studentTicketsKeys = {
|
||||
all: ['student', 'tickets'],
|
||||
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 = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['student', 'tickets', 'list', filtersRef, paginationRef],
|
||||
queryFn: () =>
|
||||
apiGetStudentTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||
select: (response) => ({
|
||||
data: response?.data ?? [],
|
||||
meta: response?.meta,
|
||||
}),
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useStudentTicketQuery = (idRef, options = {}) =>
|
||||
useQuery({
|
||||
queryKey: ['student', 'tickets', 'detail', idRef],
|
||||
queryFn: () => apiShowStudentTicket(idRef.value),
|
||||
select: (response) => response?.data ?? response,
|
||||
...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) })
|
||||
|
||||
export const useSendStudentTicketMessageMutation = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ id, payload }) => apiSendStudentTicketMessage(id, payload),
|
||||
})
|
||||
Reference in New Issue
Block a user