fix: ticket
This commit is contained in:
@@ -15,6 +15,7 @@ import { useGetMeQuery } from '@/services/query/auth'
|
|||||||
import PublicLayout from '@/layouts/PublicLayout.vue'
|
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||||
import StudentLayout from '@/layouts/StudentLayout.vue'
|
import StudentLayout from '@/layouts/StudentLayout.vue'
|
||||||
import ConfirmModal from '@/components/ConfirmModal.vue'
|
import ConfirmModal from '@/components/ConfirmModal.vue'
|
||||||
|
import CounselorLayout from '@/layouts/CounselorLayout.vue'
|
||||||
import { tokenService } from '@/services/api/token-service'
|
import { tokenService } from '@/services/api/token-service'
|
||||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ const layouts = {
|
|||||||
public: PublicLayout,
|
public: PublicLayout,
|
||||||
admin: AdminLayout,
|
admin: AdminLayout,
|
||||||
student: StudentLayout,
|
student: StudentLayout,
|
||||||
|
counselor: CounselorLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
|
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
|
||||||
|
|||||||
+4
-13
@@ -165,6 +165,10 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
|
|||||||
optional: 'اختیاری',
|
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({
|
export const TICKET_STATUS = Object.freeze({
|
||||||
open: 'باز',
|
open: 'باز',
|
||||||
answered: 'پاسخ داده شده',
|
answered: 'پاسخ داده شده',
|
||||||
@@ -182,22 +186,9 @@ export const PRIORITY_STATUS = Object.freeze({
|
|||||||
low: 'پایین',
|
low: 'پایین',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SERVICE_STATUS = Object.freeze({
|
|
||||||
approved: 'تایید شده',
|
|
||||||
pending: 'در انتظار بررسی',
|
|
||||||
in_progress: 'در حال انجام',
|
|
||||||
completed: 'تکمیل شده',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SERVICE_TYPE = Object.freeze({
|
export const SERVICE_TYPE = Object.freeze({
|
||||||
loan: 'وام',
|
loan: 'وام',
|
||||||
consultation: 'مشاوره',
|
consultation: 'مشاوره',
|
||||||
educational: 'آموزشی',
|
educational: 'آموزشی',
|
||||||
other: 'سایر',
|
other: 'سایر',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CONSULTATION_STATUS = Object.freeze({
|
|
||||||
open: 'در حال گفتگو',
|
|
||||||
answered: 'پاسخ داده شده',
|
|
||||||
closed: 'بسته شده',
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -17,19 +17,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="consultation-item__meta">
|
<div class="consultation-item__meta">
|
||||||
<div class="consultation-item__pill">
|
<Badge variant="neutral" size="sm" label="تاریخ پیام :" :value="createdAt" />
|
||||||
<span class="consultation-item__pill-label">تاریخ پیام :</span>
|
<Badge
|
||||||
<span class="consultation-item__pill-value">{{ createdAt }}</span>
|
:variant="statusVariant"
|
||||||
</div>
|
size="sm"
|
||||||
<button
|
dot
|
||||||
type="button"
|
clickable
|
||||||
class="consultation-item__status"
|
:value="statusLabel"
|
||||||
:class="`consultation-item__status--${consultation.status || 'open'}`"
|
|
||||||
@click="onStatusClick"
|
@click="onStatusClick"
|
||||||
>
|
/>
|
||||||
<span class="consultation-item__status-dot" />
|
|
||||||
<span>{{ statusLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="consultation-item__actions">
|
<div class="consultation-item__actions">
|
||||||
@@ -55,12 +51,19 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
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 BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||||
|
|
||||||
|
const STATUS_VARIANT = {
|
||||||
|
open: 'primary',
|
||||||
|
answered: 'success',
|
||||||
|
closed: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
consultation: { type: Object, required: true },
|
consultation: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
@@ -69,7 +72,9 @@ const emit = defineEmits(['show-details', 'change-status'])
|
|||||||
|
|
||||||
const userName = computed(() => props.consultation.student?.name || '—')
|
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) || '—')
|
const createdAt = computed(() => formatJalaaliDate(props.consultation.createdAt) || '—')
|
||||||
|
|
||||||
@@ -87,7 +92,7 @@ const onStatusClick = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = computed(() =>
|
const menuItems = computed(() =>
|
||||||
Object.entries(CONSULTATION_STATUS)
|
Object.entries(TICKET_STATUS)
|
||||||
.filter(([value]) => value !== props.consultation.status)
|
.filter(([value]) => value !== props.consultation.status)
|
||||||
.map(([value, label]) => ({
|
.map(([value, label]) => ({
|
||||||
key: `status-${value}`,
|
key: `status-${value}`,
|
||||||
@@ -183,72 +188,6 @@ const menuItems = computed(() =>
|
|||||||
flex: 1 1 45%;
|
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 {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -46,8 +46,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { TICKET_STATUS } from '@/enums'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { CONSULTATION_STATUS } from '@/enums'
|
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import CircleButton from '@/components/CircleButton.vue'
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
@@ -76,7 +76,7 @@ watch(
|
|||||||
|
|
||||||
const todayIso = new Date().toISOString()
|
const todayIso = new Date().toISOString()
|
||||||
|
|
||||||
const statusOptions = Object.entries(CONSULTATION_STATUS).map(([value, label]) => ({
|
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ import {
|
|||||||
const { openModal, isModal } = useModal()
|
const { openModal, isModal } = useModal()
|
||||||
const queryClient = useQueryClient()
|
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 { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
const { data, isLoading } = useAdminConsultationsListQuery(filters, pagination, {
|
const { data, isLoading } = useAdminConsultationsListQuery(filters, pagination, {
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const consultations = computed(() => data.value?.data ?? [])
|
const consultations = computed(() => data.value?.data?.items ?? [])
|
||||||
const paginationMeta = computed(() => ({
|
const paginationMeta = computed(() => ({
|
||||||
page: pagination.value.page,
|
page: pagination.value.page,
|
||||||
perPage: pagination.value.perPage,
|
perPage: pagination.value.perPage,
|
||||||
|
|||||||
@@ -14,17 +14,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ticket-item__meta">
|
<div class="ticket-item__meta">
|
||||||
<span class="ticket-item__status" :class="`ticket-item__status--${ticket.status || 'open'}`">
|
<Badge :variant="statusVariant" size="sm" :value="statusLabel" />
|
||||||
{{ statusLabel }}
|
<Badge variant="neutral" size="sm" label="تاریخ ثبت:" :value="createdAt" />
|
||||||
</span>
|
<Badge v-if="createdTime" variant="neutral" size="sm" label="ساعت:" :value="createdTime" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ticket-item__actions">
|
<div class="ticket-item__actions">
|
||||||
@@ -44,10 +36,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { TICKET_STATUS } from '@/enums'
|
import { TICKET_STATUS } from '@/enums'
|
||||||
|
import Badge from '@/components/Badge.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
|
|
||||||
|
const STATUS_VARIANT = {
|
||||||
|
open: 'warning',
|
||||||
|
answered: 'primary',
|
||||||
|
closed: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
ticket: { type: Object, required: true },
|
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 statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
|
||||||
|
|
||||||
|
const statusVariant = computed(() => STATUS_VARIANT[props.ticket.status] || 'neutral')
|
||||||
|
|
||||||
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
||||||
|
|
||||||
const createdTime = computed(() => {
|
const createdTime = computed(() => {
|
||||||
@@ -147,53 +148,6 @@ const createdTime = computed(() => {
|
|||||||
flex: 1 1 41%;
|
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 {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import TicketDetailsModal from '@/features/admin/messages/components/modals/Tick
|
|||||||
|
|
||||||
const { openModal, isModal } = useModal()
|
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 { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
const { data, isLoading } = useAdminTicketsListQuery(filters, pagination, {
|
const { data, isLoading } = useAdminTicketsListQuery(filters, pagination, {
|
||||||
|
|||||||
@@ -17,25 +17,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="service-item__meta">
|
<div class="service-item__meta">
|
||||||
<div class="service-item__pill">
|
<Badge variant="neutral" size="sm" icon="file" label="نوع خدمت :" :value="typeLabel" />
|
||||||
<SvgIcon name="file" :size="14" color="#535353" />
|
<Badge variant="neutral" size="sm" icon="calendar" label="تاریخ پیام :" :value="createdAt" />
|
||||||
<span class="service-item__pill-label">نوع خدمت :</span>
|
<Badge
|
||||||
<span class="service-item__pill-value">{{ typeLabel }}</span>
|
:variant="statusVariant"
|
||||||
</div>
|
size="sm"
|
||||||
<div class="service-item__pill">
|
dot
|
||||||
<SvgIcon name="calendar" :size="14" color="#535353" />
|
clickable
|
||||||
<span class="service-item__pill-label">تاریخ پیام :</span>
|
:value="statusLabel"
|
||||||
<span class="service-item__pill-value">{{ createdAt }}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="service-item__status"
|
|
||||||
:class="`service-item__status--${service.status || 'pending'}`"
|
|
||||||
@click="onStatusClick"
|
@click="onStatusClick"
|
||||||
>
|
/>
|
||||||
<span class="service-item__status-dot" />
|
|
||||||
<span>{{ statusLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="service-item__actions">
|
<div class="service-item__actions">
|
||||||
@@ -61,12 +52,23 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import Badge from '@/components/Badge.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums'
|
import { SERVICE_TYPE, TICKET_STATUS } from '@/enums'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
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({
|
const props = defineProps({
|
||||||
service: { type: Object, required: true },
|
service: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
@@ -82,9 +84,11 @@ const userName = computed(() => {
|
|||||||
const typeLabel = computed(() => props.service.typeLabel || SERVICE_TYPE[props.service.type] || '—')
|
const typeLabel = computed(() => props.service.typeLabel || SERVICE_TYPE[props.service.type] || '—')
|
||||||
|
|
||||||
const statusLabel = computed(
|
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(() => {
|
const createdAt = computed(() => {
|
||||||
if (props.service.faCreatedAt) {
|
if (props.service.faCreatedAt) {
|
||||||
return props.service.faCreatedTime
|
return props.service.faCreatedTime
|
||||||
@@ -108,7 +112,7 @@ const onStatusClick = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = computed(() =>
|
const menuItems = computed(() =>
|
||||||
Object.entries(SERVICE_STATUS)
|
Object.entries(TICKET_STATUS)
|
||||||
.filter(([value]) => value !== props.service.status)
|
.filter(([value]) => value !== props.service.status)
|
||||||
.map(([value, label]) => ({
|
.map(([value, label]) => ({
|
||||||
key: `status-${value}`,
|
key: `status-${value}`,
|
||||||
@@ -204,77 +208,6 @@ const menuItems = computed(() =>
|
|||||||
flex: 1 1 45%;
|
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 {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { SERVICE_STATUS } from '@/enums'
|
import { TICKET_STATUS } from '@/enums'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
@@ -76,7 +76,7 @@ watch(
|
|||||||
|
|
||||||
const todayIso = new Date().toISOString()
|
const todayIso = new Date().toISOString()
|
||||||
|
|
||||||
const statusOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
|
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
text="ارسال"
|
text="ارسال"
|
||||||
custom-class="send-message__submit"
|
custom-class="send-message__submit"
|
||||||
:disabled="!canSubmit"
|
:disabled="!canSubmit"
|
||||||
|
:loading="sendMutation.isPending.value"
|
||||||
>
|
>
|
||||||
<template #appendIcon>
|
<template #appendIcon>
|
||||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||||
@@ -66,19 +67,34 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { toast } from 'vue3-toastify'
|
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 BasicModal from '@/components/BasicModal.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
|
import {
|
||||||
|
adminServicesKeys,
|
||||||
|
useSendAdminServiceMessageMutation,
|
||||||
|
} from '@/services/query/admin-services'
|
||||||
|
|
||||||
defineOptions({ name: 'SendMessageModal' })
|
defineOptions({ name: 'SendMessageModal' })
|
||||||
|
|
||||||
const categoryOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
|
const { getModal } = useModal()
|
||||||
value,
|
const queryClient = useQueryClient()
|
||||||
label,
|
|
||||||
}))
|
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 = [
|
const priorityOptions = [
|
||||||
{ value: 'very_urgent', label: 'بسیار فوری' },
|
{ value: 'very_urgent', label: 'بسیار فوری' },
|
||||||
@@ -97,8 +113,18 @@ const canSubmit = computed(
|
|||||||
() => form.value.category && form.value.priority && form.value.description.trim().length > 0
|
() => form.value.category && form.value.priority && form.value.description.trim().length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
const onSubmit = (close) => {
|
const sendMutation = useSendAdminServiceMessageMutation()
|
||||||
if (!canSubmit.value) return
|
|
||||||
|
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('پیام با موفقیت ارسال شد')
|
toast.success('پیام با موفقیت ارسال شد')
|
||||||
close?.()
|
close?.()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,55 +7,81 @@
|
|||||||
min-width="auto"
|
min-width="auto"
|
||||||
:show-close-button="true"
|
:show-close-button="true"
|
||||||
>
|
>
|
||||||
<template #default="{ data }">
|
<template #default>
|
||||||
<div class="service-details">
|
<div class="service-details">
|
||||||
<div class="service-details__row service-details__row--two">
|
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="3" :cols-per-row="1" />
|
||||||
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle(data)" />
|
<template v-else>
|
||||||
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel(data)" />
|
<div class="service-details__row service-details__row--two">
|
||||||
</div>
|
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle" />
|
||||||
|
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="service-details__row">
|
<div class="service-details__row">
|
||||||
<LineInfoBlock title="درخواست" :desc="requestText(data)" />
|
<LineInfoBlock title="درخواست" :desc="requestText" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="service-details__divider" />
|
<div class="service-details__divider" />
|
||||||
|
|
||||||
<footer class="service-details__footer">
|
<footer class="service-details__footer">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
text="ارسال پیام"
|
text="ارسال پیام"
|
||||||
custom-class="service-details__send-btn"
|
custom-class="service-details__send-btn"
|
||||||
@click="onSendMessage(data)"
|
@click="onSendMessage"
|
||||||
>
|
>
|
||||||
<template #appendIcon>
|
<template #appendIcon>
|
||||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||||
</template>
|
</template>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</footer>
|
</footer>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BasicModal>
|
</BasicModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { SERVICE_TYPE } from '@/enums'
|
import { SERVICE_TYPE } from '@/enums'
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||||
|
import { useAdminServiceQuery } from '@/services/query/admin-services'
|
||||||
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ServiceDetailsModal' })
|
defineOptions({ name: 'ServiceDetailsModal' })
|
||||||
|
|
||||||
const { openModal } = useModal()
|
const { openModal, 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) => {
|
const serviceTypeLabel = computed(
|
||||||
openModal('SendMessageModal', { service: data })
|
() => service.value?.typeLabel || SERVICE_TYPE[service.value?.type] || '—'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</SimpleTitleIconBlock>
|
</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
|
<ServiceItem
|
||||||
v-for="service in services"
|
v-for="service in services"
|
||||||
:key="service.id"
|
:key="service.id"
|
||||||
@@ -29,6 +30,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<NoItems v-else title="متاسفیم" desc="درخواست خدمتی برای نمایش وجود ندارد." />
|
<NoItems v-else title="متاسفیم" desc="درخواست خدمتی برای نمایش وجود ندارد." />
|
||||||
|
|
||||||
|
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
|
||||||
|
|
||||||
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
|
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
|
||||||
<SendMessageModal v-if="isModal('SendMessageModal')" />
|
<SendMessageModal v-if="isModal('SendMessageModal')" />
|
||||||
</div>
|
</div>
|
||||||
@@ -37,56 +40,73 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
|
import { usePagination } from '@/composables/usePagination'
|
||||||
|
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.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 ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
|
||||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||||
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
|
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
|
||||||
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
|
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
|
||||||
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.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 { 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([
|
const { data, isLoading } = useAdminServicesListQuery(filters, pagination, {
|
||||||
{
|
keepPreviousData: true,
|
||||||
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 onFilterApply = () => {}
|
// Backend payload doesn't include the UI's legacy fields (requestId, faCreatedAt,
|
||||||
const onFilterReset = () => {}
|
// 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) => {
|
const onShowDetails = (service) => {
|
||||||
openModal('ServiceDetailsModal', service)
|
openModal('ServiceDetailsModal', { id: service.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChangeStatus = ({ service, status }) => {
|
const changeStatusMutation = useChangeAdminServiceStatusMutation()
|
||||||
const idx = allServices.value.findIndex((s) => s.id === service.id)
|
|
||||||
if (idx !== -1) allServices.value[idx] = { ...allServices.value[idx], status }
|
const onChangeStatus = async ({ service, status }) => {
|
||||||
|
await changeStatusMutation.mutateAsync({ id: service.id, payload: { status } })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<BasicModal 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>
|
||||||
|
|
||||||
|
<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 LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
|
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 { computed } from 'vue'
|
||||||
import Badge from '@/components/Badge.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 = {
|
const TONE_MAP = {
|
||||||
approved: 'success',
|
approved: 'success',
|
||||||
rejected: 'danger',
|
rejected: 'danger',
|
||||||
pending: 'neutral',
|
pending: 'neutral',
|
||||||
|
open: 'neutral',
|
||||||
|
answered: 'success',
|
||||||
closed: 'info',
|
closed: 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,12 @@
|
|||||||
<form class="rcf__form" @submit.prevent="onSubmit">
|
<form class="rcf__form" @submit.prevent="onSubmit">
|
||||||
<div class="rcf__row">
|
<div class="rcf__row">
|
||||||
<div class="rcf__cell">
|
<div class="rcf__cell">
|
||||||
<SelectField
|
<TextField
|
||||||
v-model="form.title"
|
v-model="form.subject"
|
||||||
name="title"
|
name="subject"
|
||||||
label="عنوان مشاوره درخواستی"
|
label="عنوان مشاوره درخواستی"
|
||||||
placeholder="انتخاب کنید"
|
placeholder="عنوان مشاوره را وارد کنید"
|
||||||
:options="titleOptions"
|
:error="errors.subject"
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
:error="errors.title"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,12 +16,12 @@
|
|||||||
<div class="rcf__row">
|
<div class="rcf__row">
|
||||||
<div class="rcf__cell rcf__cell--full">
|
<div class="rcf__cell rcf__cell--full">
|
||||||
<TextareaField
|
<TextareaField
|
||||||
v-model="form.description"
|
v-model="form.message"
|
||||||
name="description"
|
name="message"
|
||||||
label="توضیحات خود را وارد نمایید"
|
label="توضیحات خود را وارد نمایید"
|
||||||
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
||||||
:row="5"
|
:row="5"
|
||||||
:error="errors.description"
|
:error="errors.message"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,14 +45,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import * as yup from 'yup'
|
import * as yup from 'yup'
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import useYup from '@/composables/useYup'
|
import useYup from '@/composables/useYup'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import { useStudentConsultantTitlesQuery } from '@/services/query/student-consultants'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
submitting: { type: Boolean, default: false },
|
submitting: { type: Boolean, default: false },
|
||||||
@@ -64,13 +60,13 @@ defineProps({
|
|||||||
const emit = defineEmits(['submit'])
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
subject: '',
|
||||||
description: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const schema = yup.object({
|
const schema = yup.object({
|
||||||
title: yup.string().required('عنوان مشاوره را انتخاب کنید'),
|
subject: yup.string().trim().required('عنوان مشاوره را وارد کنید'),
|
||||||
description: yup
|
message: yup
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
||||||
@@ -79,13 +75,10 @@ const schema = yup.object({
|
|||||||
|
|
||||||
const { validate, errors } = useYup(schema)
|
const { validate, errors } = useYup(schema)
|
||||||
|
|
||||||
const { data: titles } = useStudentConsultantTitlesQuery()
|
|
||||||
const titleOptions = computed(() => titles.value ?? [])
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
emit('submit', { ...payload })
|
emit('submit', { subject: payload.subject, message: payload.message })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { toast } from 'vue3-toastify'
|
import { toast } from 'vue3-toastify'
|
||||||
|
import { TICKET_STATUS } from '@/enums'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||||
import { usePagination } from '@/composables/usePagination'
|
import { usePagination } from '@/composables/usePagination'
|
||||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
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 ConsultantItem from '@/features/student/consultants/components/ConsultantItem.vue'
|
||||||
import RequestConsultantForm from '@/features/student/consultants/components/RequestConsultantForm.vue'
|
import RequestConsultantForm from '@/features/student/consultants/components/RequestConsultantForm.vue'
|
||||||
import {
|
import {
|
||||||
studentConsultantsKeys,
|
studentTicketsKeys,
|
||||||
useCreateStudentConsultantMutation,
|
useCreateStudentTicketMutation,
|
||||||
useStudentConsultantsQuery,
|
useStudentTicketsQuery,
|
||||||
} from '@/services/query/student-consultants'
|
} from '@/services/query/student-tickets'
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@@ -59,13 +61,13 @@ const tabs = [
|
|||||||
]
|
]
|
||||||
const activeTab = ref('create')
|
const activeTab = ref('create')
|
||||||
|
|
||||||
const historyFilters = ref({})
|
const historyFilters = ref({ type: 'advise' })
|
||||||
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
|
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQuery(
|
const { data: historyData, isLoading: historyLoading } = useStudentTicketsQuery(
|
||||||
historyFilters,
|
historyFilters,
|
||||||
historyPagination,
|
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(() => ({
|
const historyMeta = computed(() => ({
|
||||||
page: historyPagination.value.page,
|
page: historyPagination.value.page,
|
||||||
perPage: historyPagination.value.perPage,
|
perPage: historyPagination.value.perPage,
|
||||||
...historyData.value?.meta,
|
...historyData.value?.meta,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const createMutation = useCreateStudentConsultantMutation()
|
const createMutation = useCreateStudentTicketMutation()
|
||||||
|
|
||||||
const onSubmit = async (payload) => {
|
const onSubmit = async (formPayload) => {
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(payload)
|
await createMutation.mutateAsync({
|
||||||
|
type: 'advise',
|
||||||
|
subject: formPayload.subject,
|
||||||
|
message: formPayload.message,
|
||||||
|
})
|
||||||
toast.success('درخواست مشاوره با موفقیت ثبت شد.')
|
toast.success('درخواست مشاوره با موفقیت ثبت شد.')
|
||||||
await queryClient.invalidateQueries({ queryKey: studentConsultantsKeys.all })
|
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||||
activeTab.value = 'history'
|
activeTab.value = 'history'
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('ثبت درخواست با خطا مواجه شد.')
|
toast.error('ثبت درخواست با خطا مواجه شد.')
|
||||||
|
|||||||
@@ -83,4 +83,10 @@ export default [
|
|||||||
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
|
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
|
||||||
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' },
|
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">
|
<form class="csf__form" @submit.prevent="onSubmit">
|
||||||
<div class="csf__row">
|
<div class="csf__row">
|
||||||
<div class="csf__cell">
|
<div class="csf__cell">
|
||||||
<SelectField
|
<TextField
|
||||||
v-model="form.typeKey"
|
v-model="form.category"
|
||||||
name="typeKey"
|
name="category"
|
||||||
label="نوع خدمت"
|
label="نوع خدمت"
|
||||||
placeholder="انتخاب کنید"
|
placeholder="نوع خدمت را وارد کنید"
|
||||||
:options="typeOptions"
|
:error="errors.category"
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
:error="errors.typeKey"
|
|
||||||
@change="onTypeChange"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="csf__cell">
|
<div class="csf__cell">
|
||||||
<SelectField
|
<TextField
|
||||||
v-model="form.title"
|
v-model="form.subject"
|
||||||
name="title"
|
name="subject"
|
||||||
label="عنوان خدمت"
|
label="عنوان خدمت"
|
||||||
placeholder="انتخاب کنید"
|
placeholder="عنوان خدمت را وارد کنید"
|
||||||
:options="titleOptions"
|
:error="errors.subject"
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
:disabled="!form.typeKey"
|
|
||||||
:error="errors.title"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,12 +25,12 @@
|
|||||||
<div class="csf__row">
|
<div class="csf__row">
|
||||||
<div class="csf__cell csf__cell--full">
|
<div class="csf__cell csf__cell--full">
|
||||||
<TextareaField
|
<TextareaField
|
||||||
v-model="form.description"
|
v-model="form.message"
|
||||||
name="description"
|
name="message"
|
||||||
label="توضیحات خود را وارد نمایید"
|
label="توضیحات خود را وارد نمایید"
|
||||||
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
|
||||||
:row="5"
|
:row="5"
|
||||||
:error="errors.description"
|
:error="errors.message"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,6 +40,7 @@
|
|||||||
v-model="files"
|
v-model="files"
|
||||||
accept="image/*,application/pdf"
|
accept="image/*,application/pdf"
|
||||||
:max-files="3"
|
:max-files="3"
|
||||||
|
@select="onFilesSelect"
|
||||||
@error="onFileError"
|
@error="onFileError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,6 +53,7 @@
|
|||||||
text="ثبت درخواست"
|
text="ثبت درخواست"
|
||||||
custom-class="csf__submit"
|
custom-class="csf__submit"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
|
:disabled="uploadMutation.isPending.value"
|
||||||
>
|
>
|
||||||
<template #appendIcon>
|
<template #appendIcon>
|
||||||
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
||||||
@@ -71,18 +65,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import * as yup from 'yup'
|
import * as yup from 'yup'
|
||||||
|
import { toast } from 'vue3-toastify'
|
||||||
import useYup from '@/composables/useYup'
|
import useYup from '@/composables/useYup'
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import FileUploader from '@/components/form/FileUploader.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 TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import {
|
|
||||||
useStudentServiceTitlesQuery,
|
|
||||||
useStudentServiceTypesQuery,
|
|
||||||
} from '@/services/query/student-services'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
submitting: { type: Boolean, default: false },
|
submitting: { type: Boolean, default: false },
|
||||||
@@ -91,17 +84,20 @@ defineProps({
|
|||||||
const emit = defineEmits(['submit', 'file-error'])
|
const emit = defineEmits(['submit', 'file-error'])
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
typeKey: '',
|
category: '',
|
||||||
title: '',
|
subject: '',
|
||||||
description: '',
|
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 files = ref([])
|
||||||
|
|
||||||
const schema = yup.object({
|
const schema = yup.object({
|
||||||
typeKey: yup.string().required('نوع خدمت را انتخاب کنید'),
|
category: yup.string().trim().required('نوع خدمت را وارد کنید'),
|
||||||
title: yup.string().required('عنوان خدمت را انتخاب کنید'),
|
subject: yup.string().trim().required('عنوان خدمت را وارد کنید'),
|
||||||
description: yup
|
message: yup
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
|
||||||
@@ -110,32 +106,38 @@ const schema = yup.object({
|
|||||||
|
|
||||||
const { validate, errors } = useYup(schema)
|
const { validate, errors } = useYup(schema)
|
||||||
|
|
||||||
const { data: types } = useStudentServiceTypesQuery()
|
const uploadMutation = useUploadMediaMutation()
|
||||||
const typeOptions = computed(() => types.value ?? [])
|
|
||||||
|
|
||||||
const typeKeyRef = computed(() => form.value.typeKey)
|
const onFilesSelect = async (newFiles) => {
|
||||||
const { data: titles } = useStudentServiceTitlesQuery(typeKeyRef, {
|
for (const file of newFiles) {
|
||||||
enabled: () => !!form.value.typeKey,
|
try {
|
||||||
})
|
const fd = objectToFormData({ file, purpose: 'attachment', context: 'ticket' })
|
||||||
const titleOptions = computed(() => titles.value ?? [])
|
const response = await uploadMutation.mutateAsync(fd)
|
||||||
|
const payload = response?.data ?? response
|
||||||
const onTypeChange = () => {
|
const id = payload?.id ?? payload?.uploadId
|
||||||
form.value.title = ''
|
if (id == null) continue
|
||||||
}
|
files.value = [
|
||||||
|
...files.value,
|
||||||
watch(
|
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
|
||||||
() => form.value.typeKey,
|
]
|
||||||
() => {
|
} catch {
|
||||||
form.value.title = ''
|
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
const onFileError = (msg) => emit('file-error', msg)
|
const onFileError = (msg) => emit('file-error', msg)
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const TONE_MAP = {
|
|||||||
approved: 'success',
|
approved: 'success',
|
||||||
rejected: 'danger',
|
rejected: 'danger',
|
||||||
pending: 'neutral',
|
pending: 'neutral',
|
||||||
|
open: 'neutral',
|
||||||
|
answered: 'success',
|
||||||
|
closed: 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<BasicModal width="95%" max-width="58rem" min-width="auto" :show-close-button="true">
|
||||||
|
<template #default>
|
||||||
|
<div class="sdm">
|
||||||
|
<header class="sdm__header">
|
||||||
|
<LineTitleBlock title="جزئیات درخواست" title-en="Request details" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="2" :cols-per-row="1" />
|
||||||
|
|
||||||
|
<template v-else-if="service">
|
||||||
|
<div class="sdm__top">
|
||||||
|
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||||
|
<span class="sdm__date">{{ requestDate }}</span>
|
||||||
|
</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 LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
|
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;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
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 0.75rem;
|
||||||
|
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" />
|
<PaginationBlock :pagination="historyMeta" @update:page="setHistoryPage" />
|
||||||
</template>
|
</template>
|
||||||
</TabsBlock>
|
</TabsBlock>
|
||||||
|
|
||||||
|
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { toast } from 'vue3-toastify'
|
import { toast } from 'vue3-toastify'
|
||||||
|
import { TICKET_STATUS } from '@/enums'
|
||||||
|
import useModal from '@/composables/useModal'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
@@ -49,13 +53,15 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
|||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import ServiceItem from '@/features/student/services/components/ServiceItem.vue'
|
import ServiceItem from '@/features/student/services/components/ServiceItem.vue'
|
||||||
import CreateServiceForm from '@/features/student/services/components/CreateServiceForm.vue'
|
import CreateServiceForm from '@/features/student/services/components/CreateServiceForm.vue'
|
||||||
|
import ServiceDetailsModal from '@/features/student/services/components/modals/ServiceDetailsModal.vue'
|
||||||
import {
|
import {
|
||||||
studentServicesKeys,
|
studentTicketsKeys,
|
||||||
useCreateStudentServiceMutation,
|
useCreateStudentTicketMutation,
|
||||||
useStudentServicesQuery,
|
useStudentTicketsQuery,
|
||||||
} from '@/services/query/student-services'
|
} from '@/services/query/student-tickets'
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const { openModal, isModal } = useModal()
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ name: 'create', label: 'درخواست خدمت', icon: 'paper-plane' },
|
{ name: 'create', label: 'درخواست خدمت', icon: 'paper-plane' },
|
||||||
@@ -63,13 +69,13 @@ const tabs = [
|
|||||||
]
|
]
|
||||||
const activeTab = ref('create')
|
const activeTab = ref('create')
|
||||||
|
|
||||||
const historyFilters = ref({})
|
const historyFilters = ref({ type: 'service' })
|
||||||
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
|
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery(
|
const { data: historyData, isLoading: historyLoading } = useStudentTicketsQuery(
|
||||||
historyFilters,
|
historyFilters,
|
||||||
historyPagination,
|
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(() => ({
|
const historyMeta = computed(() => ({
|
||||||
page: historyPagination.value.page,
|
page: historyPagination.value.page,
|
||||||
perPage: historyPagination.value.perPage,
|
perPage: historyPagination.value.perPage,
|
||||||
...historyData.value?.meta,
|
...historyData.value?.meta,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const createMutation = useCreateStudentServiceMutation()
|
const createMutation = useCreateStudentTicketMutation()
|
||||||
|
|
||||||
const onSubmit = async (payload) => {
|
const onSubmit = async (formPayload) => {
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(payload)
|
await createMutation.mutateAsync({
|
||||||
|
type: 'service',
|
||||||
|
category: formPayload.category,
|
||||||
|
subject: formPayload.subject,
|
||||||
|
message: formPayload.message,
|
||||||
|
mediaIds: formPayload.mediaIds,
|
||||||
|
})
|
||||||
toast.success('درخواست خدمت با موفقیت ثبت شد.')
|
toast.success('درخواست خدمت با موفقیت ثبت شد.')
|
||||||
await queryClient.invalidateQueries({ queryKey: studentServicesKeys.all })
|
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
|
||||||
activeTab.value = 'history'
|
activeTab.value = 'history'
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('ثبت درخواست با خطا مواجه شد.')
|
toast.error('ثبت درخواست با خطا مواجه شد.')
|
||||||
@@ -100,7 +123,9 @@ const onSubmit = async (payload) => {
|
|||||||
|
|
||||||
const onFileError = (msg) => toast.warning(msg)
|
const onFileError = (msg) => toast.warning(msg)
|
||||||
|
|
||||||
const onShowDetails = () => {}
|
const onShowDetails = (service) => {
|
||||||
|
openModal('ServiceDetailsModal', { id: service.id })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="status-tile">
|
||||||
|
<div class="status-tile__icon" :style="{ color: iconColor }">
|
||||||
|
<SvgIcon :name="icon" :size="32" />
|
||||||
|
</div>
|
||||||
|
<p class="status-tile__label">{{ label }}</p>
|
||||||
|
<p class="status-tile__count" :style="{ color: countColor }">{{ count }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
count: { type: [Number, String], default: 0 },
|
||||||
|
/** Hex color for the count number. Defaults to a neutral gray. */
|
||||||
|
countColor: { type: String, default: '#4C4C4C' },
|
||||||
|
/** Hex color for the icon. Defaults to a neutral gray. */
|
||||||
|
iconColor: { type: String, default: '#A7A7A7' },
|
||||||
|
})
|
||||||
|
</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;
|
||||||
|
min-height: 7rem;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #4c4c4c;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
font-family: var(--font-family-en);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<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" label="تاریخ پیام :" :value="createdAt" />
|
||||||
|
<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 { 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: 'success',
|
||||||
|
closed: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,249 @@
|
|||||||
|
<template>
|
||||||
|
<BasicModal width="95%" max-width="58rem" min-width="auto" :show-close-button="true">
|
||||||
|
<template #default="{ close }">
|
||||||
|
<form class="ctm" @submit.prevent="onSubmit(close)">
|
||||||
|
<header class="ctm__header">
|
||||||
|
<LineTitleBlock title="ارسال تیکت جدید" title-en="New ticket" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ctm__row ctm__row--two">
|
||||||
|
<TextField
|
||||||
|
v-model="form.subject"
|
||||||
|
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 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 LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
|
import FileUploader from '@/components/form/FileUploader.vue'
|
||||||
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||||
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||||
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
|
import {
|
||||||
|
studentTicketsKeys,
|
||||||
|
useCreateStudentTicketMutation,
|
||||||
|
} from '@/services/query/student-tickets'
|
||||||
|
|
||||||
|
defineOptions({ name: 'CreateTicketModal' })
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ value: 'very_urgent', label: 'بسیار فوری' },
|
||||||
|
{ value: 'urgent', label: 'فوری' },
|
||||||
|
{ value: 'normal', label: 'عادی' },
|
||||||
|
{ value: 'low', label: 'کم اهمیت' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
subject: '',
|
||||||
|
priority: 'very_urgent',
|
||||||
|
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',
|
||||||
|
// Backend POST /student/tickets accepts `category` — we use it to carry
|
||||||
|
// the FE-only priority value until the backend adds a dedicated field.
|
||||||
|
category: payload.priority,
|
||||||
|
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: 'very_urgent', 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;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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,144 @@
|
|||||||
|
<template>
|
||||||
|
<BasicModal
|
||||||
|
title="جزئیات تیکت"
|
||||||
|
title-en="Ticket details"
|
||||||
|
width="95%"
|
||||||
|
max-width="58rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="tdm">
|
||||||
|
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="2" :cols-per-row="1" />
|
||||||
|
|
||||||
|
<template v-else-if="ticket">
|
||||||
|
<div class="tdm__top">
|
||||||
|
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
|
||||||
|
<span class="tdm__date">{{ requestDate }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="messages.length > 0" class="tdm__messages">
|
||||||
|
<div v-for="message in messages" :key="message.id" class="tdm__bubble">
|
||||||
|
<p class="tdm__text">{{ message.message }}</p>
|
||||||
|
<span class="tdm__time">{{ messageTime(message) }}</span>
|
||||||
|
</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: 'TicketDetailsModal' })
|
||||||
|
|
||||||
|
const STATUS_VARIANT = {
|
||||||
|
open: 'warning',
|
||||||
|
answered: 'success',
|
||||||
|
closed: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 statusLabel = computed(() => TICKET_STATUS[ticket.value?.status] || '—')
|
||||||
|
const statusVariant = computed(() => STATUS_VARIANT[ticket.value?.status] || 'neutral')
|
||||||
|
|
||||||
|
const requestDate = computed(() => formatJalaaliDate(ticket.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>
|
||||||
|
.tdm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-block: 0.25rem 0.5rem;
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<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"
|
||||||
|
:icon="tile.icon"
|
||||||
|
:label="tile.label"
|
||||||
|
:count="tile.count"
|
||||||
|
:count-color="tile.countColor"
|
||||||
|
:icon-color="tile.iconColor"
|
||||||
|
/>
|
||||||
|
</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 { usePagination } from '@/composables/usePagination'
|
||||||
|
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||||
|
import { useStudentTicketsQuery } from '@/services/query/student-tickets'
|
||||||
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
import StatusTile from '@/features/student/tickets/components/StatusTile.vue'
|
||||||
|
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'
|
||||||
|
|
||||||
|
const { openModal, isModal } = useModal()
|
||||||
|
|
||||||
|
const filters = ref({ type: 'ticket' })
|
||||||
|
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
|
const { data, isLoading } = useStudentTicketsQuery(filters, pagination, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The query select normalizes to { data: Array, meta }. The page also tolerates
|
||||||
|
// a backend that nests under `data.items` (Laravel-style pagination) until the
|
||||||
|
// backend shape is locked in.
|
||||||
|
const tickets = computed(() => {
|
||||||
|
const raw = data.value?.data
|
||||||
|
if (Array.isArray(raw)) return raw
|
||||||
|
if (Array.isArray(raw?.items)) return raw.items
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationMeta = computed(() => ({
|
||||||
|
page: pagination.value.page,
|
||||||
|
perPage: pagination.value.perPage,
|
||||||
|
...data.value?.meta,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const statusTiles = computed(() => {
|
||||||
|
const list = tickets.value
|
||||||
|
const countBy = (predicate) => list.filter((_el) => predicate(_el)).length
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
icon: 'list-bullets',
|
||||||
|
label: 'همه تیکتها',
|
||||||
|
count: list.length,
|
||||||
|
iconColor: '#A7A7A7',
|
||||||
|
countColor: '#4C4C4C',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open',
|
||||||
|
icon: 'chat',
|
||||||
|
label: 'باز',
|
||||||
|
count: countBy((t) => t.status === 'open'),
|
||||||
|
iconColor: '#CC6F00',
|
||||||
|
countColor: '#CC6F00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'in_review',
|
||||||
|
icon: 'chat-centered-dots',
|
||||||
|
label: 'در حال بررسی',
|
||||||
|
count: countBy((t) => t.status === 'answered' && t.assignee),
|
||||||
|
iconColor: '#007074',
|
||||||
|
countColor: '#007074',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'answered',
|
||||||
|
icon: 'check-square',
|
||||||
|
label: 'پاسخ داده شده',
|
||||||
|
count: countBy((t) => t.status === 'answered'),
|
||||||
|
iconColor: '#33BE65',
|
||||||
|
countColor: '#33BE65',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'closed',
|
||||||
|
icon: 'close',
|
||||||
|
label: 'بسته شده',
|
||||||
|
count: countBy((t) => t.status === 'closed'),
|
||||||
|
iconColor: '#CC2831',
|
||||||
|
countColor: '#CC2831',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const 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' },
|
to: { name: 'student-consultants' },
|
||||||
active: route.name === 'student-consultants',
|
active: route.name === 'student-consultants',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'تیکتهای من',
|
||||||
|
icon: 'chat-centered-dots',
|
||||||
|
to: { name: 'student-tickets' },
|
||||||
|
active: route.name === 'student-tickets',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'گواهینامه ها',
|
title: 'گواهینامه ها',
|
||||||
icon: 'scroll',
|
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,12 @@ export const endpoints = {
|
|||||||
getMissionaryDispatches: '/student/missionary/dispatches',
|
getMissionaryDispatches: '/student/missionary/dispatches',
|
||||||
getMissionaryNarratives: '/student/missionary/narratives',
|
getMissionaryNarratives: '/student/missionary/narratives',
|
||||||
|
|
||||||
getStudentServices: '/student/services',
|
// Unified student tickets — handles services (type=service), consultants
|
||||||
createStudentService: '/student/services',
|
// (type=advise) and inbox (type=ticket) via the same endpoint with a filter.
|
||||||
getStudentServiceTypes: '/student/service-types',
|
getStudentTickets: '/student/tickets',
|
||||||
getStudentServiceTitles: '/student/service-titles',
|
createStudentTicket: '/student/tickets',
|
||||||
|
showStudentTicket: '/student/tickets/:id',
|
||||||
getStudentConsultants: '/student/consultants',
|
sendStudentTicketMessage: '/student/tickets/:id/messages',
|
||||||
createStudentConsultant: '/student/consultants',
|
|
||||||
getStudentConsultantTitles: '/student/consultant-titles',
|
|
||||||
|
|
||||||
getStudentCertificates: '/student/certificates',
|
getStudentCertificates: '/student/certificates',
|
||||||
downloadStudentCertificate: '/student/certificates/:id/download',
|
downloadStudentCertificate: '/student/certificates/:id/download',
|
||||||
@@ -140,18 +138,25 @@ export const endpoints = {
|
|||||||
showAssignmentSubmission: '/homework-submissions/:submissionId',
|
showAssignmentSubmission: '/homework-submissions/:submissionId',
|
||||||
|
|
||||||
// ─── Backend-aligned: Tickets ─────────────────────────────────────────────
|
// ─── Backend-aligned: Tickets ─────────────────────────────────────────────
|
||||||
// Admin "messages" UI talks to /admin/tickets.
|
// Admin sees every type via /admin/tickets, filtered by ?type=:
|
||||||
// Admin "consultations" UI talks to /counselor/tickets (same schema; the
|
// - messages page → ?type=ticket
|
||||||
// counselor scope is the consultation pool).
|
// - services page → ?type=service
|
||||||
|
// - consultations page → ?type=advise
|
||||||
|
// Counselor is restricted to /counselor/tickets (advise-only at the backend).
|
||||||
getTicketsList: '/admin/tickets',
|
getTicketsList: '/admin/tickets',
|
||||||
showTicket: '/admin/tickets/:id',
|
showTicket: '/admin/tickets/:id',
|
||||||
sendTicketMessage: '/admin/tickets/:id/messages',
|
sendTicketMessage: '/admin/tickets/:id/messages',
|
||||||
changeTicketStatus: '/admin/tickets/:id/status',
|
changeTicketStatus: '/admin/tickets/:id/status',
|
||||||
|
|
||||||
getConsultationsList: '/counselor/tickets',
|
getConsultationsList: '/admin/tickets',
|
||||||
showConsultation: '/counselor/tickets/:id',
|
showConsultation: '/admin/tickets/:id',
|
||||||
sendConsultationMessage: '/counselor/tickets/:id/messages',
|
sendConsultationMessage: '/admin/tickets/:id/messages',
|
||||||
changeConsultationStatus: '/counselor/tickets/:id/status',
|
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 = {}) =>
|
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,11 @@
|
|||||||
|
import { http } from '@/services/api/http'
|
||||||
|
import { buildUrl, endpoints } from '@/services/api/endpoints'
|
||||||
|
|
||||||
|
export const apiGetStudentTickets = (params) => http.get(endpoints.getStudentTickets, { 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:
|
// Shape mirrors backend Postman doc for /admin/tickets:
|
||||||
// ticket: { id, student_id, assigned_to_user_id, target_role, status, subject,
|
// ticket: { id, student_id, assigned_to_user_id, type, category, status,
|
||||||
// created_at, student, assignee, messages: [{ id, ticket_id,
|
// subject, created_at, media: [],
|
||||||
// sender_id, message, created_at, sender }] }
|
// student, assignee,
|
||||||
// Field names use camelCase here (snake_case bridge happens at the HTTP layer
|
// messages: [{ id, ticket_id, sender_id, message, created_at, sender }] }
|
||||||
// when we swap the mock for the real backend).
|
// 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) => ({
|
const makeUser = (overrides) => ({
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
@@ -46,6 +47,26 @@ const studentAli = makeUser({
|
|||||||
createdAt: '2026-02-10T10:00:00.000Z',
|
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({
|
const adminUser = makeUser({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'مدیر سامانه',
|
name: 'مدیر سامانه',
|
||||||
@@ -54,15 +75,25 @@ const adminUser = makeUser({
|
|||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
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 = [
|
export const adminTickets = [
|
||||||
{
|
{
|
||||||
id: 401,
|
id: 401,
|
||||||
studentId: studentJane.id,
|
studentId: studentJane.id,
|
||||||
assignedToUserId: adminUser.id,
|
assignedToUserId: adminUser.id,
|
||||||
targetRole: 'admin',
|
type: 'ticket',
|
||||||
|
category: 'enrollment',
|
||||||
status: 'answered',
|
status: 'answered',
|
||||||
subject: 'پیگیری وضعیت ثبتنام',
|
subject: 'پیگیری وضعیت ثبتنام',
|
||||||
createdAt: '2026-05-09T10:30:00.000Z',
|
createdAt: '2026-05-09T10:30:00.000Z',
|
||||||
|
media: [],
|
||||||
student: studentJane,
|
student: studentJane,
|
||||||
assignee: adminUser,
|
assignee: adminUser,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -96,10 +127,12 @@ export const adminTickets = [
|
|||||||
id: 402,
|
id: 402,
|
||||||
studentId: studentMaryam.id,
|
studentId: studentMaryam.id,
|
||||||
assignedToUserId: null,
|
assignedToUserId: null,
|
||||||
targetRole: 'admin',
|
type: 'ticket',
|
||||||
|
category: 'schedule',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
subject: 'سوال درباره جلسه آموزشی',
|
subject: 'سوال درباره جلسه آموزشی',
|
||||||
createdAt: '2026-05-08T09:15:00.000Z',
|
createdAt: '2026-05-08T09:15:00.000Z',
|
||||||
|
media: [],
|
||||||
student: studentMaryam,
|
student: studentMaryam,
|
||||||
assignee: null,
|
assignee: null,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -117,10 +150,12 @@ export const adminTickets = [
|
|||||||
id: 403,
|
id: 403,
|
||||||
studentId: studentAli.id,
|
studentId: studentAli.id,
|
||||||
assignedToUserId: adminUser.id,
|
assignedToUserId: adminUser.id,
|
||||||
targetRole: 'admin',
|
type: 'ticket',
|
||||||
|
category: 'billing',
|
||||||
status: 'closed',
|
status: 'closed',
|
||||||
subject: 'درخواست بستن تیکت',
|
subject: 'درخواست بستن تیکت',
|
||||||
createdAt: '2026-05-07T14:10:00.000Z',
|
createdAt: '2026-05-07T14:10:00.000Z',
|
||||||
|
media: [],
|
||||||
student: studentAli,
|
student: studentAli,
|
||||||
assignee: adminUser,
|
assignee: adminUser,
|
||||||
messages: [
|
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 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 }) => {
|
register('GET', endpoints.getTicketsList, ({ query }) => {
|
||||||
let list = filterItems(adminTickets, query, {
|
let list = filterItems(adminTickets, query, {
|
||||||
|
type: 'eq',
|
||||||
status: 'eq',
|
status: 'eq',
|
||||||
subject: (item, v) =>
|
subject: (item, v) =>
|
||||||
String(item.subject || '')
|
String(item.subject || '')
|
||||||
@@ -51,7 +52,7 @@ register('POST', endpoints.sendTicketMessage, ({ params, data }) => {
|
|||||||
}
|
}
|
||||||
ticket.messages = [...(ticket.messages || []), message]
|
ticket.messages = [...(ticket.messages || []), message]
|
||||||
ticket.status = 'answered'
|
ticket.status = 'answered'
|
||||||
ticket.assigneeId = currentAdminUser.id
|
ticket.assignedToUserId = currentAdminUser.id
|
||||||
ticket.assignee = currentAdminUser
|
ticket.assignee = currentAdminUser
|
||||||
return {
|
return {
|
||||||
success: true,
|
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,84 @@
|
|||||||
|
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.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,
|
||||||
|
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,
|
||||||
|
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],
|
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 = {}) =>
|
export const useAdminTicketsListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||||
select: (response) => ({
|
select: selectList,
|
||||||
data: response?.data ?? [],
|
|
||||||
meta: response?.meta,
|
|
||||||
}),
|
|
||||||
...options,
|
...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,43 @@
|
|||||||
|
import { cleanFilters } from '@/utils/clean-filters'
|
||||||
|
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||||
|
import {
|
||||||
|
apiCreateStudentTicket,
|
||||||
|
apiGetStudentTickets,
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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