fix
Deploy banu-front / deploy (push) Successful in 1m27s

This commit is contained in:
sajjadtalkhabi
2026-07-07 17:36:43 +03:30
parent b9ef684b51
commit 38a396ba21
89 changed files with 1815 additions and 478 deletions
+2
View File
@@ -17,6 +17,7 @@ import StudentLayout from '@/layouts/StudentLayout.vue'
import ConfirmModal from '@/components/ConfirmModal.vue' import ConfirmModal from '@/components/ConfirmModal.vue'
import CounselorLayout from '@/layouts/CounselorLayout.vue' import CounselorLayout from '@/layouts/CounselorLayout.vue'
import { tokenService } from '@/services/api/token-service' import { tokenService } from '@/services/api/token-service'
import MissionaryLayout from '@/layouts/MissionaryLayout.vue'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools' import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
const route = useRoute() const route = useRoute()
@@ -26,6 +27,7 @@ const layouts = {
admin: AdminLayout, admin: AdminLayout,
student: StudentLayout, student: StudentLayout,
counselor: CounselorLayout, counselor: CounselorLayout,
missionary: MissionaryLayout,
} }
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout) const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
+2 -2
View File
@@ -1,7 +1,7 @@
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_21191)"> <g clip-path="url(#clip0_1_21191)">
<path d="M6.5 11.375C9.19239 11.375 11.375 9.19239 11.375 6.5C11.375 3.80761 9.19239 1.625 6.5 1.625C3.80761 1.625 1.625 3.80761 1.625 6.5C1.625 9.19239 3.80761 11.375 6.5 11.375Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/> <path d="M6.5 11.375C9.19239 11.375 11.375 9.19239 11.375 6.5C11.375 3.80761 9.19239 1.625 6.5 1.625C3.80761 1.625 1.625 3.80761 1.625 6.5C1.625 9.19239 3.80761 11.375 6.5 11.375Z" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 3.65625V6.5H9.34375" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/> <path d="M6.5 3.65625V6.5H9.34375" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_1_21191"> <clipPath id="clip0_1_21191">

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 676 B

+11
View File
@@ -0,0 +1,11 @@
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_135332)">
<path d="M4.21875 16.875L18.2813 16.875C19.0579 16.875 19.6875 16.2454 19.6875 15.4688L19.6875 5.625C19.6875 4.84835 19.0579 4.21875 18.2813 4.21875L4.21875 4.21875C3.4421 4.21875 2.8125 4.84835 2.8125 5.625L2.8125 15.4687C2.8125 16.2454 3.4421 16.875 4.21875 16.875Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.0625 19.6875L8.4375 19.6875" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_2_135332">
<rect width="22.5" height="22.5" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.4375 9.84375L15.4688 9.84375" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.4375 12.6562L15.4688 12.6563" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.8125 17.5781C3.18546 17.5781 3.54315 17.43 3.80687 17.1662C4.07059 16.9025 4.21875 16.5448 4.21875 16.1719L4.21875 5.625C4.21875 5.43852 4.29283 5.25968 4.42469 5.12782C4.55655 4.99595 4.7354 4.92188 4.92188 4.92188L18.9844 4.92188C19.1709 4.92188 19.3497 4.99595 19.4816 5.12782C19.6134 5.25968 19.6875 5.43852 19.6875 5.625L19.6875 16.1719C19.6875 16.5448 19.5393 16.9025 19.2756 17.1662C19.0119 17.43 18.6542 17.5781 18.2813 17.5781L2.8125 17.5781Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.8125 17.5781C2.43954 17.5781 2.08185 17.43 1.81813 17.1662C1.55441 16.9025 1.40625 16.5448 1.40625 16.1719L1.40625 7.73438" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v9"/>
<path d="M15.5 4.5a8 8 0 1 1-7 0"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+6 -1
View File
@@ -21,7 +21,7 @@ import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan'>} */ /** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan' | 'blue'>} */
variant: { type: String, default: 'neutral' }, variant: { type: String, default: 'neutral' },
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */ /** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
size: { type: String, default: 'md' }, size: { type: String, default: 'md' },
@@ -138,5 +138,10 @@ const onClick = (event) => {
background: rgba(104, 104, 104, 10%); background: rgba(104, 104, 104, 10%);
color: #686868; color: #686868;
} }
&--blue {
background: rgba(0, 72, 255, 8%);
color: #0048ff;
}
} }
</style> </style>
+1 -1
View File
@@ -29,7 +29,7 @@
v-if="item.icon" v-if="item.icon"
:name="item.icon" :name="item.icon"
:size="16" :size="16"
:color="item.iconColor ?? item.color" :color="item.iconColor ?? item.color ?? 'currentColor'"
class="dropdown-menu__icon" class="dropdown-menu__icon"
/> />
<span class="dropdown-menu__label">{{ item.label }}</span> <span class="dropdown-menu__label">{{ item.label }}</span>
+22 -6
View File
@@ -11,7 +11,10 @@
</div> </div>
<div class="header-block__title"> <div class="header-block__title">
<slot> <slot>
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div> <div v-if="pageTitle || pageSubtitle" class="header-block__heading">
<span v-if="pageSubtitle" class="header-block__heading-eyebrow">{{ pageTitle }}</span>
<span>{{ pageSubtitle || pageTitle }}</span>
</div>
</slot> </slot>
</div> </div>
<div class="header-block__icons"> <div class="header-block__icons">
@@ -27,7 +30,10 @@
</div> </div>
<div class="header-block__title-mobile"> <div class="header-block__title-mobile">
<slot> <slot>
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div> <div v-if="pageTitle || pageSubtitle" class="header-block__heading">
<span v-if="pageSubtitle" class="header-block__heading-eyebrow">{{ pageTitle }}</span>
<span>{{ pageSubtitle || pageTitle }}</span>
</div>
</slot> </slot>
</div> </div>
@@ -43,22 +49,20 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import useAuth from '@/composables/useAuth' import useAuth from '@/composables/useAuth'
import { computed, ref, useSlots } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import DropdownMenu from '@/components/DropdownMenu.vue' import DropdownMenu from '@/components/DropdownMenu.vue'
import { useLogoutMutation } from '@/services/query/auth' import { useLogoutMutation } from '@/services/query/auth'
defineProps({ defineProps({
pageTitle: { type: String, default: '' }, pageTitle: { type: String, default: '' },
pageSubtitle: { type: String, default: '' },
}) })
const slots = useSlots()
const hasSlotContent = computed(() => !!slots.default)
const ui = useUIStore() const ui = useUIStore()
const router = useRouter() const router = useRouter()
const { clearSession } = useAuth() const { clearSession } = useAuth()
@@ -176,6 +180,18 @@ const iconList = [
} }
} }
&__heading {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__heading-eyebrow {
font-size: 0.75rem;
font-weight: 400;
color: #a7a7a7;
}
&__icons { &__icons {
flex-basis: 33.33%; flex-basis: 33.33%;
@@ -0,0 +1,100 @@
<template>
<div class="message-attachments">
<template v-for="item in media" :key="item.id">
<a
v-if="isImage(item)"
:href="item.url"
target="_blank"
rel="noopener"
class="message-attachments__image-link"
>
<img :src="item.url" :alt="item.fileName" class="message-attachments__image" />
</a>
<a
v-else
:href="item.downloadUrl || item.url"
target="_blank"
rel="noopener"
class="message-attachments__file"
>
<SvgIcon name="file" :size="18" color="currentColor" />
<span class="message-attachments__name">{{ item.fileName }}</span>
<span class="message-attachments__size">{{ formatFileSize(item.fileSize) }}</span>
</a>
</template>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
media: { type: Array, default: () => [] },
})
const isImage = (item) =>
(item.mimeType || '').startsWith('image/') || /\.(jpe?g|png|gif|webp)$/i.test(item.fileName || '')
const formatFileSize = (bytes) => {
if (!bytes) return ''
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
</script>
<style lang="scss" scoped>
.message-attachments {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.375rem;
&__image-link {
display: block;
max-width: 16rem;
}
&__image {
display: block;
width: 100%;
max-height: 12rem;
object-fit: cover;
border-radius: 0.625rem;
}
&__file {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 8%);
color: inherit;
text-decoration: none;
max-width: 100%;
&:hover {
background: rgba(0, 0, 0, 14%);
}
}
&__name {
font-family: var(--font-family-fa);
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
&__size {
font-family: var(--font-family-en);
font-size: 0.65rem;
opacity: 0.75;
white-space: nowrap;
}
}
</style>
+4 -3
View File
@@ -126,7 +126,7 @@ const onActionClick = (tab) => {
gap: 0.375rem; gap: 0.375rem;
background: rgba(255, 255, 255, 80%); background: rgba(255, 255, 255, 80%);
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%); box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
padding: 0.875rem; padding: 0.775rem;
border-radius: 0.75rem; border-radius: 0.75rem;
} }
@@ -144,7 +144,8 @@ const onActionClick = (tab) => {
&--desktop { &--desktop {
display: none; display: none;
min-width: fit-content; min-width: fit-content;
margin-block-end: 0.625rem;
// margin-block-end: 0.625rem;
@media (min-width: 768px) { @media (min-width: 768px) {
display: flex; display: flex;
@@ -162,7 +163,7 @@ const onActionClick = (tab) => {
} }
&__action-btn { &__action-btn {
padding: 0 1.25rem; height: 100%;
min-width: fit-content; min-width: fit-content;
} }
+2 -1
View File
@@ -14,7 +14,7 @@
@click="toggle" @click="toggle"
> >
<span class="datepicker-field__value">{{ displayValue }}</span> <span class="datepicker-field__value">{{ displayValue }}</span>
<SvgIcon name="calendar" :size="20" class="datepicker-field__icon" /> <SvgIcon name="calendar" :size="20" color="#DDDDDD" class="datepicker-field__icon" />
</div> </div>
<div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }"> <div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }">
@@ -377,6 +377,7 @@ export default {
this.$emit('change', gregorian) this.$emit('change', gregorian)
this.close() this.close()
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error('Error in handleConfirm:', error) console.error('Error in handleConfirm:', error)
} }
}, },
+1 -2
View File
@@ -325,8 +325,7 @@ onBeforeUnmount(cleanup)
margin: 0; margin: 0;
@media (min-width: 1024px) { @media (min-width: 1024px) {
font-size: 0.75rem; font-size: 0.7rem;
line-height: 1.85rem;
} }
} }
} }
+3
View File
@@ -36,13 +36,16 @@ export type IconName =
| 'map-pin-simple-area' | 'map-pin-simple-area'
| 'menu' | 'menu'
| 'microphone' | 'microphone'
| 'monitor'
| 'mood' | 'mood'
| 'newspaper'
| 'paper-plane' | 'paper-plane'
| 'paper-plane-right' | 'paper-plane-right'
| 'pencil' | 'pencil'
| 'phone' | 'phone'
| 'play' | 'play'
| 'plus' | 'plus'
| 'power'
| 'scroll' | 'scroll'
| 'shopping-bag' | 'shopping-bag'
| 'smiley-sad' | 'smiley-sad'
+1
View File
@@ -17,6 +17,7 @@ export const fields = {
postalCode: 'کد پستی', postalCode: 'کد پستی',
birthDate: 'تاریخ تولد', birthDate: 'تاریخ تولد',
birthday: 'تاریخ تولد',
gender: 'جنسیت', gender: 'جنسیت',
maritalStatus: 'وضعیت تأهل', maritalStatus: 'وضعیت تأهل',
+17
View File
@@ -206,3 +206,20 @@ export const SERVICE_TYPE = Object.freeze({
educational: 'آموزشی', educational: 'آموزشی',
other: 'سایر', other: 'سایر',
}) })
// Lifecycle of a dispatch request assigned to a missionary. `pending` is the
// initial backend state; the missionary may set seen, accepted, or rejected.
export const MISSIONARY_REQUEST_STATUS = Object.freeze({
pending: 'در انتظار بررسی',
seen: 'دیده شده',
accepted: 'پذیرفته شده',
rejected: 'رد شده',
})
// Landing route per role after login / when a logged-in user hits a guest
// page. Roles not listed here fall back to the student dashboard.
export const ROLE_HOME_ROUTES = Object.freeze({
admin: 'admin-dashboard',
counselor: 'counselor-consultations',
missionary: 'missionary-requests',
})
@@ -3,7 +3,7 @@
<div class="assignments-filters__grid"> <div class="assignments-filters__grid">
<TextField v-model="form.title" name="title" label="عنوان تکلیف"> <TextField v-model="form.title" name="title" label="عنوان تکلیف">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
@@ -54,7 +54,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -67,7 +67,7 @@
<SelectField <SelectField
v-model="form.priority" v-model="form.priority"
name="priority" name="priority"
label="اولویت" label="اولویت تکلیف"
:options="priorityOptions" :options="priorityOptions"
option-label="label" option-label="label"
option-value="value" option-value="value"
@@ -6,7 +6,7 @@
desc="در این قسمت می‌توانید تکالیف خود را مدیریت کنید" desc="در این قسمت می‌توانید تکالیف خود را مدیریت کنید"
> >
<template #icon> <template #icon>
<SvgIcon name="check-square" :size="24" color="var(--color-primary)" /> <img :src="assignmentIcon" alt="" />
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
@@ -20,7 +20,7 @@
</SimpleTitleIconBlock> </SimpleTitleIconBlock>
<BaseButton text="افزودن تکلیف جدید" custom-class="assignments-page__add-btn" @click="onAdd"> <BaseButton text="افزودن تکلیف جدید" custom-class="assignments-page__add-btn" @click="onAdd">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div> </div>
@@ -50,6 +50,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { gallery } from '@/utils/gallery'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
@@ -75,6 +76,8 @@ import {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const assignmentIcon = gallery.assignmentIcon
const filters = ref({ const filters = ref({
title: '', title: '',
termId: '', termId: '',
@@ -28,7 +28,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -3,7 +3,7 @@
<div class="courses-filters__grid"> <div class="courses-filters__grid">
<TextField v-model="form.title" name="title" label="عنوان دوره"> <TextField v-model="form.title" name="title" label="عنوان دوره">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
@@ -14,16 +14,6 @@
<form class="course-form__form" @submit.prevent="onSubmit"> <form class="course-form__form" @submit.prevent="onSubmit">
<div class="course-form__grid"> <div class="course-form__grid">
<div class="course-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
<div class="course-form__main-col"> <div class="course-form__main-col">
<div class="course-form__row"> <div class="course-form__row">
<div class="course-form__cell course-form__cell--third"> <div class="course-form__cell course-form__cell--third">
@@ -100,6 +90,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="course-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
</div> </div>
<div class="course-form__divider" /> <div class="course-form__divider" />
@@ -309,29 +309,23 @@ const onCancel = () => router.push({ name: 'admin-courses' })
gap: 1rem; gap: 1rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: 4fr 8fr; grid-template-columns: 8fr 4fr;
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: 3fr 9fr; grid-template-columns: 9fr 3fr;
} }
} }
&__image-col { &__image-col {
order: 2;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 1;
padding: 0.5rem; padding: 0.5rem;
} }
} }
&__main-col { &__main-col {
order: 1;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 2; padding-inline-end: 1.25rem;
padding-inline-start: 1.25rem;
} }
} }
@@ -6,7 +6,7 @@
desc="در این قسمت می‌توانید دوره‌های خود را مدیریت کنید" desc="در این قسمت می‌توانید دوره‌های خود را مدیریت کنید"
> >
<template #icon> <template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" /> <img :src="courseIcon" alt="" />
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
@@ -37,10 +37,10 @@
</template> </template>
<script setup> <script setup>
import { gallery } from '@/utils/gallery'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import NoItems from '@/components/blocks/NoItems.vue' import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue' import TabsBlock from '@/components/blocks/TabsBlock.vue'
@@ -64,6 +64,8 @@ const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const courseIcon = gallery.courseIcon
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null)) const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
const onAdd = () => { const onAdd = () => {
@@ -3,7 +3,7 @@
<div class="exams-filters__grid"> <div class="exams-filters__grid">
<TextField v-model="form.title" name="title" label="عنوان آزمون"> <TextField v-model="form.title" name="title" label="عنوان آزمون">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
@@ -30,7 +30,11 @@
label="عنوان آزمون" label="عنوان آزمون"
:error="errors.title" :error="errors.title"
@blur="validateAt('title', form.title)" @blur="validateAt('title', form.title)"
/> >
<template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<TextField <TextField
v-model="form.minimumScore" v-model="form.minimumScore"
name="minimumScore" name="minimumScore"
@@ -39,7 +43,11 @@
:convert-digits="true" :convert-digits="true"
:error="errors.minimumScore" :error="errors.minimumScore"
@blur="validateAt('minimumScore', form.minimumScore)" @blur="validateAt('minimumScore', form.minimumScore)"
/> >
<template #appendIcon>
<SvgIcon name="newspaper" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<TextField <TextField
v-model="form.score" v-model="form.score"
name="score" name="score"
@@ -48,7 +56,11 @@
:convert-digits="true" :convert-digits="true"
:error="errors.score" :error="errors.score"
@blur="validateAt('score', form.score)" @blur="validateAt('score', form.score)"
/> >
<template #appendIcon>
<SvgIcon name="newspaper" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<TextField <TextField
v-model="form.durationMinutes" v-model="form.durationMinutes"
name="durationMinutes" name="durationMinutes"
@@ -57,14 +69,15 @@
:convert-digits="true" :convert-digits="true"
:error="errors.durationMinutes" :error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)" @blur="validateAt('durationMinutes', form.durationMinutes)"
/> >
<template #appendIcon>
<SvgIcon name="clock" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<div class="exam-form__toggle-cell"> <div class="exam-form__toggle-cell">
<ToggleSwitch v-model="form.isRandom" label="به صــــورت رندوم باشد." /> <ToggleSwitch v-model="form.isRandom" label="به صــــورت رندوم باشد." />
</div> </div>
</div> </div>
</div>
<div class="exam-form__card">
<TextareaField <TextareaField
v-model="form.description" v-model="form.description"
name="description" name="description"
@@ -6,7 +6,7 @@
desc="در این قسمت می‌توانید آزمون‌های خود را مدیریت کنید" desc="در این قسمت می‌توانید آزمون‌های خود را مدیریت کنید"
> >
<template #icon> <template #icon>
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" /> <img :src="sessionIcon" alt="" />
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
@@ -20,7 +20,7 @@
</SimpleTitleIconBlock> </SimpleTitleIconBlock>
<BaseButton text="افزودن آزمون جدید" custom-class="exams-page__add-btn" @click="onAdd"> <BaseButton text="افزودن آزمون جدید" custom-class="exams-page__add-btn" @click="onAdd">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div> </div>
@@ -50,6 +50,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { gallery } from '@/utils/gallery'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
@@ -75,6 +76,8 @@ const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const sessionIcon = gallery.sessionIcon
const filters = ref({ const filters = ref({
title: '', title: '',
termId: '', termId: '',
@@ -1,7 +1,7 @@
<template> <template>
<form class="tickets-filters" @submit.prevent="onSubmit"> <form class="tickets-filters" @submit.prevent="onSubmit">
<div class="tickets-filters__grid"> <div class="tickets-filters__grid">
<TextField v-model="form.userName" name="userName" label="نام کاربر"> <TextField v-model="form.userName" name="userName" label="جستجو اسم کاربر">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template> </template>
@@ -28,7 +28,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -14,7 +14,17 @@
</div> </div>
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
<div v-else-if="messages.length > 0" class="ticket-details__thread" ref="thread"> <div
v-else-if="messages.length > 0 || ticketMedia.length > 0"
class="ticket-details__thread"
ref="thread"
>
<div v-if="ticketMedia.length > 0" class="ticket-details__row ticket-details__row--user">
<div class="ticket-details__bubble">
<MessageAttachments :media="ticketMedia" />
<span class="ticket-details__time">{{ messageTime(ticket) }}</span>
</div>
</div>
<div <div
v-for="message in messages" v-for="message in messages"
:key="message.id" :key="message.id"
@@ -22,6 +32,7 @@
:class="`ticket-details__row--${senderClass(message)}`" :class="`ticket-details__row--${senderClass(message)}`"
> >
<div class="ticket-details__bubble"> <div class="ticket-details__bubble">
<MessageAttachments v-if="message.media?.length" :media="message.media" />
<p class="ticket-details__text">{{ message.message }}</p> <p class="ticket-details__text">{{ message.message }}</p>
<span class="ticket-details__time">{{ messageTime(message) }}</span> <span class="ticket-details__time">{{ messageTime(message) }}</span>
</div> </div>
@@ -31,6 +42,25 @@
<div class="ticket-details__divider" /> <div class="ticket-details__divider" />
<div v-if="attachments.length > 0" class="ticket-details__attachments">
<span
v-for="(file, index) in attachments"
:key="file.id ?? index"
class="ticket-details__attachment"
>
<SvgIcon name="file" :size="14" color="currentColor" />
<span class="ticket-details__attachment-name">{{ file.name }}</span>
<button
type="button"
class="ticket-details__attachment-remove"
aria-label="حذف فایل"
@click="removeAttachment(index)"
>
<SvgIcon name="close" :size="12" color="var(--color-error)" />
</button>
</span>
</div>
<form class="ticket-details__compose" @submit.prevent="onSend"> <form class="ticket-details__compose" @submit.prevent="onSend">
<button <button
type="submit" type="submit"
@@ -57,6 +87,23 @@
> >
<SvgIcon name="mood" :size="30" color="currentColor" /> <SvgIcon name="mood" :size="30" color="currentColor" />
</button> </button>
<button
type="button"
class="ticket-details__compose-btn"
:disabled="uploadMutation.isPending.value"
aria-label="پیوست فایل"
@click="fileInput?.click()"
>
<SvgIcon name="attach-file" :size="30" color="currentColor" />
</button>
<input
ref="fileInput"
type="file"
class="ticket-details__file-input"
accept="image/*,application/pdf"
multiple
@change="onFilesPicked"
/>
</div> </div>
</form> </form>
</div> </div>
@@ -81,13 +128,17 @@
</template> </template>
<script setup> <script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import MessageAttachments from '@/components/blocks/MessageAttachments.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { import {
autoUpdate, autoUpdate,
@@ -143,6 +194,8 @@ const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
const messages = computed(() => ticket.value?.messages ?? []) const messages = computed(() => ticket.value?.messages ?? [])
const ticketMedia = computed(() => ticket.value?.media ?? [])
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—') const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
// Anyone whose id matches the ticket's student is "the student"; everyone else // Anyone whose id matches the ticket's student is "the student"; everyone else
@@ -157,7 +210,49 @@ const messageTime = (message) => {
} }
const text = ref('') const text = ref('')
const canSend = computed(() => text.value.trim().length > 0)
const MAX_ATTACHMENTS = 5
const fileInput = ref(null)
const attachments = ref([])
const uploadMutation = useUploadMediaMutation()
const onFilesPicked = async (event) => {
const picked = [...event.target.files]
event.target.value = ''
if (attachments.value.length + picked.length > MAX_ATTACHMENTS) {
toast.warning(`حداکثر تعداد فایل مجاز ${MAX_ATTACHMENTS} عدد است.`)
return
}
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
attachments.value = [
...attachments.value,
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
]
} catch {
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
}
}
}
const removeAttachment = (index) => {
const next = [...attachments.value]
next.splice(index, 1)
attachments.value = next
}
const canSend = computed(
() =>
(text.value.trim().length > 0 || attachments.value.length > 0) &&
!uploadMutation.isPending.value
)
const emojiOpen = ref(false) const emojiOpen = ref(false)
const emojiButton = ref(null) const emojiButton = ref(null)
@@ -213,10 +308,15 @@ const sendMutation = useSendAdminTicketMessageMutation()
const onSend = async () => { const onSend = async () => {
if (!canSend.value || !ticketId.value) return if (!canSend.value || !ticketId.value) return
const value = text.value.trim() const value = text.value.trim()
const mediaIds = attachments.value.map((f) => f.id).filter((id) => id != null)
text.value = '' text.value = ''
attachments.value = []
emojiOpen.value = false emojiOpen.value = false
// Backend POST /admin/tickets/:id/messages — body is { message: string }. // Backend POST /admin/tickets/:id/messages — body is { message: string,
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } }) // mediaIds?: number[] } where mediaIds reference previously uploaded media.
const payload = { message: value }
if (mediaIds.length > 0) payload.mediaIds = mediaIds
await sendMutation.mutateAsync({ id: ticketId.value, payload })
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all }) await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
} }
@@ -405,6 +505,12 @@ watch(messages, async () => {
display: none; display: none;
} }
&__attachments {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
&__attachment { &__attachment {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+138 -23
View File
@@ -3,138 +3,253 @@ export default [
path: '/main-dashboard', path: '/main-dashboard',
name: 'admin-dashboard', name: 'admin-dashboard',
component: () => import('@/features/admin/pages/AdminDashboardPage.vue'), component: () => import('@/features/admin/pages/AdminDashboardPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'پیشخوان' }, meta: {
layout: 'admin',
role: 'admin',
title: 'سلام بر شما',
subtitle: 'به سامانه موسسه بانو خوش آمدید.',
},
}, },
{ {
path: '/users', path: '/users',
name: 'admin-users', name: 'admin-users',
component: () => import('@/features/admin/users/pages/UsersListPage.vue'), component: () => import('@/features/admin/users/pages/UsersListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت کاربران' }, meta: {
layout: 'admin',
role: 'admin',
title: 'لیست کاربران',
subtitle: 'کنترل کامل روی اطلاعات، نقش‌ها و وضعیت کاربران',
},
}, },
{ {
path: '/add-user', path: '/add-user',
name: 'admin-add-user', name: 'admin-add-user',
component: () => import('@/features/admin/users/pages/UserFormPage.vue'), component: () => import('@/features/admin/users/pages/UserFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'افزودن کاربر جدید' }, meta: {
layout: 'admin',
role: 'admin',
title: 'لیست کاربران',
subtitle: 'کنترل کامل روی اطلاعات، نقش‌ها و وضعیت کاربران',
},
}, },
{ {
path: '/edit-user/:id', path: '/edit-user/:id',
name: 'admin-edit-user', name: 'admin-edit-user',
component: () => import('@/features/admin/users/pages/UserFormPage.vue'), component: () => import('@/features/admin/users/pages/UserFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'ویرایش کاربر' }, meta: {
layout: 'admin',
role: 'admin',
title: 'لیست کاربران',
subtitle: 'کنترل کامل روی اطلاعات، نقش‌ها و وضعیت کاربران',
},
}, },
{ {
path: '/user-verification', path: '/user-verification',
name: 'admin-user-verification', name: 'admin-user-verification',
component: () => import('@/features/admin/users/pages/UserVerificationPage.vue'), component: () => import('@/features/admin/users/pages/UserVerificationPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'تایید هویت کاربران' }, meta: {
layout: 'admin',
role: 'admin',
title: 'کاربران سیستم من',
subtitle: 'دسترسی کامل به اطلاعات، نقش‌ها و وضعیت کاربران',
},
}, },
{ {
path: '/terms', path: '/terms',
name: 'admin-terms', name: 'admin-terms',
component: () => import('@/features/admin/terms/pages/TermsListPage.vue'), component: () => import('@/features/admin/terms/pages/TermsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت ترم' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/add-term', path: '/add-term',
name: 'admin-add-term', name: 'admin-add-term',
component: () => import('@/features/admin/terms/pages/TermFormPage.vue'), component: () => import('@/features/admin/terms/pages/TermFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'افزودن ترم جدید' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/edit-term/:id', path: '/edit-term/:id',
name: 'admin-edit-term', name: 'admin-edit-term',
component: () => import('@/features/admin/terms/pages/TermFormPage.vue'), component: () => import('@/features/admin/terms/pages/TermFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'ویرایش ترم' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/courses', path: '/courses',
name: 'admin-courses', name: 'admin-courses',
component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'), component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/add-course', path: '/add-course',
name: 'admin-add-course', name: 'admin-add-course',
component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'), component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/edit-course/:id', path: '/edit-course/:id',
name: 'admin-edit-course', name: 'admin-edit-course',
component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'), component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/terms/:termId/courses', path: '/terms/:termId/courses',
name: 'admin-term-courses', name: 'admin-term-courses',
component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'), component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'دوره‌های ترم' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/courses/:courseId/sessions', path: '/courses/:courseId/sessions',
name: 'admin-course-sessions', name: 'admin-course-sessions',
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'), component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'جلسات دوره' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/sessions', path: '/sessions',
name: 'admin-sessions', name: 'admin-sessions',
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'), component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت جلسات' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/add-session', path: '/add-session',
name: 'admin-add-session', name: 'admin-add-session',
component: () => import('@/features/admin/sessions/pages/SessionFormPage.vue'), component: () => import('@/features/admin/sessions/pages/SessionFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'افزودن جلسه جدید' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/edit-session/:id', path: '/edit-session/:id',
name: 'admin-edit-session', name: 'admin-edit-session',
component: () => import('@/features/admin/sessions/pages/SessionFormPage.vue'), component: () => import('@/features/admin/sessions/pages/SessionFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'ویرایش جلسه' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/exams', path: '/exams',
name: 'admin-exams', name: 'admin-exams',
component: () => import('@/features/admin/exams/pages/ExamsListPage.vue'), component: () => import('@/features/admin/exams/pages/ExamsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت آزمون‌ها' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/add-exam', path: '/add-exam',
name: 'admin-add-exam', name: 'admin-add-exam',
component: () => import('@/features/admin/exams/pages/ExamFormPage.vue'), component: () => import('@/features/admin/exams/pages/ExamFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'افزودن آزمون جدید' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/edit-exam/:id', path: '/edit-exam/:id',
name: 'admin-edit-exam', name: 'admin-edit-exam',
component: () => import('@/features/admin/exams/pages/ExamFormPage.vue'), component: () => import('@/features/admin/exams/pages/ExamFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'ویرایش آزمون' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/assignments', path: '/assignments',
name: 'admin-assignments', name: 'admin-assignments',
component: () => import('@/features/admin/assignments/pages/AssignmentsListPage.vue'), component: () => import('@/features/admin/assignments/pages/AssignmentsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت تکالیف' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز آموزش',
subtitle: 'دسترسی سریع به دوره‌ها، آزمون‌ها و نتایج آموزشی.',
},
}, },
{ {
path: '/messages', path: '/messages',
name: 'admin-messages', name: 'admin-messages',
component: () => import('@/features/admin/messages/pages/TicketsListPage.vue'), component: () => import('@/features/admin/messages/pages/TicketsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت پیام‌ها' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز پیام‌ها',
subtitle: 'تمام ارتباطات سیستمی و کاربری را در این بخش مدیریت نمایید.',
},
}, },
{ {
path: '/services', path: '/services',
name: 'admin-services', name: 'admin-services',
component: () => import('@/features/admin/services/pages/ServicesListPage.vue'), component: () => import('@/features/admin/services/pages/ServicesListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت خدمات' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز خدمات',
subtitle: 'لیست خدمات را مشاهده و مدیریت نمایید.',
},
}, },
{ {
path: '/consultations', path: '/consultations',
name: 'admin-consultations', name: 'admin-consultations',
component: () => import('@/features/admin/consultations/pages/ConsultationsListPage.vue'), component: () => import('@/features/admin/consultations/pages/ConsultationsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'مدیریت مشاوره' }, meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز مشاوره',
subtitle: 'جلسات مشاوره را پیگیری کرده و پاسخگوی نیازهای کاربران باشید.',
},
}, },
] ]
@@ -28,7 +28,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -3,7 +3,7 @@
<div class="sessions-filters__grid"> <div class="sessions-filters__grid">
<TextField v-model="form.title" name="title" label="عنوان جلسه"> <TextField v-model="form.title" name="title" label="عنوان جلسه">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
@@ -38,7 +38,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -12,16 +12,6 @@
<form class="session-form__form" @submit.prevent="onSubmit"> <form class="session-form__form" @submit.prevent="onSubmit">
<div class="session-form__grid"> <div class="session-form__grid">
<div class="session-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
<div class="session-form__main-col"> <div class="session-form__main-col">
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" /> <LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
@@ -112,6 +102,16 @@
/> />
</div> </div>
</div> </div>
<div class="session-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
</div> </div>
<div class="session-form__divider" /> <div class="session-form__divider" />
@@ -392,32 +392,27 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
gap: 1rem; gap: 1rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: 4fr 8fr; grid-template-columns: 8fr 4fr;
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: 3fr 9fr; grid-template-columns: 9fr 3fr;
} }
} }
&__image-col { &__image-col {
order: 2;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 1;
padding: 0.5rem; padding: 0.5rem;
} }
} }
&__main-col { &__main-col {
order: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 2; padding-inline-end: 1.25rem;
padding-inline-start: 1.25rem;
} }
} }
@@ -6,7 +6,7 @@
desc="در این قسمت می‌توانید جلسات خود را مدیریت کنید" desc="در این قسمت می‌توانید جلسات خود را مدیریت کنید"
> >
<template #icon> <template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" /> <img :src="sessionIcon" alt="" />
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
@@ -20,7 +20,7 @@
</SimpleTitleIconBlock> </SimpleTitleIconBlock>
<BaseButton text="افزودن جلسه جدید" custom-class="sessions-page__add-btn" @click="onAdd"> <BaseButton text="افزودن جلسه جدید" custom-class="sessions-page__add-btn" @click="onAdd">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div> </div>
@@ -3,7 +3,7 @@
<div class="terms-filters__grid"> <div class="terms-filters__grid">
<TextField v-model="form.title" name="title" label="عنوان"> <TextField v-model="form.title" name="title" label="عنوان">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
+14 -20
View File
@@ -14,16 +14,6 @@
<form class="term-form__form" @submit.prevent="onSubmit"> <form class="term-form__form" @submit.prevent="onSubmit">
<div class="term-form__grid"> <div class="term-form__grid">
<div class="term-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
<div class="term-form__main-col"> <div class="term-form__main-col">
<LineTitleBlock title="اطلاعات ترم" title-en="Term Details" /> <LineTitleBlock title="اطلاعات ترم" title-en="Term Details" />
<div class="term-form__row"> <div class="term-form__row">
@@ -91,6 +81,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="term-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
</div> </div>
<div class="term-form__divider" /> <div class="term-form__divider" />
@@ -249,29 +249,23 @@ const onCancel = () => router.push({ name: 'admin-terms' })
gap: 1rem; gap: 1rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: 4fr 8fr; grid-template-columns: 8fr 4fr;
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: 3fr 9fr; grid-template-columns: 9fr 3fr;
} }
} }
&__image-col { &__image-col {
order: 2;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 1;
padding: 0.5rem; padding: 0.5rem;
} }
} }
&__main-col { &__main-col {
order: 1;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 2; padding-inline-end: 1.25rem;
padding-inline-start: 1.25rem;
} }
} }
@@ -312,7 +306,7 @@ const onCancel = () => router.push({ name: 'admin-terms' })
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
gap: 0.625rem; gap: 0.625rem;
} }
@@ -48,7 +48,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -14,21 +14,29 @@
</div> </div>
</div> </div>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot> <div class="users-table__middle">
{{ USER_STATUS.blocked }} <div class="users-table__date">
</Badge> <span class="users-table__date-label">تاریخ ثبت نام:</span>
<span class="users-table__date-value">
{{ formatJalaaliDateTime(user.createdAt) || '—' }}
</span>
</div>
<button <button
type="button" type="button"
class="users-table__role-badge" class="users-table__role-badge"
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`" :class="`users-table__role-badge--${roleName(user) || 'unknown'}`"
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id" :disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
@click="onRoleClick($event, user)" @click="onRoleClick($event, user)"
> >
<span class="users-table__role-dot" /> <span class="users-table__role-dot" />
<span>{{ roleLabel(user) || 'بدون نقش' }}</span> <span>{{ roleLabel(user) || 'بدون نقش' }}</span>
<SvgIcon name="caret-down" :size="12" color="currentColor" /> <SvgIcon name="caret-down" :size="12" color="currentColor" />
</button> </button>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
{{ USER_STATUS.blocked }}
</Badge>
</div>
<div class="users-table__actions"> <div class="users-table__actions">
<button <button
@@ -77,6 +85,7 @@ import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import DropdownMenu from '@/components/DropdownMenu.vue' import DropdownMenu from '@/components/DropdownMenu.vue'
import { formatJalaaliDateTime } from '@/utils/date-utils'
import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users' import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
defineProps({ defineProps({
@@ -219,10 +228,38 @@ const menuItems = computed(() => {
} }
&__info { &__info {
flex: 1; flex: 1 1 33%;
min-width: 0; min-width: 0;
} }
&__middle {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1 1 33%;
}
&__date {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
white-space: nowrap;
line-height: 1.5;
}
&__date-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
margin-inline-end: 0.25rem;
}
&__date-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
}
&__name { &__name {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 500; font-weight: 500;
@@ -13,31 +13,28 @@
<template v-else-if="user"> <template v-else-if="user">
<div class="user-details__section"> <div class="user-details__section">
<div v-if="isStudent && !user.profileCompleted" class="user-details__notice"> <div class="user-details__row user-details__row--main">
<SvgIcon name="warning" :size="20" color="var(--color-primary)" /> <div class="user-details__head">
<span>اطلاعات ثبت نام این دانشجو کامل نیست.</span> <div class="user-details__avatar">
</div> <img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="fullName" />
<SvgIcon v-else name="user" :size="48" color="#cbd5e1" />
<div class="user-details__head"> </div>
<div class="user-details__avatar"> <div class="user-details__identity">
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="fullName" /> <div class="user-details__status" :class="statusModifier">
<SvgIcon v-else name="user" :size="48" color="#cbd5e1" /> <span class="user-details__status-dot" />
</div> <span>{{ user.status === 'blocked' ? 'غیر فعال' : 'فعال' }}</span>
<div class="user-details__identity"> </div>
<div class="user-details__status" :class="statusModifier"> <p class="user-details__label">نام و نام خانوادگی</p>
<span class="user-details__status-dot" /> <p class="user-details__name">{{ fullName || '—' }}</p>
<span>{{ user.status === 'blocked' ? 'غیر فعال' : 'فعال' }}</span> <Badge
class="user-details__role"
:variant="roleVariant"
label="نقش:"
:value="roleText"
dot
/>
</div> </div>
<p class="user-details__label">نام و نام خانوادگی</p>
<p class="user-details__name">{{ fullName || '—' }}</p>
<p class="user-details__role">
<span class="user-details__role-key">نقش:</span>
<span class="user-details__role-val">{{ roleText }}</span>
</p>
</div> </div>
</div>
<div class="user-details__grid">
<LineInfoBlock <LineInfoBlock
title="تاریخ تولد" title="تاریخ تولد"
:numeric-desc="formatJalaaliDate(user.birthday) || ''" :numeric-desc="formatJalaaliDate(user.birthday) || ''"
@@ -49,11 +46,14 @@
:desc="MARITAL_STATUS[user.marriageStatus] || '—'" :desc="MARITAL_STATUS[user.marriageStatus] || '—'"
/> />
<LineInfoBlock title="جنسیت" :desc="GENDER[user.gender] || '—'" /> <LineInfoBlock title="جنسیت" :desc="GENDER[user.gender] || '—'" />
<LineInfoBlock title="استان" :desc="user?.province?.name || '—'" />
<LineInfoBlock title="شهر" :desc="user?.city?.name || '—'" />
</div> </div>
<LineInfoBlock title="آدرس" :desc="user?.address || '—'" /> <div class="user-details__row user-details__row--location">
<LineInfoBlock title="استان" :desc="user?.province?.name || '—'" />
<LineInfoBlock title="شهر" :desc="user?.city?.name || '—'" />
<LineInfoBlock title="آدرس" :desc="user?.address || '—'" />
</div>
<LineInfoBlock title="بیوگرافی" :desc="user.bio || '—'" /> <LineInfoBlock title="بیوگرافی" :desc="user.bio || '—'" />
</div> </div>
@@ -95,6 +95,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
@@ -137,6 +138,16 @@ const roleText = computed(() => {
return name ? ROLE_LABELS[name] || name : 'بدون نقش' return name ? ROLE_LABELS[name] || name : 'بدون نقش'
}) })
const ROLE_BADGE_VARIANTS = {
student: 'danger',
teacher: 'warning',
counselor: 'blue',
missionary: 'info',
admin: 'neutral',
}
const roleVariant = computed(() => ROLE_BADGE_VARIANTS[roleName.value] || 'neutral')
const isStudent = computed(() => roleName.value === 'student') const isStudent = computed(() => roleName.value === 'student')
const statusModifier = computed(() => const statusModifier = computed(() =>
@@ -172,7 +183,43 @@ const statusModifier = computed(() =>
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.875rem; gap: 0.875rem;
margin-bottom: 0.75rem; }
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
&__row--main {
align-items: center;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: auto repeat(5, 1fr);
}
}
&__row--main &__head {
grid-column: 1 / -1;
@media (min-width: 1024px) {
grid-column: auto;
}
}
&__row--location {
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
} }
&__avatar { &__avatar {
@@ -245,23 +292,7 @@ const statusModifier = computed(() =>
} }
&__role { &__role {
display: inline-flex; margin-top: 0.25rem;
align-items: center;
gap: 0.25rem;
margin: 0.25rem 0 0;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
background-color: rgba(0, 0, 0, 4%);
font-family: var(--font-family-fa);
font-size: 0.75rem;
}
&__role-key {
color: #808080;
}
&__role-val {
font-weight: 500;
} }
&__grid { &__grid {
@@ -21,25 +21,25 @@
<div class="verification-item__date"> <div class="verification-item__date">
<span class="verification-item__date-label">تاریخ ثبت نام:</span> <span class="verification-item__date-label">تاریخ ثبت نام:</span>
<span class="verification-item__date-value"> <span class="verification-item__date-value">
{{ formatJalaaliDate(user.createdAt) || '—' }} {{ formatJalaaliDateTime(user.createdAt) || '—' }}
</span> </span>
</div> </div>
<div <Badge
v-if="user.status === 'verified'" v-if="user.status === 'rejected'"
class="verification-item__badge verification-item__badge--pending" variant="danger"
> :dot="true"
<span class="verification-item__dot" /> value="رد شده"
<span>در انتظار تایید</span> :clickable="true"
</div>
<button
v-else-if="user.status === 'rejected'"
type="button"
class="verification-item__badge verification-item__badge--rejected"
@click="emit('show-reject-reason', user)" @click="emit('show-reject-reason', user)"
> />
<span class="verification-item__dot" /> <Badge
<span>رد شده</span> v-else-if="statusLabel"
</button> :variant="statusVariant"
:dot="true"
:value="statusLabel"
:clickable="user.status === 'blocked'"
@click="emit('show-reject-reason', user)"
/>
</div> </div>
<div class="verification-item__actions"> <div class="verification-item__actions">
@@ -54,11 +54,11 @@
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
v-if="user.status !== 'rejected'" v-if="user.status !== 'blocked'"
tooltip="عدم تایید" tooltip="عدم تایید"
bg-color="rgba(204, 40, 49, 0.08)" bg-color="rgba(204, 40, 49, 0.08)"
size="2.5rem" size="2.5rem"
@click="emit('verify-user', { id: user.id, status: 'rejected' })" @click="emit('verify-user', { id: user.id, status: 'blocked' })"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" color="var(--color-error)" /> <SvgIcon name="close" :size="20" color="var(--color-error)" />
@@ -80,10 +80,12 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { USER_STATUS } from '@/enums'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue' import CircleButton from '@/components/CircleButton.vue'
import { formatJalaaliDateTime } from '@/utils/date-utils'
const props = defineProps({ const props = defineProps({
user: { type: Object, required: true }, user: { type: Object, required: true },
@@ -92,7 +94,16 @@ const props = defineProps({
const emit = defineEmits(['show-details', 'show-reject-reason', 'verify-user']) const emit = defineEmits(['show-details', 'show-reject-reason', 'verify-user'])
const STATUS_VARIANTS = {
pending: 'neutral',
verified: 'warning',
approved: 'success',
blocked: 'danger',
}
const fullName = computed(() => `${props.user.name || ''}`.trim() || '—') const fullName = computed(() => `${props.user.name || ''}`.trim() || '—')
const statusLabel = computed(() => USER_STATUS[props.user.status] || '')
const statusVariant = computed(() => STATUS_VARIANTS[props.user.status] || 'neutral')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -202,37 +213,6 @@ const fullName = computed(() => `${props.user.name || ''}`.trim() || '—')
font-size: 0.75rem; font-size: 0.75rem;
} }
&__badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 1rem;
border-radius: 0.875rem;
font-family: var(--font-family-fa);
font-size: 0.7rem;
line-height: 1.5;
border: none;
cursor: default;
&--pending {
background: rgba(0, 255, 68, 6%);
color: #009a12;
}
&--rejected {
background: rgba(204, 40, 49, 6%);
color: var(--color-error);
cursor: pointer;
}
}
&__dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 9999px;
background: currentcolor;
}
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -31,7 +31,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -11,7 +11,7 @@
<div class="reject-reason-details"> <div class="reject-reason-details">
<div class="reject-reason-details__grid"> <div class="reject-reason-details__grid">
<div class="reject-reason-details__cell--full"> <div class="reject-reason-details__cell--full">
<LineInfoBlock title="توضیحات" :desc="verification(data).adminNote || ''" /> <LineInfoBlock title="توضیحات" :desc="data?.user?.blockReason || ''" />
</div> </div>
</div> </div>
@@ -34,8 +34,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'RejectReasonDetailsModal' }) defineOptions({ name: 'RejectReasonDetailsModal' })
const verification = (data) => data?.verification || {}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -9,31 +9,13 @@
> >
<template #default="{ data, close }"> <template #default="{ data, close }">
<form class="reject-reason-modal" @submit.prevent="onSubmit(data, close)"> <form class="reject-reason-modal" @submit.prevent="onSubmit(data, close)">
<div class="reject-reason-modal__row">
<TextField
v-model="form.rejectionReason"
name="rejectionReason"
label="علت رد"
:error="errors.rejectionReason"
@blur="validateAt('rejectionReason', form.rejectionReason)"
/>
<SelectField
:model-value="form.priority?.id"
name="priority"
label="وضعیت تیکت"
:options="statusOptions"
option-label="label"
option-value="value"
:error="errors.priority"
@update:model-value="(e) => handleChange('priority', { id: e })"
/>
</div>
<TextareaField <TextareaField
v-model="form.adminNote" v-model="form.reason"
name="adminNote" name="reason"
label="لطفا علت رد را توضیح دهید" label="لطفا علت رد را توضیح دهید"
:row="4" :row="4"
:error="errors.adminNote" :error="errors.reason"
@blur="validateAt('reason', form.reason)"
/> />
<div class="reject-reason-modal__divider" /> <div class="reject-reason-modal__divider" />
@@ -68,14 +50,11 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { PRIORITY_STATUS } from '@/enums'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import { rejectionReasonSchema } from '../../../schema' import { rejectionReasonSchema } from '../../../schema'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { adminUsersKeys, useChangeAdminUserStatusMutation } from '@/services/query/admin-users' import { adminUsersKeys, useChangeAdminUserStatusMutation } from '@/services/query/admin-users'
@@ -84,22 +63,12 @@ defineOptions({ name: 'RejectReasonModal' })
const queryClient = useQueryClient() const queryClient = useQueryClient()
const changeStatusMutation = useChangeAdminUserStatusMutation() const changeStatusMutation = useChangeAdminUserStatusMutation()
const DEFAULT_FORM = { rejectionReason: '', adminNote: '' } const DEFAULT_FORM = { reason: '' }
const { validate, validateAt, errors, resetErrors } = useYup(rejectionReasonSchema) const { validate, validateAt, errors, resetErrors } = useYup(rejectionReasonSchema)
const form = ref(structuredClone({ ...DEFAULT_FORM })) const form = ref(structuredClone({ ...DEFAULT_FORM }))
const statusOptions = Object.entries(PRIORITY_STATUS).map(([value, label]) => ({
value,
label,
}))
const handleChange = (key, value) => {
form.value = { ...form.value, [key]: value }
validateAt(key, value)
}
const onSubmit = async (data, close) => { const onSubmit = async (data, close) => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
@@ -107,7 +76,7 @@ const onSubmit = async (data, close) => {
if (!id) return if (!id) return
await changeStatusMutation.mutateAsync({ await changeStatusMutation.mutateAsync({
id, id,
payload: { status: 'blocked', ...payload }, payload: { status: 'blocked', reason: payload.reason },
}) })
await queryClient.invalidateQueries({ queryKey: adminUsersKeys.all }) await queryClient.invalidateQueries({ queryKey: adminUsersKeys.all })
form.value = structuredClone({ ...DEFAULT_FORM }) form.value = structuredClone({ ...DEFAULT_FORM })
@@ -122,11 +91,6 @@ const onSubmit = async (data, close) => {
text-align: start; text-align: start;
padding: 0.5rem; padding: 0.5rem;
&__row {
display: flex;
gap: 1rem;
}
&__divider { &__divider {
border-block-end: 1px solid var(--color-thd-gray); border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1rem; margin-block: 1rem;
@@ -21,10 +21,7 @@
/> />
<LineInfoBlock title="شماره تماس" :numeric-desc="personal.phone" /> <LineInfoBlock title="شماره تماس" :numeric-desc="personal.phone" />
<LineInfoBlock title="کد ملی" :numeric-desc="personal.nationalCode" /> <LineInfoBlock title="کد ملی" :numeric-desc="personal.nationalCode" />
<LineInfoBlock <LineInfoBlock title="وضعیت" :desc="MARITAL_STATUS[personal.maritalStatus] || ''" />
title="وضعیت تاهل"
:desc="MARITAL_STATUS[personal.maritalStatus] || ''"
/>
<LineInfoBlock title="جنسیت" :desc="GENDER[personal.gender] || ''" /> <LineInfoBlock title="جنسیت" :desc="GENDER[personal.gender] || ''" />
<LineInfoBlock title="استان" :desc="personal.province || '-'" /> <LineInfoBlock title="استان" :desc="personal.province || '-'" />
<LineInfoBlock title="شهر" :desc="personal.city || '-'" /> <LineInfoBlock title="شهر" :desc="personal.city || '-'" />
+15 -21
View File
@@ -14,16 +14,6 @@
<form class="user-form__form" @submit.prevent="onSubmit"> <form class="user-form__form" @submit.prevent="onSubmit">
<div class="user-form__grid"> <div class="user-form__grid">
<div class="user-form__avatar-col">
<ImageCropper
v-model="avatar"
name="avatar"
bg-color="#eeeeee"
@crop="onAvatarCropped"
@error="onAvatarError"
/>
</div>
<div class="user-form__main-col"> <div class="user-form__main-col">
<LineTitleBlock title="مشخصات فردی" title-en="Personal Details" /> <LineTitleBlock title="مشخصات فردی" title-en="Personal Details" />
<div class="user-form__row"> <div class="user-form__row">
@@ -200,6 +190,16 @@
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
<div class="user-form__avatar-col">
<ImageCropper
v-model="avatar"
name="avatar"
bg-color="#eeeeee"
@crop="onAvatarCropped"
@error="onAvatarError"
/>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -285,7 +285,7 @@ const avatar = ref(null)
const schema = isEditMode.value ? userEditSchema : userAddSchema const schema = isEditMode.value ? userEditSchema : userAddSchema
const { validate, validateAt, errors } = useYup(schema) const { validate, validateAt, errors } = useYup(schema, () => form.value)
const onProvinceChange = () => { const onProvinceChange = () => {
provinceIdRef.value = form.value.provinceId provinceIdRef.value = form.value.provinceId
@@ -336,7 +336,7 @@ const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => { const onAvatarCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'user' }) const formData = objectToFormData({ file, purpose: 'avatar', context: 'user' })
const response = await uploadMutation.mutateAsync(formData) const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload } avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
@@ -411,29 +411,23 @@ const onCancel = () => router.push({ name: 'admin-users' })
gap: 1rem; gap: 1rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: 4fr 8fr; grid-template-columns: 8fr 4fr;
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: 3fr 9fr; grid-template-columns: 9fr 3fr;
} }
} }
&__avatar-col { &__avatar-col {
order: 2;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 1;
padding: 0.5rem; padding: 0.5rem;
} }
} }
&__main-col { &__main-col {
order: 1;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 2; padding-inline-end: 1.25rem;
padding-inline-start: 1.25rem;
} }
} }
@@ -86,7 +86,7 @@ const onFilterReset = () => resetPagination()
const changeStatusMutation = useChangeAdminUserStatusMutation() const changeStatusMutation = useChangeAdminUserStatusMutation()
const onVerifyUser = ({ id, status }) => { const onVerifyUser = ({ id, status }) => {
if (status === 'rejected') { if (status === 'blocked') {
openModal('RejectReasonModal', { id }) openModal('RejectReasonModal', { id })
return return
} }
@@ -106,7 +106,7 @@ const onShowDetails = (id) => {
} }
const onShowRejectReason = (user) => { const onShowRejectReason = (user) => {
openModal('RejectReasonDetailsModal', { verification: user.verification }) openModal('RejectReasonDetailsModal', { user })
} }
</script> </script>
@@ -20,7 +20,7 @@
</SimpleTitleIconBlock> </SimpleTitleIconBlock>
<BaseButton text="افزودن کاربر جدید" custom-class="users-page__add-btn" @click="onAdd"> <BaseButton text="افزودن کاربر جدید" custom-class="users-page__add-btn" @click="onAdd">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div> </div>
+1 -8
View File
@@ -5,14 +5,7 @@ import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
import { passwordConfirmationRule } from '@/constants/rules/passwordConfirmationRule' import { passwordConfirmationRule } from '@/constants/rules/passwordConfirmationRule'
export const rejectionReasonSchema = object().shape({ export const rejectionReasonSchema = object().shape({
rejectionReason: string().required(), reason: string().required(),
priority: object()
.shape({
id: string().required().default(null),
})
.required()
.default(null),
adminNote: string().notRequired(),
}) })
const baseUserShape = { const baseUserShape = {
+19 -19
View File
@@ -41,19 +41,19 @@
<SvgIcon name="arrow-left" :size="22" color="#fff" /> <SvgIcon name="arrow-left" :size="22" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div>
<div class="login-form__links"> <div class="login-form__links">
<div class="login-form__link"> <div class="login-form__link">
<SvgIcon name="user" :size="12" color="#A7A7A7" /> <SvgIcon name="user" :size="12" color="#A7A7A7" />
<span>حساب کاربری ندارم</span> <span>حساب کاربری ندارم</span>
<RouterLink :to="{ name: 'signup' }">ثبت نام</RouterLink> <RouterLink :to="{ name: 'signup' }">ثبت نام</RouterLink>
</div> </div>
<div class="login-form__link"> <div class="login-form__link">
<SvgIcon name="check" :size="12" color="#A7A7A7" /> <SvgIcon name="check" :size="12" color="#A7A7A7" />
<button type="button" class="login-form__link-btn" @click="goLoginWithCode"> <button type="button" class="login-form__link-btn" @click="goLoginWithCode">
ورود با کد تایید ورود با کد تایید
</button> </button>
</div>
</div> </div>
</div> </div>
@@ -110,26 +110,30 @@ const goForgotPassword = () => emit('next', { mode: 'forgotPassword' })
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
flex-grow: 1; flex-grow: 1;
&__body { &__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex-grow: 1;
width: 100%; width: 100%;
padding: 1rem 0; padding: 1rem 0;
@media (min-width: 768px) { @media (min-width: 768px) {
flex-grow: 1;
justify-content: center; justify-content: center;
} }
} }
&__footer { &__footer {
margin-top: auto;
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: center; text-align: center;
width: 100%; width: 100%;
@media (min-width: 768px) {
margin-top: 0;
}
} }
} }
@@ -162,16 +166,12 @@ const goForgotPassword = () => emit('next', { mode: 'forgotPassword' })
} }
.login-form__links { .login-form__links {
margin-top: 1.5rem; margin: 0.5rem 0;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
@media (min-width: 768px) {
margin-top: auto;
}
} }
.login-form__link { .login-form__link {
@@ -34,12 +34,12 @@
<SvgIcon name="arrow-left" :size="22" color="#fff" /> <SvgIcon name="arrow-left" :size="22" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div>
<div class="login-otp__links"> <div class="login-otp__links">
<button type="button" class="login-otp__back" @click="emit('back')"> <button type="button" class="login-otp__back" @click="emit('back')">
بازگشت به ورود با رمز عبور بازگشت به ورود با رمز عبور
</button> </button>
</div>
</div> </div>
<div class="auth-form__footer"> <div class="auth-form__footer">
@@ -98,26 +98,30 @@ const onSubmit = async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
flex-grow: 1; flex-grow: 1;
&__body { &__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex-grow: 1;
width: 100%; width: 100%;
padding: 1rem 0; padding: 1rem 0;
@media (min-width: 768px) { @media (min-width: 768px) {
flex-grow: 1;
justify-content: center; justify-content: center;
} }
} }
&__footer { &__footer {
margin-top: auto;
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: center; text-align: center;
width: 100%; width: 100%;
@media (min-width: 768px) {
margin-top: 0;
}
} }
} }
@@ -127,14 +131,10 @@ const onSubmit = async () => {
} }
.login-otp__links { .login-otp__links {
margin-top: 1.5rem; margin: 0.5rem 0;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@media (min-width: 768px) {
margin-top: auto;
}
} }
.login-otp__back { .login-otp__back {
+12 -12
View File
@@ -67,12 +67,12 @@
<SvgIcon name="arrow-left" :size="22" color="#fff" /> <SvgIcon name="arrow-left" :size="22" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div>
<div class="signup-form__login-link"> <div class="signup-form__login-link">
<SvgIcon name="user" :size="12" color="#A7A7A7" /> <SvgIcon name="user" :size="12" color="#A7A7A7" />
<span>حساب کاربری دارم</span> <span>حساب کاربری دارم</span>
<RouterLink :to="{ name: 'login' }">ورود</RouterLink> <RouterLink :to="{ name: 'login' }">ورود</RouterLink>
</div>
</div> </div>
<div class="auth-form__footer"> <div class="auth-form__footer">
@@ -131,26 +131,30 @@ const onSubmit = async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
flex-grow: 1; flex-grow: 1;
&__body { &__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex-grow: 1;
width: 100%; width: 100%;
padding: 1rem 0; padding: 1rem 0;
@media (min-width: 768px) { @media (min-width: 768px) {
flex-grow: 1;
justify-content: center; justify-content: center;
} }
} }
&__footer { &__footer {
margin-top: auto;
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: center; text-align: center;
width: 100%; width: 100%;
@media (min-width: 768px) {
margin-top: 0;
}
} }
} }
@@ -160,7 +164,7 @@ const onSubmit = async () => {
} }
.signup-form__login-link { .signup-form__login-link {
margin-top: 1.5rem; margin: 0.5rem 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
@@ -168,10 +172,6 @@ const onSubmit = async () => {
font-size: 0.75rem; font-size: 0.75rem;
color: #a7a7a7; color: #a7a7a7;
@media (min-width: 768px) {
margin-top: auto;
}
a { a {
color: var(--color-sec-gray); color: var(--color-sec-gray);
text-decoration: underline; text-decoration: underline;
+20 -21
View File
@@ -28,20 +28,20 @@
<SvgIcon name="arrow-left" :size="22" color="#fff" /> <SvgIcon name="arrow-left" :size="22" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div>
<div class="verify__links"> <div class="verify__links">
<div class="verify__link"> <div class="verify__link">
<SvgIcon name="user" :size="12" color="#A7A7A7" /> <SvgIcon name="user" :size="12" color="#A7A7A7" />
<button type="button" class="verify__link-btn" @click="emit('password-login')"> <button type="button" class="verify__link-btn" @click="emit('password-login')">
ورود با رمز عبور ورود با رمز عبور
</button> </button>
</div> </div>
<div class="verify__link"> <div class="verify__link">
<SvgIcon name="phone" :size="12" color="#A7A7A7" /> <SvgIcon name="phone" :size="12" color="#A7A7A7" />
<button type="button" class="verify__link-btn" @click="emit('back')"> <button type="button" class="verify__link-btn" @click="emit('back')">
تغییر شماره تلفن همراه تغییر شماره تلفن همراه
</button> </button>
</div>
</div> </div>
</div> </div>
@@ -137,7 +137,6 @@ const onSubmit = async () => {
const response = await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, code }) const response = await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, code })
applySession(response?.data ?? response) applySession(response?.data ?? response)
emit('verified', { phone: props.phoneNumber, code }) emit('verified', { phone: props.phoneNumber, code })
redirectAfterLogin()
} }
const onResend = async () => { const onResend = async () => {
@@ -154,26 +153,30 @@ const onResend = async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
flex-grow: 1; flex-grow: 1;
&__body { &__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex-grow: 1;
width: 100%; width: 100%;
padding: 1rem 0; padding: 1rem 0;
@media (min-width: 768px) { @media (min-width: 768px) {
flex-grow: 1;
justify-content: center; justify-content: center;
} }
} }
&__footer { &__footer {
margin-top: auto;
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: center; text-align: center;
width: 100%; width: 100%;
@media (min-width: 768px) {
margin-top: 0;
}
} }
} }
@@ -201,16 +204,12 @@ const onResend = async () => {
} }
.verify__links { .verify__links {
margin-top: 1.5rem; margin: 0.5rem 0;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
@media (min-width: 768px) {
margin-top: auto;
}
} }
.verify__link { .verify__link {
@@ -111,11 +111,8 @@ import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { educationalInformationSchema } from '@/features/auth/schema/student-register' import { educationalInformationSchema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { EDUCATION_STATUS, SEMINARY_LEVEL, STUDENT_REGISTRATION, UNIVERSITY_LEVEL } from '@/enums' import { EDUCATION_STATUS, SEMINARY_LEVEL, STUDENT_REGISTRATION, UNIVERSITY_LEVEL } from '@/enums'
import {
toKeyValueList,
useStudentRegistrationStore,
} from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
@@ -148,7 +145,7 @@ const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
await saveRegisterMutation.mutateAsync(toKeyValueList(payload)) await saveRegisterMutation.mutateAsync(payload)
store.educational = { ...store.educational, ...payload } store.educational = { ...store.educational, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION) store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
store.goToNextStep() store.goToNextStep()
@@ -30,11 +30,11 @@
</div> </div>
<div class="personal-info__cell"> <div class="personal-info__cell">
<DatePickerField <DatePickerField
v-model="form.birthDate" v-model="form.birthday"
name="birthDate" name="birthday"
label="تاریخ تولد" label="تاریخ تولد"
:max="maxBirthDate" :max="maxBirthDate"
:error="errors.birthDate" :error="errors.birthday"
/> />
</div> </div>
<div class="personal-info__cell"> <div class="personal-info__cell">
@@ -198,21 +198,25 @@ const maxBirthDate = computed(() => {
const initialPhone = ref(user.value?.phone || store.personal.phone || '') const initialPhone = ref(user.value?.phone || store.personal.phone || '')
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '') const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
const initialAvatar =
store.personal.avatar || (user.value?.avatarUrl ? { url: user.value.avatarUrl } : null)
const form = ref({ const form = ref({
name: user.value?.name || store.personal.name || '', name: user.value?.name || store.personal.name || '',
birthDate: store.personal.birthDate || '', birthday: user.value?.birthday || store.personal.birthday || '',
nationalCode: initialNationalCode.value, nationalCode: initialNationalCode.value,
maritalStatus: store.personal.maritalStatus || '', maritalStatus: user.value?.marriageStatus || store.personal.maritalStatus || '',
gender: store.personal.gender || '', gender: user.value?.gender || store.personal.gender || '',
phone: initialPhone.value, phone: initialPhone.value,
provinceId: store.personal.provinceId || null, provinceId: user.value?.provinceId || store.personal.provinceId || null,
cityId: store.personal.cityId || null, cityId: user.value?.cityId || store.personal.cityId || null,
address: store.personal.address || '', address:
avatar: store.personal.avatar || null, (typeof user.value?.address === 'string' && user.value.address) || store.personal.address || '',
avatar: initialAvatar,
avatarId: store.personal.avatarId || '', avatarId: store.personal.avatarId || '',
}) })
const avatar = ref(store.personal.avatar || null) const avatar = ref(initialAvatar)
const { validate, validateAt, errors } = useYup(personalInformationSchema) const { validate, validateAt, errors } = useYup(personalInformationSchema)
@@ -263,7 +267,7 @@ const onSubmit = async () => {
// (http auto-converts camelCase snake_case: birthday, national_code, ) // (http auto-converts camelCase snake_case: birthday, national_code, )
const body = { const body = {
name: payload.name, name: payload.name,
birthday: payload.birthDate, birthday: payload.birthday,
nationalCode: payload.nationalCode, nationalCode: payload.nationalCode,
marriageStatus: payload.maritalStatus, marriageStatus: payload.maritalStatus,
gender: payload.gender, gender: payload.gender,
@@ -117,10 +117,7 @@ import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums' import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { professionalInformationSchema } from '@/features/auth/schema/student-register' import { professionalInformationSchema } from '@/features/auth/schema/student-register'
import { import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
toKeyValueList,
useStudentRegistrationStore,
} from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
@@ -193,7 +190,7 @@ const onSubmit = async () => {
specializedTopics: payload.specializedTopics, specializedTopics: payload.specializedTopics,
propagationPlatforms, propagationPlatforms,
} }
await saveRegisterMutation.mutateAsync(toKeyValueList(next)) await saveRegisterMutation.mutateAsync(next)
store.professional = next store.professional = next
store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION) store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
store.goToNextStep() store.goToNextStep()
@@ -18,6 +18,7 @@
<SvgIcon :name="tab.icon" :size="22" color="#fff" /> <SvgIcon :name="tab.icon" :size="22" color="#fff" />
</li> </li>
</ul> </ul>
<SidebarLogout color="#fff" stacked />
</div> </div>
</aside> </aside>
</template> </template>
@@ -25,6 +26,7 @@
<script setup> <script setup>
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({ defineProps({
tabs: { type: Array, required: true }, tabs: { type: Array, required: true },
@@ -42,10 +42,7 @@ import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart6Schema } from '@/features/auth/schema/student-register' import { skillAssessmentPart6Schema } from '@/features/auth/schema/student-register'
import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue' import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue'
import { import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
toKeyValueList,
useStudentRegistrationStore,
} from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
@@ -70,7 +67,7 @@ const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
await saveRegisterMutation.mutateAsync(toKeyValueList(payload)) await saveRegisterMutation.mutateAsync(payload)
store.skill6 = { ...store.skill6, ...payload } store.skill6 = { ...store.skill6, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6) store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
store.goToNextStep() store.goToNextStep()
@@ -46,10 +46,7 @@ import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart7Schema } from '@/features/auth/schema/student-register' import { skillAssessmentPart7Schema } from '@/features/auth/schema/student-register'
import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue' import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue'
import { import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
toKeyValueList,
useStudentRegistrationStore,
} from '@/features/auth/store/student-registration'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
@@ -75,7 +72,7 @@ const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
try { try {
await saveRegisterMutation.mutateAsync(toKeyValueList(payload)) await saveRegisterMutation.mutateAsync(payload)
store.skill7 = { ...store.skill7, ...payload } store.skill7 = { ...store.skill7, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7) store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
store.isProfileCompleted = true store.isProfileCompleted = true
@@ -46,10 +46,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
toKeyValueList,
useStudentRegistrationStore,
} from '@/features/auth/store/student-registration'
const props = defineProps({ const props = defineProps({
step: { type: Number, required: true }, step: { type: Number, required: true },
@@ -89,7 +86,7 @@ const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
await saveRegisterMutation.mutateAsync(toKeyValueList(payload)) await saveRegisterMutation.mutateAsync(payload)
store[props.storeKey] = { ...store[props.storeKey], ...payload } store[props.storeKey] = { ...store[props.storeKey], ...payload }
store.markStepAsCompleted(props.step) store.markStepAsCompleted(props.step)
store.goToNextStep() store.goToNextStep()
@@ -1,3 +1,4 @@
import { ROLE_HOME_ROUTES } from '@/enums'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -12,8 +13,7 @@ export const useRedirectAfterLogin = () => {
router.push(redirect).catch(() => router.push('/')) router.push(redirect).catch(() => router.push('/'))
return return
} }
const target = const target = { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' }
auth.role === 'admin' ? { name: 'admin-dashboard' } : { name: 'student-dashboard' }
router.push(target).catch(() => router.push('/')) router.push(target).catch(() => router.push('/'))
} }
} }
@@ -44,8 +44,8 @@ const { isModal, openModal } = useModal()
const { data: registerData } = useGetRegisterDataQuery() const { data: registerData } = useGetRegisterDataQuery()
watch( watch(
registerData, registerData,
(list) => { (value) => {
if (Array.isArray(list) && list.length > 0) store.hydrateFromKeyValueList(list) if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value)
}, },
{ immediate: true } { immediate: true }
) )
+1 -1
View File
@@ -12,7 +12,7 @@ export const personalInformationSchema = object().shape({
avatar: mixed().notRequired(), avatar: mixed().notRequired(),
avatarId: mixed().notRequired(), avatarId: mixed().notRequired(),
name: requiredString(3), name: requiredString(3),
birthDate: string().required(), birthday: string().required(),
nationalCode: nationalCodeRule, nationalCode: nationalCodeRule,
maritalStatus: string().required(), maritalStatus: string().required(),
gender: string().required(), gender: string().required(),
+18 -16
View File
@@ -10,7 +10,7 @@ const emptyState = () => ({
avatar: null, avatar: null,
avatarId: '', avatarId: '',
name: '', name: '',
birthDate: '', birthday: '',
nationalCode: '', nationalCode: '',
maritalStatus: '', maritalStatus: '',
gender: '', gender: '',
@@ -75,7 +75,7 @@ const SECTION_FIELDS = {
'avatar', 'avatar',
'avatarId', 'avatarId',
'name', 'name',
'birthDate', 'birthday',
'nationalCode', 'nationalCode',
'maritalStatus', 'maritalStatus',
'gender', 'gender',
@@ -115,15 +115,16 @@ const FIELD_TO_SECTION = Object.entries(SECTION_FIELDS).reduce((acc, [section, f
return acc return acc
}, {}) }, {})
export const toKeyValueList = (obj = {}) => // Register-data travels as a flat `{ field: value }` object. Legacy payloads
Object.entries(obj).map(([key, value]) => ({ key, value })) // were a `[{ key, value }]` list — tolerate both when reading.
export const toRegisterDataObject = (input) => {
export const fromKeyValueList = (list = []) => { if (Array.isArray(input)) {
if (!Array.isArray(list)) return {} return input.reduce((acc, item) => {
return list.reduce((acc, item) => { if (item && typeof item === 'object' && 'key' in item) acc[item.key] = item.value
if (item && typeof item === 'object' && 'key' in item) acc[item.key] = item.value return acc
return acc }, {})
}, {}) }
return input && typeof input === 'object' ? input : {}
} }
const readPersisted = () => { const readPersisted = () => {
@@ -241,10 +242,11 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
: personal.value.avatarId, : personal.value.avatarId,
}) })
// Take the flat `{key,value}` list from GET /me/register-data and slot each // Take the flat `{ field: value }` map from GET /me/register-data and slot
// value into the right section ref, leaving anything we don't recognise alone. // each value into the right section ref, leaving anything we don't
const hydrateFromKeyValueList = (list) => { // recognise alone.
const flat = fromKeyValueList(list) const hydrateFromRegisterData = (input) => {
const flat = toRegisterDataObject(input)
const buckets = {} const buckets = {}
Object.entries(flat).forEach(([key, value]) => { Object.entries(flat).forEach(([key, value]) => {
const section = FIELD_TO_SECTION[key] const section = FIELD_TO_SECTION[key]
@@ -301,7 +303,7 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
goToPrevStep, goToPrevStep,
canGoToStep, canGoToStep,
buildCompletePayload, buildCompletePayload,
hydrateFromKeyValueList, hydrateFromRegisterData,
reset, reset,
} }
}) })
+6 -1
View File
@@ -3,6 +3,11 @@ export default [
path: '/counselor/consultations', path: '/counselor/consultations',
name: 'counselor-consultations', name: 'counselor-consultations',
component: () => import('@/features/counselor/pages/CounselorConsultationsPage.vue'), component: () => import('@/features/counselor/pages/CounselorConsultationsPage.vue'),
meta: { layout: 'counselor', role: 'counselor', title: 'مشاوره' }, meta: {
layout: 'counselor',
role: 'counselor',
title: 'مرکز مشاوره',
subtitle: 'جلسات مشاوره را پیگیری کرده و پاسخگوی نیازهای کاربران باشید.',
},
}, },
] ]
@@ -0,0 +1,181 @@
<template>
<div class="missionary-request" @click="emit('show-details', request)">
<div class="missionary-request__heading">
<p class="missionary-request__title">{{ request.title }}</p>
<p class="missionary-request__subtitle">{{ request.requesterName }}</p>
</div>
<div class="missionary-request__meta">
<div class="missionary-request__pill">
<span class="missionary-request__pill-label">تاریخ درخواست :</span>
<span class="missionary-request__pill-value">
{{ formatJalaaliDate(request.requestedDate) || '—' }}
</span>
</div>
<div class="missionary-request__pill">
<span class="missionary-request__pill-label">مکان :</span>
<span class="missionary-request__pill-value missionary-request__pill-value--fa">
{{ request.location || '—' }}
</span>
</div>
<Badge :variant="statusVariant" :dot="true" :value="statusLabel" />
</div>
<div class="missionary-request__actions" @click.stop>
<CircleButton
v-if="request.status !== 'accepted'"
tooltip="پذیرش درخواست"
bg-color="rgba(0, 154, 18, 0.08)"
size="2.5rem"
@click="emit('change-status', { id: request.id, status: 'accepted' })"
>
<template #icon>
<SvgIcon name="check" :size="20" color="var(--color-secondary)" />
</template>
</CircleButton>
<CircleButton
v-if="request.status !== 'rejected'"
tooltip="رد درخواست"
bg-color="rgba(204, 40, 49, 0.08)"
size="2.5rem"
@click="emit('change-status', { id: request.id, status: 'rejected' })"
>
<template #icon>
<SvgIcon name="close" :size="20" color="var(--color-error)" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="missionary-request__details-btn"
@click="emit('show-details', request)"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const STATUS_VARIANTS = {
pending: 'warning',
seen: 'info',
accepted: 'success',
rejected: 'danger',
}
const props = defineProps({
request: { type: Object, required: true },
})
const emit = defineEmits(['show-details', 'change-status'])
const statusLabel = computed(() => MISSIONARY_REQUEST_STATUS[props.request.status] || '—')
const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'neutral')
</script>
<style lang="scss" scoped>
.missionary-request {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__heading {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1 1 33%;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
flex: 1 1 40%;
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
white-space: nowrap;
line-height: 1.5;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
color: #5d5d5d;
&--fa {
font-family: var(--font-family-fa);
}
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
}
&__details-btn {
min-width: 9rem;
padding: 0 0.625rem;
}
}
</style>
@@ -0,0 +1,123 @@
<template>
<BasicModal
title="جزئیات درخواست"
title-en="Request Details"
width="95%"
max-width="50rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="request-details">
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<template v-else-if="request">
<div class="request-details__status">
<Badge :variant="statusVariant" :dot="true" :value="statusLabel" />
</div>
<div class="request-details__grid">
<LineInfoBlock title="عنوان درخواست" :desc="request.title || '—'" />
<LineInfoBlock title="نام درخواست کننده" :desc="request.requesterName || '—'" />
<LineInfoBlock title="شماره تماس" :numeric-desc="request.requesterPhone || ''" />
<LineInfoBlock title="ایمیل" :numeric-desc="request.requesterEmail || ''" />
<LineInfoBlock title="مکان" :desc="request.location || '—'" />
<LineInfoBlock
title="تاریخ درخواست"
:numeric-desc="formatJalaaliDate(request.requestedDate) || '—'"
/>
<LineInfoBlock
title="تاریخ ثبت"
:numeric-desc="formatJalaaliDateTime(request.createdAt) || '—'"
/>
<LineInfoBlock title="منبع درخواست" :desc="request.externalSource || '—'" />
<div class="request-details__cell--full">
<LineInfoBlock title="توضیحات" :desc="request.description || '—'" />
</div>
</div>
</template>
<NoItems v-else title="یافت نشد" desc="اطلاعات این درخواست در دسترس نیست." />
<div class="request-details__actions">
<BaseButton text="بسیار خب" custom-class="request-details__btn" @click="close">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import useModal from '@/composables/useModal'
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { formatJalaaliDate, formatJalaaliDateTime } from '@/utils/date-utils'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useMissionaryRequestQuery } from '@/services/query/missionary-requests'
defineOptions({ name: 'MissionaryRequestDetailsModal' })
const STATUS_VARIANTS = {
pending: 'warning',
seen: 'info',
accepted: 'success',
rejected: 'danger',
}
const { getModal } = useModal()
const modalData = computed(() => getModal('MissionaryRequestDetailsModal')?.data ?? {})
const requestId = computed(() => modalData.value.id ?? null)
const { data: request, isLoading } = useMissionaryRequestQuery(requestId, {
enabled: () => !!requestId.value,
})
const statusLabel = computed(() => MISSIONARY_REQUEST_STATUS[request.value?.status] || '—')
const statusVariant = computed(() => STATUS_VARIANTS[request.value?.status] || 'neutral')
</script>
<style lang="scss" scoped>
.request-details {
width: 100%;
text-align: start;
&__status {
margin-bottom: 0.5rem;
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__cell--full {
grid-column: 1 / -1;
}
&__actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
&__btn {
min-width: 12rem;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
export { default as MissionaryRequestDetailsModal } from './components/modals/MissionaryRequestDetailsModal.vue'
@@ -0,0 +1,103 @@
<template>
<div class="missionary-requests-page">
<BoxedIconTitleBlock
class="missionary-requests-page__heading"
title="لیست درخواست ها"
desc="در این قسمت می‌توانید درخواست‌های اعزام خود را مدیریت کنید"
>
<template #icon>
<SvgIcon name="user-sound" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SimpleTitleIconBlock title="لیست همه درخواست‌ها" class="missionary-requests-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="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
@show-details="onShowDetails"
@change-status="onAskChangeStatus"
/>
</div>
<NoItems v-else title="متاسفیم" desc="درخواستی برای نمایش وجود ندارد." />
<MissionaryRequestDetailsModal v-if="isModal('MissionaryRequestDetailsModal')" />
</div>
</template>
<script setup>
import { computed } 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 BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import MissionaryRequestItem from '@/features/missionary/components/MissionaryRequestItem.vue'
import MissionaryRequestDetailsModal from '@/features/missionary/components/modals/MissionaryRequestDetailsModal.vue'
import {
missionaryRequestsKeys,
useChangeMissionaryRequestStatusMutation,
useMissionaryRequestsListQuery,
} from '@/services/query/missionary-requests'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const { data, isLoading } = useMissionaryRequestsListQuery()
const requests = computed(() => data.value ?? [])
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: missionaryRequestsKeys.all, refetchType: 'all' })
const statusMutation = useChangeMissionaryRequestStatusMutation()
const changeStatus = (id, status) =>
statusMutation.mutate({ id, payload: { status } }, { onSuccess: invalidate })
const onShowDetails = (request) => {
openModal('MissionaryRequestDetailsModal', { id: request.id })
if (request.status === 'pending') changeStatus(request.id, 'seen')
}
const STATUS_CONFIRMS = {
accepted: { title: 'پذیرش درخواست', verb: 'پذیرش' },
rejected: { title: 'رد درخواست', verb: 'رد' },
}
const onAskChangeStatus = ({ id, status }) => {
const config = STATUS_CONFIRMS[status]
if (!config) return
const request = requests.value.find((r) => r.id === id)
openModal('ConfirmModal', {
title: config.title,
message: `آیا از ${config.verb} درخواست <strong>${request?.title || ''}</strong> مطمئن هستید؟`,
onConfirm: () => changeStatus(id, status),
})
}
</script>
<style lang="scss" scoped>
.missionary-requests-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.5rem;
}
&__list-title {
margin-bottom: 0.375rem;
}
}
</style>
+24
View File
@@ -0,0 +1,24 @@
export default [
{
path: '/missionary-panel/requests',
name: 'missionary-requests',
component: () => import('@/features/missionary/pages/MissionaryRequestsPage.vue'),
meta: {
layout: 'missionary',
role: 'missionary',
title: 'سیستم مدیریت اعزام',
subtitle: 'ساده‌ترین راه برای برنامه‌ریزی و پایش مأموریت‌ها',
},
},
{
path: '/missionary-panel/tickets',
name: 'missionary-tickets',
component: () => import('@/features/student/tickets/pages/StudentTicketsPage.vue'),
meta: {
layout: 'missionary',
role: 'missionary',
title: 'تیکت‌های شما',
subtitle: 'اینجا پل ارتباطی شما با پشتیبانی است.',
},
},
]
+90 -15
View File
@@ -3,90 +3,165 @@ export default [
path: '/dashboard', path: '/dashboard',
name: 'student-dashboard', name: 'student-dashboard',
component: () => import('@/features/student/pages/StudentDashboardPage.vue'), component: () => import('@/features/student/pages/StudentDashboardPage.vue'),
meta: { layout: 'student', role: 'student', title: 'پیشخوان' }, meta: {
layout: 'student',
role: 'student',
title: 'سلام بر شما',
subtitle: 'به سامانه موسسه بانو خوش آمدید.',
},
}, },
{ {
path: '/profile', path: '/profile',
name: 'student-profile', name: 'student-profile',
component: () => import('@/features/student/profile/pages/EditProfilePage.vue'), component: () => import('@/features/student/profile/pages/EditProfilePage.vue'),
meta: { layout: 'student', role: 'student', title: 'ویرایش پروفایل' }, meta: {
layout: 'student',
role: 'student',
title: 'پروفایل شما',
subtitle: 'مشخصاتتان را ویرایش نمایید.',
},
}, },
{ {
path: '/my-courses', path: '/my-courses',
name: 'student-courses', name: 'student-courses',
component: () => import('@/features/student/courses/pages/StudentCoursesPage.vue'), component: () => import('@/features/student/courses/pages/StudentCoursesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'دوره‌های من' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-courses/:id', path: '/my-courses/:id',
name: 'student-course-details', name: 'student-course-details',
component: () => import('@/features/student/courses/pages/StudentCourseDetailsPage.vue'), component: () => import('@/features/student/courses/pages/StudentCourseDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-terms', path: '/my-terms',
name: 'student-terms', name: 'student-terms',
component: () => import('@/features/student/terms/pages/StudentTermsPage.vue'), component: () => import('@/features/student/terms/pages/StudentTermsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'ترم‌های آموزشی' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-terms/:id', path: '/my-terms/:id',
name: 'student-term-details', name: 'student-term-details',
component: () => import('@/features/student/terms/pages/StudentTermDetailsPage.vue'), component: () => import('@/features/student/terms/pages/StudentTermDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-lessons/:id', path: '/my-lessons/:id',
name: 'student-lesson-details', name: 'student-lesson-details',
component: () => import('@/features/student/lessons/pages/StudentLessonDetailsPage.vue'), component: () => import('@/features/student/lessons/pages/StudentLessonDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات درس' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-sessions/:id', path: '/my-sessions/:id',
name: 'student-session-details', name: 'student-session-details',
component: () => import('@/features/student/sessions/pages/StudentSessionDetailsPage.vue'), component: () => import('@/features/student/sessions/pages/StudentSessionDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات جلسه' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-exams/:id', path: '/my-exams/:id',
name: 'student-exam', name: 'student-exam',
component: () => import('@/features/student/exams/pages/StudentExamPage.vue'), component: () => import('@/features/student/exams/pages/StudentExamPage.vue'),
meta: { layout: 'student', role: 'student', title: 'آزمون' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/my-exams/:id/take', path: '/my-exams/:id/take',
name: 'student-take-exam', name: 'student-take-exam',
component: () => import('@/features/student/exams/pages/StudentTakeExamPage.vue'), component: () => import('@/features/student/exams/pages/StudentTakeExamPage.vue'),
meta: { layout: 'student', role: 'student', title: 'شروع آزمون' }, meta: {
layout: 'student',
role: 'student',
title: 'یادگیری شما',
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
}, },
{ {
path: '/missionary', path: '/missionary',
name: 'student-missionary', name: 'student-missionary',
component: () => import('@/features/student/missionary/pages/StudentMissionaryPage.vue'), component: () => import('@/features/student/missionary/pages/StudentMissionaryPage.vue'),
meta: { layout: 'student', role: 'student', title: 'سامانه اعزام' }, meta: {
layout: 'student',
role: 'student',
title: 'سیستم مدیریت اعزام',
subtitle: 'ساده‌ترین راه برای برنامه‌ریزی و پایش مأموریت‌ها',
},
}, },
{ {
path: '/user/services', path: '/user/services',
name: 'student-services', name: 'student-services',
component: () => import('@/features/student/services/pages/StudentServicesPage.vue'), component: () => import('@/features/student/services/pages/StudentServicesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'خدمات' }, meta: {
layout: 'student',
role: 'student',
title: 'درخواست خدمت',
subtitle: 'فقط یک مرحله تا شروع کار شما.',
},
}, },
{ {
path: '/user/consultants', path: '/user/consultants',
name: 'student-consultants', name: 'student-consultants',
component: () => import('@/features/student/consultants/pages/StudentConsultantsPage.vue'), component: () => import('@/features/student/consultants/pages/StudentConsultantsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'مشاوره' }, meta: {
layout: 'student',
role: 'student',
title: 'مشاوره سریع',
subtitle: 'چند ثانیه برای دریافت مشاوره کافیست.',
},
}, },
{ {
path: '/user/certificates', path: '/user/certificates',
name: 'student-certificates', name: 'student-certificates',
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'), component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' }, meta: {
layout: 'student',
role: 'student',
title: 'گواهینامه معتبر',
subtitle: 'نشان اعتماد و تخصص ما در هر حوزه',
},
}, },
{ {
path: '/my-tickets', path: '/my-tickets',
name: 'student-tickets', name: 'student-tickets',
component: () => import('@/features/student/tickets/pages/StudentTicketsPage.vue'), component: () => import('@/features/student/tickets/pages/StudentTicketsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'تیکت‌های من' }, meta: {
layout: 'student',
role: 'student',
title: 'تیکت‌های شما',
subtitle: 'اینجا پل ارتباطی شما با پشتیبانی است.',
},
}, },
] ]
@@ -14,7 +14,17 @@
</div> </div>
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
<div v-else-if="messages.length > 0" ref="thread" class="ticket-details__thread"> <div
v-else-if="messages.length > 0 || ticketMedia.length > 0"
ref="thread"
class="ticket-details__thread"
>
<div v-if="ticketMedia.length > 0" class="ticket-details__row ticket-details__row--self">
<div class="ticket-details__bubble">
<MessageAttachments :media="ticketMedia" />
<span class="ticket-details__time">{{ messageTime(ticket) }}</span>
</div>
</div>
<div <div
v-for="message in messages" v-for="message in messages"
:key="message.id" :key="message.id"
@@ -22,6 +32,7 @@
:class="`ticket-details__row--${senderClass(message)}`" :class="`ticket-details__row--${senderClass(message)}`"
> >
<div class="ticket-details__bubble"> <div class="ticket-details__bubble">
<MessageAttachments v-if="message.media?.length" :media="message.media" />
<p class="ticket-details__text">{{ message.message }}</p> <p class="ticket-details__text">{{ message.message }}</p>
<span class="ticket-details__time">{{ messageTime(message) }}</span> <span class="ticket-details__time">{{ messageTime(message) }}</span>
</div> </div>
@@ -60,6 +71,7 @@ import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import MessageAttachments from '@/components/blocks/MessageAttachments.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { import {
studentTicketsKeys, studentTicketsKeys,
@@ -81,6 +93,8 @@ const { data: ticket, isLoading } = useStudentTicketQuery(ticketId, {
const messages = computed(() => ticket.value?.messages ?? []) const messages = computed(() => ticket.value?.messages ?? [])
const ticketMedia = computed(() => ticket.value?.media ?? [])
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—') const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
// Student viewing their own ticket their own messages get the highlighted // Student viewing their own ticket their own messages get the highlighted
+6 -1
View File
@@ -6,7 +6,11 @@
<div class="admin-layout__content"> <div class="admin-layout__content">
<div class="admin-layout__inner"> <div class="admin-layout__inner">
<HeaderBlock :page-title="pageTitle" class="admin-layout__header" /> <HeaderBlock
:page-title="pageTitle"
:page-subtitle="pageSubtitle"
class="admin-layout__header"
/>
<div class="admin-layout__page"> <div class="admin-layout__page">
<RouterView /> <RouterView />
</div> </div>
@@ -53,6 +57,7 @@ onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize)) onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'پیشخوان') const pageTitle = computed(() => route.meta?.title || 'پیشخوان')
const pageSubtitle = computed(() => route.meta?.subtitle || '')
const educationRoutes = new Set([ const educationRoutes = new Set([
'admin-terms', 'admin-terms',
+6 -1
View File
@@ -6,7 +6,11 @@
<div class="counselor-layout__content"> <div class="counselor-layout__content">
<div class="counselor-layout__inner"> <div class="counselor-layout__inner">
<HeaderBlock :page-title="pageTitle" class="counselor-layout__header" /> <HeaderBlock
:page-title="pageTitle"
:page-subtitle="pageSubtitle"
class="counselor-layout__header"
/>
<div class="counselor-layout__page"> <div class="counselor-layout__page">
<RouterView /> <RouterView />
</div> </div>
@@ -53,6 +57,7 @@ onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize)) onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'مشاوره') const pageTitle = computed(() => route.meta?.title || 'مشاوره')
const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [ const menuItems = computed(() => [
{ {
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="missionary-layout">
<div class="missionary-layout__row">
<Transition name="fade">
<div
v-if="isMobile && ui.isSidebarOpen"
class="missionary-layout__backdrop"
@click="ui.setSidebarState(false)"
/>
</Transition>
<Transition :name="isMobile ? 'slide-drawer' : ''">
<aside
v-if="(isMobile && ui.isSidebarOpen) || !isMobile"
class="missionary-layout__sidebar"
:class="{ 'missionary-layout__sidebar--mobile': isMobile }"
>
<StudentSidebar :menu-items="menuItems" />
</aside>
</Transition>
<div class="missionary-layout__content">
<div class="missionary-layout__inner">
<HeaderBlock
:page-title="pageTitle"
:page-subtitle="pageSubtitle"
class="missionary-layout__header"
/>
<RouterView />
<div class="missionary-layout__footer">
<Copyright />
</div>
</div>
</div>
</div>
</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 StudentSidebar from '@/layouts/sidebars/StudentSidebar.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 pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [
{
title: 'لیست درخواست ها',
icon: 'user-sound',
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets',
},
])
</script>
<style lang="scss" scoped>
.missionary-layout {
min-height: 100dvh;
width: 100%;
background-color: #f5f5f5;
overflow: hidden;
&__row {
width: 100%;
height: 100%;
@media (min-width: 1024px) {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0.25rem;
}
}
&__sidebar {
position: fixed;
top: 0;
right: 0;
z-index: 30;
height: 100%;
min-height: 100dvh;
width: 70%;
@media (min-width: 1024px) {
position: static;
width: auto;
flex-basis: 16.6667%;
}
&--mobile {
width: 70%;
max-width: 20rem;
}
}
&__backdrop {
position: fixed;
inset: 0;
z-index: 20;
backdrop-filter: blur(4px);
}
&__content {
flex-basis: 83.3333%;
width: 100%;
@media (min-width: 1024px) {
height: 100%;
}
@media (min-width: 1280px) {
flex-basis: 93%;
}
}
&__inner {
display: flex;
flex-direction: column;
min-height: 100dvh;
padding: 1.25rem;
}
&__header {
margin-bottom: 1.25rem;
@media (min-width: 1024px) {
margin-bottom: 2.5rem;
}
}
&__footer {
margin-top: auto;
padding: 0.5rem 0 1.25rem;
}
}
</style>
+12 -2
View File
@@ -21,7 +21,11 @@
<div class="student-layout__content"> <div class="student-layout__content">
<div class="student-layout__inner"> <div class="student-layout__inner">
<HeaderBlock :page-title="pageTitle" class="student-layout__header" /> <HeaderBlock
:page-title="pageTitle"
:page-subtitle="pageSubtitle"
class="student-layout__header"
/>
<RouterView /> <RouterView />
<div class="student-layout__footer"> <div class="student-layout__footer">
<Copyright /> <Copyright />
@@ -51,8 +55,15 @@ onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => window.removeEventListener('resize', onResize)) onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'پیشخوان') const pageTitle = computed(() => route.meta?.title || 'پیشخوان')
const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [ const menuItems = computed(() => [
{
title: 'داشبورد',
icon: 'monitor',
to: { name: 'student-dashboard' },
active: route.name === 'student-dashboard',
},
{ {
title: 'آموزش', title: 'آموزش',
icon: 'graduation-cap', icon: 'graduation-cap',
@@ -65,7 +76,6 @@ const menuItems = computed(() => [
'student-exam', 'student-exam',
'student-courses', 'student-courses',
'student-course-details', 'student-course-details',
'student-dashboard',
].includes(route.name), ].includes(route.name),
}, },
{ {
+3 -1
View File
@@ -29,6 +29,7 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<SidebarLogout />
</aside> </aside>
</template> </template>
@@ -36,6 +37,7 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({ defineProps({
menuItems: { type: Array, default: () => [] }, menuItems: { type: Array, default: () => [] },
@@ -46,7 +48,7 @@ const logo = gallery.logoPinkishRed
<style lang="scss" scoped> <style lang="scss" scoped>
.admin-sidebar { .admin-sidebar {
height: 100%; height: 100vh;
width: 100%; width: 100%;
background: #fff; background: #fff;
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
@@ -18,12 +18,14 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<SidebarLogout />
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({ defineProps({
menuItems: { type: Array, default: () => [] }, menuItems: { type: Array, default: () => [] },
+90
View File
@@ -0,0 +1,90 @@
<template>
<button
type="button"
class="sidebar-logout"
:class="{ 'sidebar-logout--stacked': stacked }"
:style="{ color }"
@click="onLogout"
>
<SvgIcon name="power" :size="34" color="currentColor" />
<span class="sidebar-logout__labels">
<span class="sidebar-logout__title">خــروج</span>
<span class="sidebar-logout__subtitle">Logout</span>
</span>
</button>
</template>
<script setup>
import { useRouter } from 'vue-router'
import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useLogoutMutation } from '@/services/query/auth'
defineProps({
color: { type: String, default: '#9a9a9a' },
stacked: { type: Boolean, default: false },
})
const router = useRouter()
const { clearSession } = useAuth()
const logoutMutation = useLogoutMutation()
const onLogout = async () => {
try {
await logoutMutation.mutateAsync()
} catch {
/* ignore */
} finally {
clearSession()
router.push({ name: 'login' })
}
}
</script>
<style lang="scss" scoped>
.sidebar-logout {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: transparent;
border: none;
cursor: pointer;
opacity: 0.9;
transition: opacity 0.15s;
&:hover {
opacity: 1;
}
&__labels {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: start;
}
&--stacked {
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem 0.25rem;
}
&--stacked &__labels {
align-items: center;
text-align: center;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1rem;
font-weight: 500;
}
&__subtitle {
font-size: 0.9375rem;
opacity: 0.55;
}
}
</style>
+2
View File
@@ -18,12 +18,14 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<SidebarLogout color="#fff" />
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({ defineProps({
menuItems: { type: Array, default: () => [] }, menuItems: { type: Array, default: () => [] },
+17 -2
View File
@@ -1,3 +1,4 @@
import { ROLE_HOME_ROUTES } from '@/enums'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { tokenService } from '@/services/api/token-service' import { tokenService } from '@/services/api/token-service'
@@ -18,11 +19,25 @@ export const guestGuard = (to) => {
if (!to.meta?.requiresGuest) return if (!to.meta?.requiresGuest) return
if (tokenService.hasToken()) { if (tokenService.hasToken()) {
const auth = useAuthStore() const auth = useAuthStore()
return auth.role === 'admin' ? { name: 'admin-dashboard' } : { name: 'student-dashboard' } return { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' }
} }
return return
} }
// Students whose account is not yet `approved` (pending/verified) must finish
// the registration wizard before reaching any authenticated page.
export const statusGuard = (to) => {
if (isPublic(to)) return
if (to.meta?.requiresGuest) return
if (to.name === 'student-register') return
if (!tokenService.hasToken()) return
const auth = useAuthStore()
if (!auth.user) return
if (auth.role && auth.role !== 'student') return
if (auth.user.status === 'approved') return
return { name: 'student-register' }
}
export const roleGuard = (to) => { export const roleGuard = (to) => {
if (!to.meta?.role) return if (!to.meta?.role) return
const auth = useAuthStore() const auth = useAuthStore()
@@ -37,4 +52,4 @@ export const titleGuard = (to) => {
document.title = title ? `${APP_TITLE} - ${title}` : APP_TITLE document.title = title ? `${APP_TITLE} - ${title}` : APP_TITLE
} }
export const guards = [authGuard, guestGuard, roleGuard] export const guards = [authGuard, guestGuard, statusGuard, roleGuard]
+7 -7
View File
@@ -15,13 +15,13 @@ const router = createRouter({
}, },
}) })
// router.beforeEach((to, from) => { router.beforeEach((to, from) => {
// for (const guard of guards) { for (const guard of guards) {
// const result = guard(to, from) const result = guard(to, from)
// if (result !== undefined) return result if (result !== undefined) return result
// } }
// return return
// }) })
router.afterEach((to) => titleGuard(to)) router.afterEach((to) => titleGuard(to))
+4
View File
@@ -164,6 +164,10 @@ export const endpoints = {
showCounselorTicket: '/counselor/tickets/:id', showCounselorTicket: '/counselor/tickets/:id',
sendCounselorTicketMessage: '/counselor/tickets/:id/messages', sendCounselorTicketMessage: '/counselor/tickets/:id/messages',
changeCounselorTicketStatus: '/counselor/tickets/:id/status', changeCounselorTicketStatus: '/counselor/tickets/:id/status',
listMissionaryRequests: '/missionary/requests',
showMissionaryRequest: '/missionary/requests/:id',
changeMissionaryRequestStatus: '/missionary/requests/:id/status',
} }
export const buildUrl = (template, params = {}) => export const buildUrl = (template, params = {}) =>
+11
View File
@@ -0,0 +1,11 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiListMissionaryRequests = (params) =>
http.get(endpoints.listMissionaryRequests, { params })
export const apiShowMissionaryRequest = (id) =>
http.get(buildUrl(endpoints.showMissionaryRequest, { id }))
export const apiChangeMissionaryRequestStatus = (id, payload) =>
http.patch(buildUrl(endpoints.changeMissionaryRequestStatus, { id }), payload)
@@ -0,0 +1,62 @@
export const missionaryRequests = [
{
id: 1,
missionaryId: 8,
externalSource: 'wordpress',
externalReferenceId: 'wp-ticket-12341',
requesterName: 'احمد رضایی',
requesterPhone: '09123456789',
requesterEmail: 'ahmad@example.com',
title: 'درخواست جلسه هفتگی روضه خانگی',
description: 'به دنبال برگزاری یک جلسه ثابت هفتگی در منزل هستیم.',
location: 'تهران، ایران',
requestedDate: '2026-05-20',
status: 'pending',
createdAt: '2026-05-09T10:00:00+00:00',
},
{
id: 2,
missionaryId: 8,
externalSource: 'wordpress',
externalReferenceId: 'wp-ticket-12342',
requesterName: 'مریم اکبری',
requesterPhone: '09121112233',
requesterEmail: 'maryam@example.com',
title: 'مراسم مناسبتی دهه محرم',
description: 'برای دهه اول محرم نیاز به مبلغ برای جلسات خانگی داریم.',
location: 'قم، ایران',
requestedDate: '2026-06-25',
status: 'seen',
createdAt: '2026-05-11T08:30:00+00:00',
},
{
id: 3,
missionaryId: 8,
externalSource: 'wordpress',
externalReferenceId: 'wp-ticket-12343',
requesterName: 'فاطمه حسینی',
requesterPhone: '09354445566',
requesterEmail: 'fatemeh@example.com',
title: 'جلسه پرسش و پاسخ اعتقادی دانشگاه',
description: 'برگزاری نشست پرسش و پاسخ برای دانشجویان خوابگاه.',
location: 'اصفهان، ایران',
requestedDate: '2026-06-02',
status: 'accepted',
createdAt: '2026-05-12T14:15:00+00:00',
},
{
id: 4,
missionaryId: 8,
externalSource: 'wordpress',
externalReferenceId: 'wp-ticket-12344',
requesterName: 'حسین مولایی',
requesterPhone: '09107778899',
requesterEmail: 'hossein@example.com',
title: 'کلاس آموزش احکام مدرسه',
description: 'برگزاری کلاس هفتگی احکام برای دانش‌آموزان دوره اول.',
location: 'مشهد، ایران',
requestedDate: '2026-05-30',
status: 'rejected',
createdAt: '2026-05-13T09:45:00+00:00',
},
]
@@ -0,0 +1,22 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { findOrThrow, updateById } from '@/services/mock/helpers'
import { missionaryRequests } from '@/services/mock/fixtures/missionary-requests'
register('GET', endpoints.listMissionaryRequests, () => ({
success: true,
message: 'OK',
data: missionaryRequests,
}))
register('GET', endpoints.showMissionaryRequest, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(missionaryRequests, params.id),
}))
register('PATCH', endpoints.changeMissionaryRequestStatus, ({ params, data }) => ({
success: true,
message: 'Status updated.',
data: updateById(missionaryRequests, params.id, { status: data.status }),
}))
+15 -6
View File
@@ -70,16 +70,25 @@ export const useGetRegistrationVideoQuery = (options = {}) =>
export const useUploadMediaMutation = () => useMutation({ mutationFn: apiUploadMedia }) export const useUploadMediaMutation = () => useMutation({ mutationFn: apiUploadMedia })
// Register-data is a flat `{ field: value }` object; legacy responses were a
// `[{ key, value }]` list. Normalize both into the object shape.
const toRegisterDataObject = (response) => {
const body = response?.data ?? response
const raw = Array.isArray(body) ? body : body?.data ?? body
if (Array.isArray(raw)) {
return raw.reduce((acc, item) => {
if (item && typeof item === 'object' && 'key' in item) acc[item.key] = item.value
return acc
}, {})
}
return raw && typeof raw === 'object' ? raw : {}
}
export const useGetRegisterDataQuery = (options = {}) => export const useGetRegisterDataQuery = (options = {}) =>
useQuery({ useQuery({
queryKey: authKeys.registerData(), queryKey: authKeys.registerData(),
queryFn: () => apiGetRegisterData(), queryFn: () => apiGetRegisterData(),
select: (response) => { select: toRegisterDataObject,
const data = response?.data ?? response
if (Array.isArray(data)) return data
if (Array.isArray(data?.data)) return data.data
return []
},
...options, ...options,
}) })
+33
View File
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiChangeMissionaryRequestStatus,
apiListMissionaryRequests,
apiShowMissionaryRequest,
} from '@/services/api/missionary-requests'
export const missionaryRequestsKeys = {
all: ['missionary', 'requests'],
list: () => ['missionary', 'requests', 'list'],
detail: (id) => ['missionary', 'requests', 'detail', id],
}
export const useMissionaryRequestsListQuery = (options = {}) =>
useQuery({
queryKey: missionaryRequestsKeys.list(),
queryFn: () => apiListMissionaryRequests(),
select: (response) => response?.data ?? [],
...options,
})
export const useMissionaryRequestQuery = (idRef, options = {}) =>
useQuery({
queryKey: ['missionary', 'requests', 'detail', idRef],
queryFn: () => apiShowMissionaryRequest(idRef.value),
select: (response) => response?.data ?? response,
...options,
})
export const useChangeMissionaryRequestStatusMutation = () =>
useMutation({
mutationFn: ({ id, payload }) => apiChangeMissionaryRequestStatus(id, payload),
})
+6
View File
@@ -1,6 +1,9 @@
import VideoCover from '@/assets/images/cover.png' import VideoCover from '@/assets/images/cover.png'
import logoWhite from '@/assets/images/logo-white.png' import logoWhite from '@/assets/images/logo-white.png'
import courseIcon from '@/assets/images/course-icon.png'
import authLogo from '@/assets/images/auth/auth-logo.png' import authLogo from '@/assets/images/auth/auth-logo.png'
import sessionIcon from '@/assets/images/session-icon.png'
import assignmentIcon from '@/assets/images/assignment-icon.png'
import loginPattern from '@/assets/images/auth/login-pattern.jpg' import loginPattern from '@/assets/images/auth/login-pattern.jpg'
import logoPinkishRed from '@/assets/images/logo-pinkish-red.png' import logoPinkishRed from '@/assets/images/logo-pinkish-red.png'
import registrationCompleteFlower from '@/assets/images/auth/flower.png' import registrationCompleteFlower from '@/assets/images/auth/flower.png'
@@ -15,6 +18,9 @@ export const gallery = {
loginPatternWithLogo, loginPatternWithLogo,
logoWhite, logoWhite,
logoPinkishRed, logoPinkishRed,
courseIcon,
sessionIcon,
assignmentIcon,
VideoCover, VideoCover,
registrationCompleteFlower, registrationCompleteFlower,
studentSidebarPattern, studentSidebarPattern,