fix: ticket

This commit is contained in:
sajjadtalkhabi
2026-06-05 18:43:02 +03:30
parent 1c665830b0
commit ea562aeefa
53 changed files with 2475 additions and 859 deletions
+2
View File
@@ -15,6 +15,7 @@ import { useGetMeQuery } from '@/services/query/auth'
import PublicLayout from '@/layouts/PublicLayout.vue'
import StudentLayout from '@/layouts/StudentLayout.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
import CounselorLayout from '@/layouts/CounselorLayout.vue'
import { tokenService } from '@/services/api/token-service'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
@@ -24,6 +25,7 @@ const layouts = {
public: PublicLayout,
admin: AdminLayout,
student: StudentLayout,
counselor: CounselorLayout,
}
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
+4 -13
View File
@@ -165,6 +165,10 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
optional: 'اختیاری',
})
// Single ticket-lifecycle vocabulary. Used by every ticket-like resource —
// admin /messages, admin /services, admin /consultations, counselor, student
// /my-tickets, /user/services, /user/consultants. Backend exposes exactly
// these three values across all of them.
export const TICKET_STATUS = Object.freeze({
open: 'باز',
answered: 'پاسخ داده شده',
@@ -182,22 +186,9 @@ export const PRIORITY_STATUS = Object.freeze({
low: 'پایین',
})
export const SERVICE_STATUS = Object.freeze({
approved: 'تایید شده',
pending: 'در انتظار بررسی',
in_progress: 'در حال انجام',
completed: 'تکمیل شده',
})
export const SERVICE_TYPE = Object.freeze({
loan: 'وام',
consultation: 'مشاوره',
educational: 'آموزشی',
other: 'سایر',
})
export const CONSULTATION_STATUS = Object.freeze({
open: 'در حال گفتگو',
answered: 'پاسخ داده شده',
closed: 'بسته شده',
})
@@ -17,19 +17,15 @@
</div>
<div class="consultation-item__meta">
<div class="consultation-item__pill">
<span class="consultation-item__pill-label">تاریخ پیام :</span>
<span class="consultation-item__pill-value">{{ createdAt }}</span>
</div>
<button
type="button"
class="consultation-item__status"
:class="`consultation-item__status--${consultation.status || 'open'}`"
<Badge variant="neutral" size="sm" label="تاریخ پیام :" :value="createdAt" />
<Badge
:variant="statusVariant"
size="sm"
dot
clickable
:value="statusLabel"
@click="onStatusClick"
>
<span class="consultation-item__status-dot" />
<span>{{ statusLabel }}</span>
</button>
/>
</div>
<div class="consultation-item__actions">
@@ -55,12 +51,19 @@
<script setup>
import { computed, ref } from 'vue'
import { CONSULTATION_STATUS } from '@/enums'
import { TICKET_STATUS } from '@/enums'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import DropdownMenu from '@/components/DropdownMenu.vue'
const STATUS_VARIANT = {
open: 'primary',
answered: 'success',
closed: 'info',
}
const props = defineProps({
consultation: { type: Object, required: true },
})
@@ -69,7 +72,9 @@ const emit = defineEmits(['show-details', 'change-status'])
const userName = computed(() => props.consultation.student?.name || '—')
const statusLabel = computed(() => CONSULTATION_STATUS[props.consultation.status] || '—')
const statusLabel = computed(() => TICKET_STATUS[props.consultation.status] || '—')
const statusVariant = computed(() => STATUS_VARIANT[props.consultation.status] || 'neutral')
const createdAt = computed(() => formatJalaaliDate(props.consultation.createdAt) || '—')
@@ -87,7 +92,7 @@ const onStatusClick = (event) => {
}
const menuItems = computed(() =>
Object.entries(CONSULTATION_STATUS)
Object.entries(TICKET_STATUS)
.filter(([value]) => value !== props.consultation.status)
.map(([value, label]) => ({
key: `status-${value}`,
@@ -183,72 +188,6 @@ const menuItems = computed(() =>
flex: 1 1 45%;
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: rgba(107, 107, 107, 5%);
padding: 0.375rem 0.875rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.875rem;
border: none;
font-family: var(--font-family-fa);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: filter 0.15s ease;
&:hover {
filter: brightness(0.96);
}
&--answered {
background: rgba(0, 154, 18, 8%);
color: #009a12;
}
&--open {
background: rgba(0, 112, 116, 10%);
color: #007074;
}
&--closed {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
}
&__status-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 9999px;
background: currentcolor;
}
&__actions {
display: flex;
justify-content: flex-end;
@@ -46,8 +46,8 @@
</template>
<script setup>
import { TICKET_STATUS } from '@/enums'
import { computed, ref, watch } from 'vue'
import { CONSULTATION_STATUS } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
@@ -76,7 +76,7 @@ watch(
const todayIso = new Date().toISOString()
const statusOptions = Object.entries(CONSULTATION_STATUS).map(([value, label]) => ({
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
value,
label,
}))
@@ -62,14 +62,14 @@ import {
const { openModal, isModal } = useModal()
const queryClient = useQueryClient()
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
const filters = ref({ type: 'advise', userName: '', status: '', fromDate: '', toDate: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminConsultationsListQuery(filters, pagination, {
keepPreviousData: true,
})
const consultations = computed(() => data.value?.data ?? [])
const consultations = computed(() => data.value?.data?.items ?? [])
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
@@ -14,17 +14,9 @@
</div>
<div class="ticket-item__meta">
<span class="ticket-item__status" :class="`ticket-item__status--${ticket.status || 'open'}`">
{{ statusLabel }}
</span>
<div class="ticket-item__pill">
<span class="ticket-item__pill-label">تاریخ ثبت:</span>
<span class="ticket-item__pill-value">{{ createdAt }}</span>
</div>
<div v-if="createdTime" class="ticket-item__pill">
<span class="ticket-item__pill-label">ساعت:</span>
<span class="ticket-item__pill-value">{{ createdTime }}</span>
</div>
<Badge :variant="statusVariant" size="sm" :value="statusLabel" />
<Badge variant="neutral" size="sm" label="تاریخ ثبت:" :value="createdAt" />
<Badge v-if="createdTime" variant="neutral" size="sm" label="ساعت:" :value="createdTime" />
</div>
<div class="ticket-item__actions">
@@ -44,10 +36,17 @@
<script setup>
import { computed } from 'vue'
import { TICKET_STATUS } from '@/enums'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const STATUS_VARIANT = {
open: 'warning',
answered: 'primary',
closed: 'info',
}
const props = defineProps({
ticket: { type: Object, required: true },
})
@@ -58,6 +57,8 @@ const userName = computed(() => props.ticket.student?.name || '—')
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
const statusVariant = computed(() => STATUS_VARIANT[props.ticket.status] || 'neutral')
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
const createdTime = computed(() => {
@@ -147,53 +148,6 @@ const createdTime = computed(() => {
flex: 1 1 41%;
}
&__status {
padding: 0.25rem 0.875rem;
border-radius: 0.875rem;
font-family: var(--font-family-fa);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
&--open {
background: rgba(204, 154, 40, 8%);
color: #cc6f00;
}
&--answered {
background: rgba(0, 112, 116, 8%);
color: #007074;
}
&--closed {
background: rgba(104, 104, 104, 8%);
color: #686868;
}
}
&__pill {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__actions {
display: flex;
justify-content: flex-end;
@@ -52,7 +52,7 @@ import TicketDetailsModal from '@/features/admin/messages/components/modals/Tick
const { openModal, isModal } = useModal()
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
const filters = ref({ type: 'ticket', userName: '', status: '', fromDate: '', toDate: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminTicketsListQuery(filters, pagination, {
@@ -17,25 +17,16 @@
</div>
<div class="service-item__meta">
<div class="service-item__pill">
<SvgIcon name="file" :size="14" color="#535353" />
<span class="service-item__pill-label">نوع خدمت :</span>
<span class="service-item__pill-value">{{ typeLabel }}</span>
</div>
<div class="service-item__pill">
<SvgIcon name="calendar" :size="14" color="#535353" />
<span class="service-item__pill-label">تاریخ پیام :</span>
<span class="service-item__pill-value">{{ createdAt }}</span>
</div>
<button
type="button"
class="service-item__status"
:class="`service-item__status--${service.status || 'pending'}`"
<Badge variant="neutral" size="sm" icon="file" label="نوع خدمت :" :value="typeLabel" />
<Badge variant="neutral" size="sm" icon="calendar" label="تاریخ پیام :" :value="createdAt" />
<Badge
:variant="statusVariant"
size="sm"
dot
clickable
:value="statusLabel"
@click="onStatusClick"
>
<span class="service-item__status-dot" />
<span>{{ statusLabel }}</span>
</button>
/>
</div>
<div class="service-item__actions">
@@ -61,12 +52,23 @@
<script setup>
import { computed, ref } from 'vue'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums'
import { SERVICE_TYPE, TICKET_STATUS } from '@/enums'
import { formatJalaaliDate } from '@/utils/date-utils'
import DropdownMenu from '@/components/DropdownMenu.vue'
const STATUS_VARIANT = {
open: 'warning',
answered: 'primary',
closed: 'info',
approved: 'success',
pending: 'warning',
in_progress: 'primary',
completed: 'info',
}
const props = defineProps({
service: { type: Object, required: true },
})
@@ -82,9 +84,11 @@ const userName = computed(() => {
const typeLabel = computed(() => props.service.typeLabel || SERVICE_TYPE[props.service.type] || '—')
const statusLabel = computed(
() => props.service.statusLabel || SERVICE_STATUS[props.service.status] || '—'
() => props.service.statusLabel || TICKET_STATUS[props.service.status] || '—'
)
const statusVariant = computed(() => STATUS_VARIANT[props.service.status] || 'neutral')
const createdAt = computed(() => {
if (props.service.faCreatedAt) {
return props.service.faCreatedTime
@@ -108,7 +112,7 @@ const onStatusClick = (event) => {
}
const menuItems = computed(() =>
Object.entries(SERVICE_STATUS)
Object.entries(TICKET_STATUS)
.filter(([value]) => value !== props.service.status)
.map(([value, label]) => ({
key: `status-${value}`,
@@ -204,77 +208,6 @@ const menuItems = computed(() =>
flex: 1 1 45%;
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: rgba(107, 107, 107, 5%);
padding: 0.375rem 0.875rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.875rem;
border: none;
font-family: var(--font-family-fa);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: filter 0.15s ease;
&:hover {
filter: brightness(0.96);
}
&--approved {
background: rgba(0, 153, 76, 10%);
color: #00994c;
}
&--pending {
background: rgba(204, 154, 40, 10%);
color: #cc6f00;
}
&--in_progress {
background: rgba(0, 112, 116, 10%);
color: #007074;
}
&--completed {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
}
&__status-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 9999px;
background: currentcolor;
}
&__actions {
display: flex;
justify-content: flex-end;
@@ -46,7 +46,7 @@
</template>
<script setup>
import { SERVICE_STATUS } from '@/enums'
import { TICKET_STATUS } from '@/enums'
import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
@@ -76,7 +76,7 @@ watch(
const todayIso = new Date().toISOString()
const statusOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
value,
label,
}))
@@ -52,6 +52,7 @@
text="ارسال"
custom-class="send-message__submit"
:disabled="!canSubmit"
:loading="sendMutation.isPending.value"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
@@ -66,19 +67,34 @@
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { SERVICE_STATUS } from '@/enums'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import {
adminServicesKeys,
useSendAdminServiceMessageMutation,
} from '@/services/query/admin-services'
defineOptions({ name: 'SendMessageModal' })
const categoryOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
value,
label,
}))
const { getModal } = useModal()
const queryClient = useQueryClient()
const modalData = computed(() => getModal('SendMessageModal')?.data ?? {})
const serviceId = computed(() => modalData.value.id ?? null)
// FE-only the message endpoint doesn't accept category. Keeping the dropdown
// visible per the Figma until the backend supports it.
const categoryOptions = [
{ value: 'general', label: 'عمومی' },
{ value: 'financial', label: 'مالی' },
{ value: 'technical', label: 'فنی' },
{ value: 'other', label: 'سایر' },
]
const priorityOptions = [
{ value: 'very_urgent', label: 'بسیار فوری' },
@@ -97,8 +113,18 @@ const canSubmit = computed(
() => form.value.category && form.value.priority && form.value.description.trim().length > 0
)
const onSubmit = (close) => {
if (!canSubmit.value) return
const sendMutation = useSendAdminServiceMessageMutation()
const onSubmit = async (close) => {
if (!canSubmit.value || !serviceId.value) return
// Backend POST /admin/tickets/:id/messages accepts only { message }.
// category + priority are collected in the form but not yet supported
// server-side (see backend-vs-ui-gaps).
await sendMutation.mutateAsync({
id: serviceId.value,
payload: { message: form.value.description.trim() },
})
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
toast.success('پیام با موفقیت ارسال شد')
close?.()
}
@@ -7,55 +7,81 @@
min-width="auto"
:show-close-button="true"
>
<template #default="{ data }">
<template #default>
<div class="service-details">
<div class="service-details__row service-details__row--two">
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle(data)" />
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel(data)" />
</div>
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="3" :cols-per-row="1" />
<template v-else>
<div class="service-details__row service-details__row--two">
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle" />
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel" />
</div>
<div class="service-details__row">
<LineInfoBlock title="درخواست" :desc="requestText(data)" />
</div>
<div class="service-details__row">
<LineInfoBlock title="درخواست" :desc="requestText" />
</div>
<div class="service-details__divider" />
<div class="service-details__divider" />
<footer class="service-details__footer">
<BaseButton
text="ارسال پیام"
custom-class="service-details__send-btn"
@click="onSendMessage(data)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
<footer class="service-details__footer">
<BaseButton
text="ارسال پیام"
custom-class="service-details__send-btn"
@click="onSendMessage"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</template>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import { SERVICE_TYPE } from '@/enums'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { useAdminServiceQuery } from '@/services/query/admin-services'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
defineOptions({ name: 'ServiceDetailsModal' })
const { openModal } = useModal()
const { 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) => {
openModal('SendMessageModal', { service: data })
const serviceTypeLabel = computed(
() => 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>
@@ -18,7 +18,8 @@
</template>
</SimpleTitleIconBlock>
<div v-if="services.length > 0">
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="services.length > 0">
<ServiceItem
v-for="service in services"
:key="service.id"
@@ -29,6 +30,8 @@
</div>
<NoItems v-else title="متاسفیم" desc="درخواست خدمتی برای نمایش وجود ندارد." />
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
<SendMessageModal v-if="isModal('SendMessageModal')" />
</div>
@@ -37,56 +40,73 @@
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.vue'
import {
adminServicesKeys,
useAdminServicesListQuery,
useChangeAdminServiceStatusMutation,
} from '@/services/query/admin-services'
const { openModal, isModal } = useModal()
const queryClient = useQueryClient()
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
const filters = ref({ type: 'service', userName: '', status: '', fromDate: '', toDate: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const allServices = ref([
{
id: 1,
requestId: '387',
user: { firstName: 'علیرضا', lastName: 'محمدی' },
title: 'گفتگو درباره وام',
type: 'loan',
status: 'approved',
requestText:
'من علاقه‌مند به دنیای تکنولوژی، طراحی رابط کاربری و توسعه نرم‌افزار هستم. در کنار کار حرفه‌ای، به یادگیری موضوعات جدید، مطالعه و موسیقی هم علاقه دارم. هدفم ساختن محصولاتی است.',
faCreatedAt: '1404/05/12',
faCreatedTime: '13:54',
},
])
const services = computed(() => {
const f = filters.value
return allServices.value.filter((s) => {
if (f.status && s.status !== f.status) return false
if (f.userName) {
const fullName = `${s.user?.firstName || ''} ${s.user?.lastName || ''}`.trim()
if (!fullName.includes(f.userName)) return false
}
return true
})
const { data, isLoading } = useAdminServicesListQuery(filters, pagination, {
keepPreviousData: true,
})
const onFilterApply = () => {}
const onFilterReset = () => {}
// Backend payload doesn't include the UI's legacy fields (requestId, faCreatedAt,
// user.firstName/lastName, typeLabel/statusLabel). Bridge them here so ServiceItem
// and the modals can stay untouched.
const services = computed(() =>
(data.value?.data?.items ?? []).map((s) => ({
...s,
requestId: s.requestId ?? s.id,
user:
s.user ??
(s.student
? {
id: s.student.id,
firstName: s.student.name?.split(' ')[0] || '',
lastName: s.student.name?.split(' ').slice(1).join(' ') || '',
fullName: s.student.name,
avatarUrl: s.student.avatarUrl,
}
: null),
}))
)
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const onFilterApply = () => resetPagination()
const onFilterReset = () => resetPagination()
const onShowDetails = (service) => {
openModal('ServiceDetailsModal', service)
openModal('ServiceDetailsModal', { id: service.id })
}
const onChangeStatus = ({ service, status }) => {
const idx = allServices.value.findIndex((s) => s.id === service.id)
if (idx !== -1) allServices.value[idx] = { ...allServices.value[idx], status }
const changeStatusMutation = useChangeAdminServiceStatusMutation()
const onChangeStatus = async ({ service, status }) => {
await changeStatusMutation.mutateAsync({ id: service.id, payload: { status } })
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
}
</script>
@@ -0,0 +1,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>
+8
View File
@@ -0,0 +1,8 @@
export default [
{
path: '/counselor/consultations',
name: 'counselor-consultations',
component: () => import('@/features/counselor/pages/CounselorConsultationsPage.vue'),
meta: { layout: 'counselor', role: 'counselor', title: 'مشاوره' },
},
]
@@ -22,10 +22,14 @@
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
// Backend statuses are open/answered/closed. Legacy keys stay in place for any
// pre-migration cached data.
const TONE_MAP = {
approved: 'success',
rejected: 'danger',
pending: 'neutral',
open: 'neutral',
answered: 'success',
closed: 'info',
}
@@ -3,15 +3,12 @@
<form class="rcf__form" @submit.prevent="onSubmit">
<div class="rcf__row">
<div class="rcf__cell">
<SelectField
v-model="form.title"
name="title"
<TextField
v-model="form.subject"
name="subject"
label="عنوان مشاوره درخواستی"
placeholder="انتخاب کنید"
:options="titleOptions"
option-label="label"
option-value="value"
:error="errors.title"
placeholder="عنوان مشاوره را وارد کنید"
:error="errors.subject"
/>
</div>
</div>
@@ -19,12 +16,12 @@
<div class="rcf__row">
<div class="rcf__cell rcf__cell--full">
<TextareaField
v-model="form.description"
name="description"
v-model="form.message"
name="message"
label="توضیحات خود را وارد نمایید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="5"
:error="errors.description"
:error="errors.message"
/>
</div>
</div>
@@ -48,14 +45,13 @@
</template>
<script setup>
import { ref } from 'vue'
import * as yup from 'yup'
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useStudentConsultantTitlesQuery } from '@/services/query/student-consultants'
defineProps({
submitting: { type: Boolean, default: false },
@@ -64,13 +60,13 @@ defineProps({
const emit = defineEmits(['submit'])
const form = ref({
title: '',
description: '',
subject: '',
message: '',
})
const schema = yup.object({
title: yup.string().required('عنوان مشاوره را انتخاب کنید'),
description: yup
subject: yup.string().trim().required('عنوان مشاوره را وارد کنید'),
message: yup
.string()
.trim()
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
@@ -79,13 +75,10 @@ const schema = yup.object({
const { validate, errors } = useYup(schema)
const { data: titles } = useStudentConsultantTitlesQuery()
const titleOptions = computed(() => titles.value ?? [])
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
emit('submit', { ...payload })
emit('submit', { subject: payload.subject, message: payload.message })
}
</script>
@@ -35,9 +35,11 @@
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { TICKET_STATUS } from '@/enums'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
@@ -46,10 +48,10 @@ import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ConsultantItem from '@/features/student/consultants/components/ConsultantItem.vue'
import RequestConsultantForm from '@/features/student/consultants/components/RequestConsultantForm.vue'
import {
studentConsultantsKeys,
useCreateStudentConsultantMutation,
useStudentConsultantsQuery,
} from '@/services/query/student-consultants'
studentTicketsKeys,
useCreateStudentTicketMutation,
useStudentTicketsQuery,
} from '@/services/query/student-tickets'
const queryClient = useQueryClient()
@@ -59,13 +61,13 @@ const tabs = [
]
const activeTab = ref('create')
const historyFilters = ref({})
const historyFilters = ref({ type: 'advise' })
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQuery(
const { data: historyData, isLoading: historyLoading } = useStudentTicketsQuery(
historyFilters,
historyPagination,
{
@@ -74,20 +76,42 @@ const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQu
}
)
const consultants = computed(() => historyData.value?.data ?? [])
const formatTime = (iso) => {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
}
// ConsultantItem expects title/requestNumber/dateLabel/timeLabel/statusLabel.
// Bridge the new backend payload without touching the item template.
const consultants = computed(() =>
(historyData.value?.data?.items ?? []).map((t) => ({
...t,
title: t.subject ?? '',
requestNumber: t.id,
dateLabel: formatJalaaliDate(t.createdAt) || '',
timeLabel: formatTime(t.createdAt),
statusLabel: TICKET_STATUS[t.status] ?? '',
}))
)
const historyMeta = computed(() => ({
page: historyPagination.value.page,
perPage: historyPagination.value.perPage,
...historyData.value?.meta,
}))
const createMutation = useCreateStudentConsultantMutation()
const createMutation = useCreateStudentTicketMutation()
const onSubmit = async (payload) => {
const onSubmit = async (formPayload) => {
try {
await createMutation.mutateAsync(payload)
await createMutation.mutateAsync({
type: 'advise',
subject: formPayload.subject,
message: formPayload.message,
})
toast.success('درخواست مشاوره با موفقیت ثبت شد.')
await queryClient.invalidateQueries({ queryKey: studentConsultantsKeys.all })
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
activeTab.value = 'history'
} catch {
toast.error('ثبت درخواست با خطا مواجه شد.')
+6
View File
@@ -83,4 +83,10 @@ export default [
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' },
},
{
path: '/my-tickets',
name: 'student-tickets',
component: () => import('@/features/student/tickets/pages/StudentTicketsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'تیکت‌های من' },
},
]
@@ -3,29 +3,21 @@
<form class="csf__form" @submit.prevent="onSubmit">
<div class="csf__row">
<div class="csf__cell">
<SelectField
v-model="form.typeKey"
name="typeKey"
<TextField
v-model="form.category"
name="category"
label="نوع خدمت"
placeholder="انتخاب کنید"
:options="typeOptions"
option-label="label"
option-value="value"
:error="errors.typeKey"
@change="onTypeChange"
placeholder="نوع خدمت را وارد کنید"
:error="errors.category"
/>
</div>
<div class="csf__cell">
<SelectField
v-model="form.title"
name="title"
<TextField
v-model="form.subject"
name="subject"
label="عنوان خدمت"
placeholder="انتخاب کنید"
:options="titleOptions"
option-label="label"
option-value="value"
:disabled="!form.typeKey"
:error="errors.title"
placeholder="عنوان خدمت را وارد کنید"
:error="errors.subject"
/>
</div>
</div>
@@ -33,12 +25,12 @@
<div class="csf__row">
<div class="csf__cell csf__cell--full">
<TextareaField
v-model="form.description"
name="description"
v-model="form.message"
name="message"
label="توضیحات خود را وارد نمایید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="5"
:error="errors.description"
:error="errors.message"
/>
</div>
</div>
@@ -48,6 +40,7 @@
v-model="files"
accept="image/*,application/pdf"
:max-files="3"
@select="onFilesSelect"
@error="onFileError"
/>
</div>
@@ -60,6 +53,7 @@
text="ثبت درخواست"
custom-class="csf__submit"
:loading="submitting"
:disabled="uploadMutation.isPending.value"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
@@ -71,18 +65,17 @@
</template>
<script setup>
import { ref } from 'vue'
import * as yup from 'yup'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextField from '@/components/form/TextField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import {
useStudentServiceTitlesQuery,
useStudentServiceTypesQuery,
} from '@/services/query/student-services'
defineProps({
submitting: { type: Boolean, default: false },
@@ -91,17 +84,20 @@ defineProps({
const emit = defineEmits(['submit', 'file-error'])
const form = ref({
typeKey: '',
title: '',
description: '',
category: '',
subject: '',
message: '',
})
// Items here are uploaded-media descriptors { id, name, size, url } not raw
// File objects. We upload immediately on select (same pattern as
// AddAssignmentModal / SessionFormPage) and just collect the IDs at submit.
const files = ref([])
const schema = yup.object({
typeKey: yup.string().required('نوع خدمت را انتخاب کنید'),
title: yup.string().required('عنوان خدمت را انتخاب کنید'),
description: yup
category: yup.string().trim().required('نوع خدمت را وارد کنید'),
subject: yup.string().trim().required('عنوان خدمت را وارد کنید'),
message: yup
.string()
.trim()
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
@@ -110,32 +106,38 @@ const schema = yup.object({
const { validate, errors } = useYup(schema)
const { data: types } = useStudentServiceTypesQuery()
const typeOptions = computed(() => types.value ?? [])
const uploadMutation = useUploadMediaMutation()
const typeKeyRef = computed(() => form.value.typeKey)
const { data: titles } = useStudentServiceTitlesQuery(typeKeyRef, {
enabled: () => !!form.value.typeKey,
})
const titleOptions = computed(() => titles.value ?? [])
const onTypeChange = () => {
form.value.title = ''
}
watch(
() => form.value.typeKey,
() => {
form.value.title = ''
const onFilesSelect = async (newFiles) => {
for (const file of newFiles) {
try {
const fd = objectToFormData({ file, purpose: 'attachment', context: 'ticket' })
const response = await uploadMutation.mutateAsync(fd)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
if (id == null) continue
files.value = [
...files.value,
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
]
} catch {
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
}
}
)
}
const onFileError = (msg) => emit('file-error', msg)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
emit('submit', { ...payload, files: files.value })
emit('submit', {
category: payload.category,
subject: payload.subject,
message: payload.message,
mediaIds: files.value.map((f) => f.id).filter((id) => id != null),
})
}
</script>
@@ -26,6 +26,9 @@ const TONE_MAP = {
approved: 'success',
rejected: 'danger',
pending: 'neutral',
open: 'neutral',
answered: 'success',
closed: 'info',
}
const props = defineProps({
@@ -0,0 +1,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" />
</template>
</TabsBlock>
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { TICKET_STATUS } from '@/enums'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
@@ -49,13 +53,15 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ServiceItem from '@/features/student/services/components/ServiceItem.vue'
import CreateServiceForm from '@/features/student/services/components/CreateServiceForm.vue'
import ServiceDetailsModal from '@/features/student/services/components/modals/ServiceDetailsModal.vue'
import {
studentServicesKeys,
useCreateStudentServiceMutation,
useStudentServicesQuery,
} from '@/services/query/student-services'
studentTicketsKeys,
useCreateStudentTicketMutation,
useStudentTicketsQuery,
} from '@/services/query/student-tickets'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const tabs = [
{ name: 'create', label: 'درخواست خدمت', icon: 'paper-plane' },
@@ -63,13 +69,13 @@ const tabs = [
]
const activeTab = ref('create')
const historyFilters = ref({})
const historyFilters = ref({ type: 'service' })
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery(
const { data: historyData, isLoading: historyLoading } = useStudentTicketsQuery(
historyFilters,
historyPagination,
{
@@ -78,20 +84,37 @@ const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery
}
)
const services = computed(() => historyData.value?.data ?? [])
// ServiceItem expects title/requestNumber/typeLabel/statusLabel. Bridge the
// new backend payload (subject/id/category/status) without changing the item
// template.
const services = computed(() =>
(historyData.value?.data?.items ?? []).map((t) => ({
...t,
title: t.subject ?? '',
requestNumber: t.id,
typeLabel: t.category ?? '',
statusLabel: TICKET_STATUS[t.status] ?? '',
}))
)
const historyMeta = computed(() => ({
page: historyPagination.value.page,
perPage: historyPagination.value.perPage,
...historyData.value?.meta,
}))
const createMutation = useCreateStudentServiceMutation()
const createMutation = useCreateStudentTicketMutation()
const onSubmit = async (payload) => {
const onSubmit = async (formPayload) => {
try {
await createMutation.mutateAsync(payload)
await createMutation.mutateAsync({
type: 'service',
category: formPayload.category,
subject: formPayload.subject,
message: formPayload.message,
mediaIds: formPayload.mediaIds,
})
toast.success('درخواست خدمت با موفقیت ثبت شد.')
await queryClient.invalidateQueries({ queryKey: studentServicesKeys.all })
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
activeTab.value = 'history'
} catch {
toast.error('ثبت درخواست با خطا مواجه شد.')
@@ -100,7 +123,9 @@ const onSubmit = async (payload) => {
const onFileError = (msg) => toast.warning(msg)
const onShowDetails = () => {}
const onShowDetails = (service) => {
openModal('ServiceDetailsModal', { id: service.id })
}
</script>
<style lang="scss" scoped>
@@ -0,0 +1,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>
+144
View File
@@ -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>
+6
View File
@@ -95,6 +95,12 @@ const menuItems = computed(() => [
to: { name: 'student-consultants' },
active: route.name === 'student-consultants',
},
{
title: 'تیکت‌های من',
icon: 'chat-centered-dots',
to: { name: 'student-tickets' },
active: route.name === 'student-tickets',
},
{
title: 'گواهینامه ها',
icon: 'scroll',
+94
View File
@@ -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>
+16
View File
@@ -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)
+14
View File
@@ -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)
+20 -15
View File
@@ -35,14 +35,12 @@ export const endpoints = {
getMissionaryDispatches: '/student/missionary/dispatches',
getMissionaryNarratives: '/student/missionary/narratives',
getStudentServices: '/student/services',
createStudentService: '/student/services',
getStudentServiceTypes: '/student/service-types',
getStudentServiceTitles: '/student/service-titles',
getStudentConsultants: '/student/consultants',
createStudentConsultant: '/student/consultants',
getStudentConsultantTitles: '/student/consultant-titles',
// Unified student tickets — handles services (type=service), consultants
// (type=advise) and inbox (type=ticket) via the same endpoint with a filter.
getStudentTickets: '/student/tickets',
createStudentTicket: '/student/tickets',
showStudentTicket: '/student/tickets/:id',
sendStudentTicketMessage: '/student/tickets/:id/messages',
getStudentCertificates: '/student/certificates',
downloadStudentCertificate: '/student/certificates/:id/download',
@@ -140,18 +138,25 @@ export const endpoints = {
showAssignmentSubmission: '/homework-submissions/:submissionId',
// ─── Backend-aligned: Tickets ─────────────────────────────────────────────
// Admin "messages" UI talks to /admin/tickets.
// Admin "consultations" UI talks to /counselor/tickets (same schema; the
// counselor scope is the consultation pool).
// Admin sees every type via /admin/tickets, filtered by ?type=:
// - messages page → ?type=ticket
// - services page → ?type=service
// - consultations page → ?type=advise
// Counselor is restricted to /counselor/tickets (advise-only at the backend).
getTicketsList: '/admin/tickets',
showTicket: '/admin/tickets/:id',
sendTicketMessage: '/admin/tickets/:id/messages',
changeTicketStatus: '/admin/tickets/:id/status',
getConsultationsList: '/counselor/tickets',
showConsultation: '/counselor/tickets/:id',
sendConsultationMessage: '/counselor/tickets/:id/messages',
changeConsultationStatus: '/counselor/tickets/:id/status',
getConsultationsList: '/admin/tickets',
showConsultation: '/admin/tickets/:id',
sendConsultationMessage: '/admin/tickets/:id/messages',
changeConsultationStatus: '/admin/tickets/:id/status',
getCounselorTickets: '/counselor/tickets',
showCounselorTicket: '/counselor/tickets/:id',
sendCounselorTicketMessage: '/counselor/tickets/:id/messages',
changeCounselorTicketStatus: '/counselor/tickets/:id/status',
}
export const buildUrl = (template, params = {}) =>
-10
View File
@@ -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)
-12
View File
@@ -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 })
+11
View File
@@ -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: '۱۶:۱۲',
},
],
},
]
+175 -9
View File
@@ -1,9 +1,10 @@
// Shape mirrors backend Postman doc for /admin/tickets:
// ticket: { id, student_id, assigned_to_user_id, target_role, status, subject,
// created_at, student, assignee, messages: [{ id, ticket_id,
// sender_id, message, created_at, sender }] }
// Field names use camelCase here (snake_case bridge happens at the HTTP layer
// when we swap the mock for the real backend).
// ticket: { id, student_id, assigned_to_user_id, type, category, status,
// subject, created_at, media: [],
// student, assignee,
// messages: [{ id, ticket_id, sender_id, message, created_at, sender }] }
// Fixture uses camelCase — the mock adapter returns it as-is, mimicking what
// the FE sees post-camelize when wired to the real backend.
const makeUser = (overrides) => ({
id: overrides.id,
@@ -46,6 +47,26 @@ const studentAli = makeUser({
createdAt: '2026-02-10T10:00:00.000Z',
})
const studentReza = makeUser({
id: 104,
name: 'رضا حسینی',
email: 'reza@example.com',
phone: '+989124444444',
roles: ['student'],
avatarUrl: '',
createdAt: '2026-02-12T10:00:00.000Z',
})
const studentZahra = makeUser({
id: 105,
name: 'زهرا کریمی',
email: 'zahra@example.com',
phone: '+989125555555',
roles: ['student'],
avatarUrl: '',
createdAt: '2026-02-15T10:00:00.000Z',
})
const adminUser = makeUser({
id: 1,
name: 'مدیر سامانه',
@@ -54,15 +75,25 @@ const adminUser = makeUser({
createdAt: '2026-01-01T00:00:00.000Z',
})
const counselorUser = makeUser({
id: 7,
name: 'مشاور سامانه',
email: 'counselor@example.com',
roles: ['counselor'],
createdAt: '2026-01-15T00:00:00.000Z',
})
export const adminTickets = [
{
id: 401,
studentId: studentJane.id,
assignedToUserId: adminUser.id,
targetRole: 'admin',
type: 'ticket',
category: 'enrollment',
status: 'answered',
subject: 'پیگیری وضعیت ثبت‌نام',
createdAt: '2026-05-09T10:30:00.000Z',
media: [],
student: studentJane,
assignee: adminUser,
messages: [
@@ -96,10 +127,12 @@ export const adminTickets = [
id: 402,
studentId: studentMaryam.id,
assignedToUserId: null,
targetRole: 'admin',
type: 'ticket',
category: 'schedule',
status: 'open',
subject: 'سوال درباره جلسه آموزشی',
createdAt: '2026-05-08T09:15:00.000Z',
media: [],
student: studentMaryam,
assignee: null,
messages: [
@@ -117,10 +150,12 @@ export const adminTickets = [
id: 403,
studentId: studentAli.id,
assignedToUserId: adminUser.id,
targetRole: 'admin',
type: 'ticket',
category: 'billing',
status: 'closed',
subject: 'درخواست بستن تیکت',
createdAt: '2026-05-07T14:10:00.000Z',
media: [],
student: studentAli,
assignee: adminUser,
messages: [
@@ -142,7 +177,138 @@ export const adminTickets = [
},
],
},
{
id: 501,
studentId: studentReza.id,
assignedToUserId: adminUser.id,
type: 'service',
category: 'loan',
status: 'open',
subject: 'درخواست وام تحصیلی',
createdAt: '2026-05-12T13:54:00.000Z',
media: [],
student: studentReza,
assignee: adminUser,
messages: [
{
id: 1,
ticketId: 501,
senderId: studentReza.id,
message: 'برای ادامه تحصیل به وام نیاز دارم.',
createdAt: '2026-05-12T13:54:00.000Z',
sender: studentReza,
},
],
},
{
id: 502,
studentId: studentJane.id,
assignedToUserId: null,
type: 'service',
category: 'insurance',
status: 'answered',
subject: 'درخواست بیمه دانشجویی',
createdAt: '2026-05-10T08:20:00.000Z',
media: [],
student: studentJane,
assignee: null,
messages: [
{
id: 1,
ticketId: 502,
senderId: studentJane.id,
message: 'لطفا اطلاعات بیمه را ارسال کنید.',
createdAt: '2026-05-10T08:20:00.000Z',
sender: studentJane,
},
],
},
{
id: 601,
studentId: studentZahra.id,
assignedToUserId: counselorUser.id,
type: 'advise',
category: 'education',
status: 'answered',
subject: 'مشاوره انتخاب رشته',
createdAt: '2026-05-12T13:54:00.000Z',
media: [],
student: studentZahra,
assignee: counselorUser,
messages: [
{
id: 1,
ticketId: 601,
senderId: studentZahra.id,
message: 'سلام، در خصوص انتخاب رشته مشاوره می‌خواستم.',
createdAt: '2026-05-12T13:54:00.000Z',
sender: studentZahra,
},
{
id: 2,
ticketId: 601,
senderId: counselorUser.id,
message: 'سلام و وقت بخیر، لطفا رشته فعلی و علاقه‌مندی‌های خود را بفرمایید.',
createdAt: '2026-05-12T14:02:00.000Z',
sender: counselorUser,
},
],
},
{
id: 602,
studentId: studentMaryam.id,
assignedToUserId: null,
type: 'advise',
category: 'career',
status: 'open',
subject: 'مشاوره مسیر شغلی',
createdAt: '2026-05-11T09:30:00.000Z',
media: [],
student: studentMaryam,
assignee: null,
messages: [
{
id: 1,
ticketId: 602,
senderId: studentMaryam.id,
message: 'برای ادامه تحصیل در سطح ۳ راهنمایی نیاز دارم.',
createdAt: '2026-05-11T09:30:00.000Z',
sender: studentMaryam,
},
],
},
{
id: 603,
studentId: studentAli.id,
assignedToUserId: counselorUser.id,
type: 'advise',
category: 'personal',
status: 'closed',
subject: 'مشاوره فردی',
createdAt: '2026-05-09T16:10:00.000Z',
media: [],
student: studentAli,
assignee: counselorUser,
messages: [
{
id: 1,
ticketId: 603,
senderId: studentAli.id,
message: 'مشاوره دریافت شد، با تشکر.',
createdAt: '2026-05-09T16:10:00.000Z',
sender: studentAli,
},
{
id: 2,
ticketId: 603,
senderId: counselorUser.id,
message: 'موفق باشید.',
createdAt: '2026-05-09T16:12:00.000Z',
sender: counselorUser,
},
],
},
]
// The signed-in admin used as `sender` when a message is posted from the FE.
export const currentAdminUser = adminUser
export const currentCounselorUser = counselorUser
@@ -1,50 +0,0 @@
export const studentConsultantTitles = [
{ value: 'course-talk', label: 'گفتگو درباره دوره' },
{ value: 'academic', label: 'مشاوره تحصیلی' },
{ value: 'career', label: 'مشاوره شغلی' },
{ value: 'psychology', label: 'مشاوره روانشناسی' },
{ value: 'other', label: 'سایر' },
]
const STATUS_LABELS = {
pending: 'در حال بررسی',
approved: 'پاسخ داده شده',
rejected: 'رد شده',
closed: 'بسته شده',
}
export const studentConsultants = [
{
id: 'c-1',
requestNumber: 215,
title: 'درخواست مشاوره',
topicLabel: 'گفتگو درباره دوره',
status: 'pending',
statusLabel: STATUS_LABELS.pending,
description: 'سوال در مورد محتوای دوره',
timeLabel: '12:53',
dateLabel: '1404/12/21',
},
{
id: 'c-2',
requestNumber: 198,
title: 'درخواست مشاوره',
topicLabel: 'مشاوره شغلی',
status: 'approved',
statusLabel: STATUS_LABELS.approved,
description: 'راهنمایی برای انتخاب مسیر شغلی',
timeLabel: '09:12',
dateLabel: '1404/11/05',
},
{
id: 'c-3',
requestNumber: 174,
title: 'درخواست مشاوره',
topicLabel: 'مشاوره تحصیلی',
status: 'rejected',
statusLabel: STATUS_LABELS.rejected,
description: 'برنامه‌ریزی درسی',
timeLabel: '16:40',
dateLabel: '1404/10/02',
},
]
@@ -1,61 +0,0 @@
export const studentServiceTypes = [
{ value: 'loan', label: 'وام' },
{ value: 'consultation', label: 'مشاوره' },
{ value: 'insurance', label: 'بیمه' },
{ value: 'other', label: 'سایر' },
]
export const studentServiceTitlesByType = {
loan: [
{ value: 'tuition-loan', label: 'وام شهریه' },
{ value: 'living-loan', label: 'وام معیشت' },
],
consultation: [
{ value: 'course-talk', label: 'گفتگو درباره دوره' },
{ value: 'career-talk', label: 'مشاوره شغلی' },
],
insurance: [{ value: 'student-insurance', label: 'بیمه دانشجویی' }],
other: [{ value: 'other-issue', label: 'موضوع دیگر' }],
}
const STATUS_LABELS = {
approved: 'تاییــد شـــــده',
pending: 'در انتظار بررسی',
rejected: 'رد شده',
}
export const studentServices = [
{
id: 's-1',
requestNumber: 387,
title: 'درخواست وام',
typeKey: 'loan',
typeLabel: 'وام',
status: 'approved',
statusLabel: STATUS_LABELS.approved,
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ',
createdAt: '1404/05/12',
},
{
id: 's-2',
requestNumber: 44,
title: 'درخواست وام',
typeKey: 'loan',
typeLabel: 'وام',
status: 'pending',
statusLabel: STATUS_LABELS.pending,
description: 'درخواست شهریه ترم جدید',
createdAt: '1404/06/02',
},
{
id: 's-3',
requestNumber: 18,
title: 'گفتگو درباره دوره',
typeKey: 'consultation',
typeLabel: 'مشاوره',
status: 'rejected',
statusLabel: STATUS_LABELS.rejected,
description: 'سوال در مورد محتوای دوره',
createdAt: '1404/04/22',
},
]
@@ -1,56 +0,0 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminConsultations } from '@/services/mock/fixtures/admin-consultations'
import {
filterDateRange,
filterItems,
findOrThrow,
isoNow,
makeId,
paginate,
updateById,
} from '@/services/mock/helpers'
const STATUS_LABELS = {
in_progress: 'در حال گفتگو',
answered: 'پاسخ داده شده',
closed: 'بسته شده',
}
register('GET', endpoints.getConsultationsList, ({ query }) => {
let list = filterItems(adminConsultations, query, {
status: 'eq',
userName: (item, v) =>
`${item.user?.firstName || ''} ${item.user?.lastName || ''}`
.toLowerCase()
.includes(String(v).toLowerCase()),
})
list = filterDateRange(list, query)
return paginate(list, query)
})
register('GET', endpoints.showConsultation, ({ params }) => ({
data: findOrThrow(adminConsultations, params.id),
}))
register('POST', endpoints.sendConsultationMessage, ({ params, data }) => {
const consultation = findOrThrow(adminConsultations, params.id)
const message = {
id: makeId(),
sender: 'admin',
text: data.text || '',
time: 'همین الان',
sentAt: isoNow(),
}
consultation.messages = [...(consultation.messages || []), message]
consultation.status = 'answered'
consultation.statusLabel = STATUS_LABELS.answered
return { data: consultation }
})
register('POST', endpoints.changeConsultationStatus, ({ params, data }) => ({
data: updateById(adminConsultations, params.id, {
status: data.status,
statusLabel: STATUS_LABELS[data.status] || data.status,
}),
}))
+2 -1
View File
@@ -13,6 +13,7 @@ import {
register('GET', endpoints.getTicketsList, ({ query }) => {
let list = filterItems(adminTickets, query, {
type: 'eq',
status: 'eq',
subject: (item, v) =>
String(item.subject || '')
@@ -51,7 +52,7 @@ register('POST', endpoints.sendTicketMessage, ({ params, data }) => {
}
ticket.messages = [...(ticket.messages || []), message]
ticket.status = 'answered'
ticket.assigneeId = currentAdminUser.id
ticket.assignedToUserId = currentAdminUser.id
ticket.assignee = currentAdminUser
return {
success: true,
@@ -0,0 +1,58 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminTickets, currentCounselorUser } from '@/services/mock/fixtures/admin-tickets'
import {
filterDateRange,
filterItems,
findOrThrow,
isoNow,
makeId,
paginate,
updateById,
} from '@/services/mock/helpers'
// Counselor scope: backend hides every ticket whose type !== 'advise'.
const counselorPool = () => adminTickets.filter((t) => t.type === 'advise')
register('GET', endpoints.getCounselorTickets, ({ query }) => {
let list = filterItems(counselorPool(), query, {
type: 'eq',
status: 'eq',
userName: (item, v) =>
String(item.student?.name || '')
.toLowerCase()
.includes(String(v).toLowerCase()),
})
list = filterDateRange(list, query)
const { data: items, meta } = paginate(list, query)
return { success: true, message: 'OK', data: items, meta }
})
register('GET', endpoints.showCounselorTicket, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(counselorPool(), params.id),
}))
register('POST', endpoints.sendCounselorTicketMessage, ({ params, data }) => {
const ticket = findOrThrow(counselorPool(), params.id)
const message = {
id: makeId(),
ticketId: ticket.id,
senderId: currentCounselorUser.id,
message: data.message || '',
createdAt: isoNow(),
sender: currentCounselorUser,
}
ticket.messages = [...(ticket.messages || []), message]
ticket.status = 'answered'
ticket.assignedToUserId = currentCounselorUser.id
ticket.assignee = currentCounselorUser
return { success: true, message: 'Message posted.', data: message }
})
register('PATCH', endpoints.changeCounselorTicketStatus, ({ params, data }) => ({
success: true,
message: 'Ticket status updated.',
data: updateById(adminTickets, params.id, { status: data.status }),
}))
@@ -1,29 +0,0 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
studentConsultantTitles,
studentConsultants,
} from '@/services/mock/fixtures/student-consultants'
register('GET', endpoints.getStudentConsultants, ({ query }) => paginate(studentConsultants, query))
register('POST', endpoints.createStudentConsultant, ({ data }) => {
const id = `c-${studentConsultants.length + 1}`
const next = {
id,
requestNumber: 500 + studentConsultants.length + 1,
title: 'درخواست مشاوره',
topicLabel:
studentConsultantTitles.find((t) => t.value === data?.title)?.label || data?.title || '—',
status: 'pending',
statusLabel: 'در حال بررسی',
description: data?.description || '',
timeLabel: '12:00',
dateLabel: '1404/06/12',
}
studentConsultants.unshift(next)
return { data: next }
})
register('GET', endpoints.getStudentConsultantTitles, () => ({ data: studentConsultantTitles }))
@@ -1,33 +0,0 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
studentServiceTitlesByType,
studentServiceTypes,
studentServices,
} from '@/services/mock/fixtures/student-services'
register('GET', endpoints.getStudentServices, ({ query }) => paginate(studentServices, query))
register('POST', endpoints.createStudentService, ({ data }) => {
const id = `s-${studentServices.length + 1}`
const next = {
id,
requestNumber: 500 + studentServices.length + 1,
title: data?.title || '—',
typeKey: data?.typeKey || 'other',
typeLabel: studentServiceTypes.find((t) => t.value === data?.typeKey)?.label || 'سایر',
status: 'pending',
statusLabel: 'در انتظار بررسی',
description: data?.description || '',
createdAt: '1404/06/12',
}
studentServices.unshift(next)
return { data: next }
})
register('GET', endpoints.getStudentServiceTypes, () => ({ data: studentServiceTypes }))
register('GET', endpoints.getStudentServiceTitles, ({ query }) => ({
data: studentServiceTitlesByType[query.type] || [],
}))
@@ -0,0 +1,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 }
})
+44
View File
@@ -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),
})
+10 -4
View File
@@ -13,15 +13,21 @@ export const adminTicketsKeys = {
detail: (id) => ['admin', 'tickets', 'detail', id],
}
// Backend may wrap the list as `{success, data: [...]}` (flat) or
// `{data: {data: [...], meta}}` (Laravel default). Normalize.
const selectList = (response) => {
const inner = response?.data
if (Array.isArray(inner)) return { data: inner, meta: response?.meta }
if (Array.isArray(inner?.data)) return { data: inner.data, meta: inner?.meta ?? response?.meta }
return { data: [], meta: response?.meta }
}
export const useAdminTicketsListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data ?? [],
meta: response?.meta,
}),
select: selectList,
...options,
})
+44
View File
@@ -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),
})
-35
View File
@@ -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) })
-45
View File
@@ -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) })
+43
View File
@@ -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),
})