@@ -17,6 +17,7 @@ import StudentLayout from '@/layouts/StudentLayout.vue'
|
||||
import ConfirmModal from '@/components/ConfirmModal.vue'
|
||||
import CounselorLayout from '@/layouts/CounselorLayout.vue'
|
||||
import { tokenService } from '@/services/api/token-service'
|
||||
import MissionaryLayout from '@/layouts/MissionaryLayout.vue'
|
||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -26,6 +27,7 @@ const layouts = {
|
||||
admin: AdminLayout,
|
||||
student: StudentLayout,
|
||||
counselor: CounselorLayout,
|
||||
missionary: MissionaryLayout,
|
||||
}
|
||||
|
||||
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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)">
|
||||
<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 3.65625V6.5H9.34375" 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="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21191">
|
||||
|
||||
|
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 676 B |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -21,7 +21,7 @@ import { computed } from 'vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
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' },
|
||||
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
|
||||
size: { type: String, default: 'md' },
|
||||
@@ -138,5 +138,10 @@ const onClick = (event) => {
|
||||
background: rgba(104, 104, 104, 10%);
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: rgba(0, 72, 255, 8%);
|
||||
color: #0048ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
:size="16"
|
||||
:color="item.iconColor ?? item.color"
|
||||
:color="item.iconColor ?? item.color ?? 'currentColor'"
|
||||
class="dropdown-menu__icon"
|
||||
/>
|
||||
<span class="dropdown-menu__label">{{ item.label }}</span>
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
</div>
|
||||
<div class="header-block__title">
|
||||
<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>
|
||||
</div>
|
||||
<div class="header-block__icons">
|
||||
@@ -27,7 +30,10 @@
|
||||
</div>
|
||||
<div class="header-block__title-mobile">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -43,22 +49,20 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUIStore } from '@/store/ui'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useAuth from '@/composables/useAuth'
|
||||
import { computed, ref, useSlots } from 'vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import { useLogoutMutation } from '@/services/query/auth'
|
||||
|
||||
defineProps({
|
||||
pageTitle: { type: String, default: '' },
|
||||
pageSubtitle: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const hasSlotContent = computed(() => !!slots.default)
|
||||
|
||||
const ui = useUIStore()
|
||||
const router = useRouter()
|
||||
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 {
|
||||
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>
|
||||
@@ -126,7 +126,7 @@ const onActionClick = (tab) => {
|
||||
gap: 0.375rem;
|
||||
background: rgba(255, 255, 255, 80%);
|
||||
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
|
||||
padding: 0.875rem;
|
||||
padding: 0.775rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,8 @@ const onActionClick = (tab) => {
|
||||
&--desktop {
|
||||
display: none;
|
||||
min-width: fit-content;
|
||||
margin-block-end: 0.625rem;
|
||||
|
||||
// margin-block-end: 0.625rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: flex;
|
||||
@@ -162,7 +163,7 @@ const onActionClick = (tab) => {
|
||||
}
|
||||
|
||||
&__action-btn {
|
||||
padding: 0 1.25rem;
|
||||
height: 100%;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@click="toggle"
|
||||
>
|
||||
<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 v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }">
|
||||
@@ -377,6 +377,7 @@ export default {
|
||||
this.$emit('change', gregorian)
|
||||
this.close()
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error in handleConfirm:', error)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -325,8 +325,7 @@ onBeforeUnmount(cleanup)
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.85rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,16 @@ export type IconName =
|
||||
| 'map-pin-simple-area'
|
||||
| 'menu'
|
||||
| 'microphone'
|
||||
| 'monitor'
|
||||
| 'mood'
|
||||
| 'newspaper'
|
||||
| 'paper-plane'
|
||||
| 'paper-plane-right'
|
||||
| 'pencil'
|
||||
| 'phone'
|
||||
| 'play'
|
||||
| 'plus'
|
||||
| 'power'
|
||||
| 'scroll'
|
||||
| 'shopping-bag'
|
||||
| 'smiley-sad'
|
||||
|
||||
@@ -17,6 +17,7 @@ export const fields = {
|
||||
postalCode: 'کد پستی',
|
||||
|
||||
birthDate: 'تاریخ تولد',
|
||||
birthday: 'تاریخ تولد',
|
||||
gender: 'جنسیت',
|
||||
maritalStatus: 'وضعیت تأهل',
|
||||
|
||||
|
||||
@@ -206,3 +206,20 @@ export const SERVICE_TYPE = Object.freeze({
|
||||
educational: 'آموزشی',
|
||||
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">
|
||||
<TextField v-model="form.title" name="title" label="عنوان تکلیف">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
@@ -54,7 +54,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<SelectField
|
||||
v-model="form.priority"
|
||||
name="priority"
|
||||
label="اولویت"
|
||||
label="اولویت تکلیف"
|
||||
:options="priorityOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
desc="در این قسمت میتوانید تکالیف خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="check-square" :size="24" color="var(--color-primary)" />
|
||||
<img :src="assignmentIcon" alt="" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</SimpleTitleIconBlock>
|
||||
<BaseButton text="افزودن تکلیف جدید" custom-class="assignments-page__add-btn" @click="onAdd">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="18" color="#fff" />
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
@@ -75,6 +76,8 @@ import {
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const assignmentIcon = gallery.assignmentIcon
|
||||
|
||||
const filters = ref({
|
||||
title: '',
|
||||
termId: '',
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="courses-filters__grid">
|
||||
<TextField v-model="form.title" name="title" label="عنوان دوره">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
|
||||
@@ -14,16 +14,6 @@
|
||||
|
||||
<form class="course-form__form" @submit.prevent="onSubmit">
|
||||
<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__row">
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
@@ -100,6 +90,16 @@
|
||||
</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 class="course-form__divider" />
|
||||
@@ -309,29 +309,23 @@ const onCancel = () => router.push({ name: 'admin-courses' })
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: 4fr 8fr;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: 3fr 9fr;
|
||||
grid-template-columns: 9fr 3fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-col {
|
||||
order: 2;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__main-col {
|
||||
order: 1;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 2;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
desc="در این قسمت میتوانید دورههای خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
|
||||
<img :src="courseIcon" alt="" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
@@ -37,10 +37,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||
@@ -64,6 +64,8 @@ const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const courseIcon = gallery.courseIcon
|
||||
|
||||
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
|
||||
|
||||
const onAdd = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="exams-filters__grid">
|
||||
<TextField v-model="form.title" name="title" label="عنوان آزمون">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
label="عنوان آزمون"
|
||||
:error="errors.title"
|
||||
@blur="validateAt('title', form.title)"
|
||||
/>
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<TextField
|
||||
v-model="form.minimumScore"
|
||||
name="minimumScore"
|
||||
@@ -39,7 +43,11 @@
|
||||
:convert-digits="true"
|
||||
:error="errors.minimumScore"
|
||||
@blur="validateAt('minimumScore', form.minimumScore)"
|
||||
/>
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="newspaper" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<TextField
|
||||
v-model="form.score"
|
||||
name="score"
|
||||
@@ -48,7 +56,11 @@
|
||||
:convert-digits="true"
|
||||
:error="errors.score"
|
||||
@blur="validateAt('score', form.score)"
|
||||
/>
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="newspaper" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<TextField
|
||||
v-model="form.durationMinutes"
|
||||
name="durationMinutes"
|
||||
@@ -57,14 +69,15 @@
|
||||
:convert-digits="true"
|
||||
:error="errors.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">
|
||||
<ToggleSwitch v-model="form.isRandom" label="به صــــورت رندوم باشد." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-form__card">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
desc="در این قسمت میتوانید آزمونهای خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
|
||||
<img :src="sessionIcon" alt="" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</SimpleTitleIconBlock>
|
||||
<BaseButton text="افزودن آزمون جدید" custom-class="exams-page__add-btn" @click="onAdd">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="18" color="#fff" />
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -50,6 +50,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
@@ -75,6 +76,8 @@ const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const sessionIcon = gallery.sessionIcon
|
||||
|
||||
const filters = ref({
|
||||
title: '',
|
||||
termId: '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<form class="tickets-filters" @submit.prevent="onSubmit">
|
||||
<div class="tickets-filters__grid">
|
||||
<TextField v-model="form.userName" name="userName" label="نام کاربر">
|
||||
<TextField v-model="form.userName" name="userName" label="جستجو اسم کاربر">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -14,7 +14,17 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
@@ -22,6 +32,7 @@
|
||||
:class="`ticket-details__row--${senderClass(message)}`"
|
||||
>
|
||||
<div class="ticket-details__bubble">
|
||||
<MessageAttachments v-if="message.media?.length" :media="message.media" />
|
||||
<p class="ticket-details__text">{{ message.message }}</p>
|
||||
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
||||
</div>
|
||||
@@ -31,6 +42,25 @@
|
||||
|
||||
<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">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -57,6 +87,23 @@
|
||||
>
|
||||
<SvgIcon name="mood" :size="30" color="currentColor" />
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -81,13 +128,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from 'vue3-toastify'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import MessageAttachments from '@/components/blocks/MessageAttachments.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
autoUpdate,
|
||||
@@ -143,6 +194,8 @@ const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
|
||||
|
||||
const messages = computed(() => ticket.value?.messages ?? [])
|
||||
|
||||
const ticketMedia = computed(() => ticket.value?.media ?? [])
|
||||
|
||||
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
||||
|
||||
// Anyone whose id matches the ticket's student is "the student"; everyone else
|
||||
@@ -157,7 +210,49 @@ const messageTime = (message) => {
|
||||
}
|
||||
|
||||
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 emojiButton = ref(null)
|
||||
@@ -213,10 +308,15 @@ const sendMutation = useSendAdminTicketMessageMutation()
|
||||
const onSend = async () => {
|
||||
if (!canSend.value || !ticketId.value) return
|
||||
const value = text.value.trim()
|
||||
const mediaIds = attachments.value.map((f) => f.id).filter((id) => id != null)
|
||||
text.value = ''
|
||||
attachments.value = []
|
||||
emojiOpen.value = false
|
||||
// Backend POST /admin/tickets/:id/messages — body is { message: string }.
|
||||
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
|
||||
// Backend POST /admin/tickets/:id/messages — body is { message: string,
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -405,6 +505,12 @@ watch(messages, async () => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
&__attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,138 +3,253 @@ export default [
|
||||
path: '/main-dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/features/admin/pages/AdminDashboardPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'پیشخوان' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'سلام بر شما',
|
||||
subtitle: 'به سامانه موسسه بانو خوش آمدید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'admin-users',
|
||||
component: () => import('@/features/admin/users/pages/UsersListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت کاربران' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'لیست کاربران',
|
||||
subtitle: 'کنترل کامل روی اطلاعات، نقشها و وضعیت کاربران',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/add-user',
|
||||
name: 'admin-add-user',
|
||||
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',
|
||||
name: 'admin-edit-user',
|
||||
component: () => import('@/features/admin/users/pages/UserFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش کاربر' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'لیست کاربران',
|
||||
subtitle: 'کنترل کامل روی اطلاعات، نقشها و وضعیت کاربران',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/user-verification',
|
||||
name: 'admin-user-verification',
|
||||
component: () => import('@/features/admin/users/pages/UserVerificationPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'تایید هویت کاربران' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'کاربران سیستم من',
|
||||
subtitle: 'دسترسی کامل به اطلاعات، نقشها و وضعیت کاربران',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/terms',
|
||||
name: 'admin-terms',
|
||||
component: () => import('@/features/admin/terms/pages/TermsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت ترم' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/add-term',
|
||||
name: 'admin-add-term',
|
||||
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',
|
||||
name: 'admin-edit-term',
|
||||
component: () => import('@/features/admin/terms/pages/TermFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش ترم' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/courses',
|
||||
name: 'admin-courses',
|
||||
component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/add-course',
|
||||
name: 'admin-add-course',
|
||||
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',
|
||||
name: 'admin-edit-course',
|
||||
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',
|
||||
name: 'admin-term-courses',
|
||||
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',
|
||||
name: 'admin-course-sessions',
|
||||
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'جلسات دوره' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/sessions',
|
||||
name: 'admin-sessions',
|
||||
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت جلسات' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/add-session',
|
||||
name: 'admin-add-session',
|
||||
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',
|
||||
name: 'admin-edit-session',
|
||||
component: () => import('@/features/admin/sessions/pages/SessionFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش جلسه' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/exams',
|
||||
name: 'admin-exams',
|
||||
component: () => import('@/features/admin/exams/pages/ExamsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت آزمونها' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/add-exam',
|
||||
name: 'admin-add-exam',
|
||||
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',
|
||||
name: 'admin-edit-exam',
|
||||
component: () => import('@/features/admin/exams/pages/ExamFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش آزمون' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/assignments',
|
||||
name: 'admin-assignments',
|
||||
component: () => import('@/features/admin/assignments/pages/AssignmentsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت تکالیف' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز آموزش',
|
||||
subtitle: 'دسترسی سریع به دورهها، آزمونها و نتایج آموزشی.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/messages',
|
||||
name: 'admin-messages',
|
||||
component: () => import('@/features/admin/messages/pages/TicketsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت پیامها' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز پیامها',
|
||||
subtitle: 'تمام ارتباطات سیستمی و کاربری را در این بخش مدیریت نمایید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/services',
|
||||
name: 'admin-services',
|
||||
component: () => import('@/features/admin/services/pages/ServicesListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت خدمات' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
role: 'admin',
|
||||
title: 'مرکز خدمات',
|
||||
subtitle: 'لیست خدمات را مشاهده و مدیریت نمایید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/consultations',
|
||||
name: 'admin-consultations',
|
||||
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"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sessions-filters__grid">
|
||||
<TextField v-model="form.title" name="title" label="عنوان جلسه">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
@@ -38,7 +38,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -12,16 +12,6 @@
|
||||
|
||||
<form class="session-form__form" @submit.prevent="onSubmit">
|
||||
<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">
|
||||
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
||||
|
||||
@@ -112,6 +102,16 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-form__image-col">
|
||||
<ImageCropper
|
||||
v-model="image"
|
||||
name="image"
|
||||
bg-color="#eeeeee"
|
||||
@crop="onImageCropped"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-form__divider" />
|
||||
@@ -392,32 +392,27 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: 4fr 8fr;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: 3fr 9fr;
|
||||
grid-template-columns: 9fr 3fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-col {
|
||||
order: 2;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__main-col {
|
||||
order: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 2;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
desc="در این قسمت میتوانید جلسات خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
|
||||
<img :src="sessionIcon" alt="" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</SimpleTitleIconBlock>
|
||||
<BaseButton text="افزودن جلسه جدید" custom-class="sessions-page__add-btn" @click="onAdd">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="18" color="#fff" />
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="terms-filters__grid">
|
||||
<TextField v-model="form.title" name="title" label="عنوان">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
|
||||
@@ -14,16 +14,6 @@
|
||||
|
||||
<form class="term-form__form" @submit.prevent="onSubmit">
|
||||
<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">
|
||||
<LineTitleBlock title="اطلاعات ترم" title-en="Term Details" />
|
||||
<div class="term-form__row">
|
||||
@@ -91,6 +81,16 @@
|
||||
</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 class="term-form__divider" />
|
||||
@@ -249,29 +249,23 @@ const onCancel = () => router.push({ name: 'admin-terms' })
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: 4fr 8fr;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: 3fr 9fr;
|
||||
grid-template-columns: 9fr 3fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-col {
|
||||
order: 2;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__main-col {
|
||||
order: 1;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 2;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +306,7 @@ const onCancel = () => router.push({ name: 'admin-terms' })
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -14,21 +14,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
|
||||
{{ USER_STATUS.blocked }}
|
||||
</Badge>
|
||||
<div class="users-table__middle">
|
||||
<div class="users-table__date">
|
||||
<span class="users-table__date-label">تاریخ ثبت نام:</span>
|
||||
<span class="users-table__date-value">
|
||||
{{ formatJalaaliDateTime(user.createdAt) || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="users-table__role-badge"
|
||||
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`"
|
||||
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
|
||||
@click="onRoleClick($event, user)"
|
||||
>
|
||||
<span class="users-table__role-dot" />
|
||||
<span>{{ roleLabel(user) || 'بدون نقش' }}</span>
|
||||
<SvgIcon name="caret-down" :size="12" color="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="users-table__role-badge"
|
||||
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`"
|
||||
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
|
||||
@click="onRoleClick($event, user)"
|
||||
>
|
||||
<span class="users-table__role-dot" />
|
||||
<span>{{ roleLabel(user) || 'بدون نقش' }}</span>
|
||||
<SvgIcon name="caret-down" :size="12" color="currentColor" />
|
||||
</button>
|
||||
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
|
||||
{{ USER_STATUS.blocked }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="users-table__actions">
|
||||
<button
|
||||
@@ -77,6 +85,7 @@ import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import { formatJalaaliDateTime } from '@/utils/date-utils'
|
||||
import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
|
||||
|
||||
defineProps({
|
||||
@@ -219,10 +228,38 @@ const menuItems = computed(() => {
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
flex: 1 1 33%;
|
||||
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 {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
|
||||
@@ -13,31 +13,28 @@
|
||||
|
||||
<template v-else-if="user">
|
||||
<div class="user-details__section">
|
||||
<div v-if="isStudent && !user.profileCompleted" class="user-details__notice">
|
||||
<SvgIcon name="warning" :size="20" color="var(--color-primary)" />
|
||||
<span>اطلاعات ثبت نام این دانشجو کامل نیست.</span>
|
||||
</div>
|
||||
|
||||
<div class="user-details__head">
|
||||
<div class="user-details__avatar">
|
||||
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="fullName" />
|
||||
<SvgIcon v-else name="user" :size="48" color="#cbd5e1" />
|
||||
</div>
|
||||
<div class="user-details__identity">
|
||||
<div class="user-details__status" :class="statusModifier">
|
||||
<span class="user-details__status-dot" />
|
||||
<span>{{ user.status === 'blocked' ? 'غیر فعال' : 'فعال' }}</span>
|
||||
<div class="user-details__row user-details__row--main">
|
||||
<div class="user-details__head">
|
||||
<div class="user-details__avatar">
|
||||
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="fullName" />
|
||||
<SvgIcon v-else name="user" :size="48" color="#cbd5e1" />
|
||||
</div>
|
||||
<div class="user-details__identity">
|
||||
<div class="user-details__status" :class="statusModifier">
|
||||
<span class="user-details__status-dot" />
|
||||
<span>{{ user.status === 'blocked' ? 'غیر فعال' : 'فعال' }}</span>
|
||||
</div>
|
||||
<p class="user-details__label">نام و نام خانوادگی</p>
|
||||
<p class="user-details__name">{{ fullName || '—' }}</p>
|
||||
<Badge
|
||||
class="user-details__role"
|
||||
:variant="roleVariant"
|
||||
label="نقش:"
|
||||
:value="roleText"
|
||||
dot
|
||||
/>
|
||||
</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 class="user-details__grid">
|
||||
<LineInfoBlock
|
||||
title="تاریخ تولد"
|
||||
:numeric-desc="formatJalaaliDate(user.birthday) || '—'"
|
||||
@@ -49,11 +46,14 @@
|
||||
:desc="MARITAL_STATUS[user.marriageStatus] || '—'"
|
||||
/>
|
||||
<LineInfoBlock title="جنسیت" :desc="GENDER[user.gender] || '—'" />
|
||||
<LineInfoBlock title="استان" :desc="user?.province?.name || '—'" />
|
||||
<LineInfoBlock title="شهر" :desc="user?.city?.name || '—'" />
|
||||
</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 || '—'" />
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
@@ -137,6 +138,16 @@ const roleText = computed(() => {
|
||||
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 statusModifier = computed(() =>
|
||||
@@ -172,7 +183,43 @@ const statusModifier = computed(() =>
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -245,23 +292,7 @@ const statusModifier = computed(() =>
|
||||
}
|
||||
|
||||
&__role {
|
||||
display: inline-flex;
|
||||
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;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
|
||||
@@ -21,25 +21,25 @@
|
||||
<div class="verification-item__date">
|
||||
<span class="verification-item__date-label">تاریخ ثبت نام:</span>
|
||||
<span class="verification-item__date-value">
|
||||
{{ formatJalaaliDate(user.createdAt) || '—' }}
|
||||
{{ formatJalaaliDateTime(user.createdAt) || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="user.status === 'verified'"
|
||||
class="verification-item__badge verification-item__badge--pending"
|
||||
>
|
||||
<span class="verification-item__dot" />
|
||||
<span>در انتظار تایید</span>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="user.status === 'rejected'"
|
||||
type="button"
|
||||
class="verification-item__badge verification-item__badge--rejected"
|
||||
<Badge
|
||||
v-if="user.status === 'rejected'"
|
||||
variant="danger"
|
||||
:dot="true"
|
||||
value="رد شده"
|
||||
:clickable="true"
|
||||
@click="emit('show-reject-reason', user)"
|
||||
>
|
||||
<span class="verification-item__dot" />
|
||||
<span>رد شده</span>
|
||||
</button>
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="statusLabel"
|
||||
:variant="statusVariant"
|
||||
:dot="true"
|
||||
:value="statusLabel"
|
||||
:clickable="user.status === 'blocked'"
|
||||
@click="emit('show-reject-reason', user)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="verification-item__actions">
|
||||
@@ -54,11 +54,11 @@
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
v-if="user.status !== 'rejected'"
|
||||
v-if="user.status !== 'blocked'"
|
||||
tooltip="عدم تایید"
|
||||
bg-color="rgba(204, 40, 49, 0.08)"
|
||||
size="2.5rem"
|
||||
@click="emit('verify-user', { id: user.id, status: 'rejected' })"
|
||||
@click="emit('verify-user', { id: user.id, status: 'blocked' })"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" color="var(--color-error)" />
|
||||
@@ -80,10 +80,12 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { USER_STATUS } from '@/enums'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import { formatJalaaliDateTime } from '@/utils/date-utils'
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true },
|
||||
@@ -92,7 +94,16 @@ const props = defineProps({
|
||||
|
||||
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 statusLabel = computed(() => USER_STATUS[props.user.status] || '')
|
||||
const statusVariant = computed(() => STATUS_VARIANTS[props.user.status] || 'neutral')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -202,37 +213,6 @@ const fullName = computed(() => `${props.user.name || ''}`.trim() || '—')
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="reject-reason-details">
|
||||
<div class="reject-reason-details__grid">
|
||||
<div class="reject-reason-details__cell--full">
|
||||
<LineInfoBlock title="توضیحات" :desc="verification(data).adminNote || ''" />
|
||||
<LineInfoBlock title="توضیحات" :desc="data?.user?.blockReason || '—'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
|
||||
defineOptions({ name: 'RejectReasonDetailsModal' })
|
||||
|
||||
const verification = (data) => data?.verification || {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -9,31 +9,13 @@
|
||||
>
|
||||
<template #default="{ 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
|
||||
v-model="form.adminNote"
|
||||
name="adminNote"
|
||||
v-model="form.reason"
|
||||
name="reason"
|
||||
label="لطفا علت رد را توضیح دهید"
|
||||
:row="4"
|
||||
:error="errors.adminNote"
|
||||
:error="errors.reason"
|
||||
@blur="validateAt('reason', form.reason)"
|
||||
/>
|
||||
|
||||
<div class="reject-reason-modal__divider" />
|
||||
@@ -68,14 +50,11 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import useYup from '@/composables/useYup'
|
||||
import { PRIORITY_STATUS } from '@/enums'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import { rejectionReasonSchema } from '../../../schema'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import { adminUsersKeys, useChangeAdminUserStatusMutation } from '@/services/query/admin-users'
|
||||
|
||||
@@ -84,22 +63,12 @@ defineOptions({ name: 'RejectReasonModal' })
|
||||
const queryClient = useQueryClient()
|
||||
const changeStatusMutation = useChangeAdminUserStatusMutation()
|
||||
|
||||
const DEFAULT_FORM = { rejectionReason: '', adminNote: '' }
|
||||
const DEFAULT_FORM = { reason: '' }
|
||||
|
||||
const { validate, validateAt, errors, resetErrors } = useYup(rejectionReasonSchema)
|
||||
|
||||
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 { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
@@ -107,7 +76,7 @@ const onSubmit = async (data, close) => {
|
||||
if (!id) return
|
||||
await changeStatusMutation.mutateAsync({
|
||||
id,
|
||||
payload: { status: 'blocked', ...payload },
|
||||
payload: { status: 'blocked', reason: payload.reason },
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: adminUsersKeys.all })
|
||||
form.value = structuredClone({ ...DEFAULT_FORM })
|
||||
@@ -122,11 +91,6 @@ const onSubmit = async (data, close) => {
|
||||
text-align: start;
|
||||
padding: 0.5rem;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-block-end: 1px solid var(--color-thd-gray);
|
||||
margin-block: 1rem;
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
/>
|
||||
<LineInfoBlock title="شماره تماس" :numeric-desc="personal.phone" />
|
||||
<LineInfoBlock title="کد ملی" :numeric-desc="personal.nationalCode" />
|
||||
<LineInfoBlock
|
||||
title="وضعیت تاهل"
|
||||
:desc="MARITAL_STATUS[personal.maritalStatus] || ''"
|
||||
/>
|
||||
<LineInfoBlock title="وضعیت" :desc="MARITAL_STATUS[personal.maritalStatus] || ''" />
|
||||
<LineInfoBlock title="جنسیت" :desc="GENDER[personal.gender] || ''" />
|
||||
<LineInfoBlock title="استان" :desc="personal.province || '-'" />
|
||||
<LineInfoBlock title="شهر" :desc="personal.city || '-'" />
|
||||
|
||||
@@ -14,16 +14,6 @@
|
||||
|
||||
<form class="user-form__form" @submit.prevent="onSubmit">
|
||||
<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">
|
||||
<LineTitleBlock title="مشخصات فردی" title-en="Personal Details" />
|
||||
<div class="user-form__row">
|
||||
@@ -200,6 +190,16 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-form__avatar-col">
|
||||
<ImageCropper
|
||||
v-model="avatar"
|
||||
name="avatar"
|
||||
bg-color="#eeeeee"
|
||||
@crop="onAvatarCropped"
|
||||
@error="onAvatarError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -285,7 +285,7 @@ const avatar = ref(null)
|
||||
|
||||
const schema = isEditMode.value ? userEditSchema : userAddSchema
|
||||
|
||||
const { validate, validateAt, errors } = useYup(schema)
|
||||
const { validate, validateAt, errors } = useYup(schema, () => form.value)
|
||||
|
||||
const onProvinceChange = () => {
|
||||
provinceIdRef.value = form.value.provinceId
|
||||
@@ -336,7 +336,7 @@ const uploadMutation = useUploadMediaMutation()
|
||||
|
||||
const onAvatarCropped = async (file) => {
|
||||
try {
|
||||
const formData = objectToFormData({ file, subType: 'avatar', context: 'user' })
|
||||
const formData = objectToFormData({ file, purpose: 'avatar', context: 'user' })
|
||||
const response = await uploadMutation.mutateAsync(formData)
|
||||
const payload = response?.data ?? response
|
||||
avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
|
||||
@@ -411,29 +411,23 @@ const onCancel = () => router.push({ name: 'admin-users' })
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: 4fr 8fr;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: 3fr 9fr;
|
||||
grid-template-columns: 9fr 3fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar-col {
|
||||
order: 2;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__main-col {
|
||||
order: 1;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 2;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ const onFilterReset = () => resetPagination()
|
||||
const changeStatusMutation = useChangeAdminUserStatusMutation()
|
||||
|
||||
const onVerifyUser = ({ id, status }) => {
|
||||
if (status === 'rejected') {
|
||||
if (status === 'blocked') {
|
||||
openModal('RejectReasonModal', { id })
|
||||
return
|
||||
}
|
||||
@@ -106,7 +106,7 @@ const onShowDetails = (id) => {
|
||||
}
|
||||
|
||||
const onShowRejectReason = (user) => {
|
||||
openModal('RejectReasonDetailsModal', { verification: user.verification })
|
||||
openModal('RejectReasonDetailsModal', { user })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</SimpleTitleIconBlock>
|
||||
<BaseButton text="افزودن کاربر جدید" custom-class="users-page__add-btn" @click="onAdd">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="18" color="#fff" />
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,7 @@ import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
|
||||
import { passwordConfirmationRule } from '@/constants/rules/passwordConfirmationRule'
|
||||
|
||||
export const rejectionReasonSchema = object().shape({
|
||||
rejectionReason: string().required(),
|
||||
priority: object()
|
||||
.shape({
|
||||
id: string().required().default(null),
|
||||
})
|
||||
.required()
|
||||
.default(null),
|
||||
adminNote: string().notRequired(),
|
||||
reason: string().required(),
|
||||
})
|
||||
|
||||
const baseUserShape = {
|
||||
|
||||
@@ -41,19 +41,19 @@
|
||||
<SvgIcon name="arrow-left" :size="22" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="login-form__links">
|
||||
<div class="login-form__link">
|
||||
<SvgIcon name="user" :size="12" color="#A7A7A7" />
|
||||
<span>حساب کاربری ندارم</span>
|
||||
<RouterLink :to="{ name: 'signup' }">ثبت نام</RouterLink>
|
||||
</div>
|
||||
<div class="login-form__link">
|
||||
<SvgIcon name="check" :size="12" color="#A7A7A7" />
|
||||
<button type="button" class="login-form__link-btn" @click="goLoginWithCode">
|
||||
ورود با کد تایید
|
||||
</button>
|
||||
</div>
|
||||
<div class="login-form__links">
|
||||
<div class="login-form__link">
|
||||
<SvgIcon name="user" :size="12" color="#A7A7A7" />
|
||||
<span>حساب کاربری ندارم</span>
|
||||
<RouterLink :to="{ name: 'signup' }">ثبت نام</RouterLink>
|
||||
</div>
|
||||
<div class="login-form__link">
|
||||
<SvgIcon name="check" :size="12" color="#A7A7A7" />
|
||||
<button type="button" class="login-form__link-btn" @click="goLoginWithCode">
|
||||
ورود با کد تایید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,26 +110,30 @@ const goForgotPassword = () => emit('next', { mode: 'forgotPassword' })
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,16 +166,12 @@ const goForgotPassword = () => emit('next', { mode: 'forgotPassword' })
|
||||
}
|
||||
|
||||
.login-form__links {
|
||||
margin-top: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form__link {
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
<SvgIcon name="arrow-left" :size="22" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="login-otp__links">
|
||||
<button type="button" class="login-otp__back" @click="emit('back')">
|
||||
بازگشت به ورود با رمز عبور
|
||||
</button>
|
||||
</div>
|
||||
<div class="login-otp__links">
|
||||
<button type="button" class="login-otp__back" @click="emit('back')">
|
||||
بازگشت به ورود با رمز عبور
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-form__footer">
|
||||
@@ -98,26 +98,30 @@ const onSubmit = async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,14 +131,10 @@ const onSubmit = async () => {
|
||||
}
|
||||
|
||||
.login-otp__links {
|
||||
margin-top: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.login-otp__back {
|
||||
|
||||
@@ -67,12 +67,12 @@
|
||||
<SvgIcon name="arrow-left" :size="22" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="signup-form__login-link">
|
||||
<SvgIcon name="user" :size="12" color="#A7A7A7" />
|
||||
<span>حساب کاربری دارم</span>
|
||||
<RouterLink :to="{ name: 'login' }">ورود</RouterLink>
|
||||
</div>
|
||||
<div class="signup-form__login-link">
|
||||
<SvgIcon name="user" :size="12" color="#A7A7A7" />
|
||||
<span>حساب کاربری دارم</span>
|
||||
<RouterLink :to="{ name: 'login' }">ورود</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="auth-form__footer">
|
||||
@@ -131,26 +131,30 @@ const onSubmit = async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +164,7 @@ const onSubmit = async () => {
|
||||
}
|
||||
|
||||
.signup-form__login-link {
|
||||
margin-top: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
@@ -168,10 +172,6 @@ const onSubmit = async () => {
|
||||
font-size: 0.75rem;
|
||||
color: #a7a7a7;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-sec-gray);
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -28,20 +28,20 @@
|
||||
<SvgIcon name="arrow-left" :size="22" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="verify__links">
|
||||
<div class="verify__link">
|
||||
<SvgIcon name="user" :size="12" color="#A7A7A7" />
|
||||
<button type="button" class="verify__link-btn" @click="emit('password-login')">
|
||||
ورود با رمز عبور
|
||||
</button>
|
||||
</div>
|
||||
<div class="verify__link">
|
||||
<SvgIcon name="phone" :size="12" color="#A7A7A7" />
|
||||
<button type="button" class="verify__link-btn" @click="emit('back')">
|
||||
تغییر شماره تلفن همراه
|
||||
</button>
|
||||
</div>
|
||||
<div class="verify__links">
|
||||
<div class="verify__link">
|
||||
<SvgIcon name="user" :size="12" color="#A7A7A7" />
|
||||
<button type="button" class="verify__link-btn" @click="emit('password-login')">
|
||||
ورود با رمز عبور
|
||||
</button>
|
||||
</div>
|
||||
<div class="verify__link">
|
||||
<SvgIcon name="phone" :size="12" color="#A7A7A7" />
|
||||
<button type="button" class="verify__link-btn" @click="emit('back')">
|
||||
تغییر شماره تلفن همراه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,6 @@ const onSubmit = async () => {
|
||||
const response = await verifySignupMutation.mutateAsync({ phone: props.phoneNumber, code })
|
||||
applySession(response?.data ?? response)
|
||||
emit('verified', { phone: props.phoneNumber, code })
|
||||
redirectAfterLogin()
|
||||
}
|
||||
|
||||
const onResend = async () => {
|
||||
@@ -154,26 +153,30 @@ const onResend = async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,16 +204,12 @@ const onResend = async () => {
|
||||
}
|
||||
|
||||
.verify__links {
|
||||
margin-top: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.verify__link {
|
||||
|
||||
@@ -111,11 +111,8 @@ import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import { useSaveRegisterDataMutation } from '@/services/query/auth'
|
||||
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
||||
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 {
|
||||
toKeyValueList,
|
||||
useStudentRegistrationStore,
|
||||
} from '@/features/auth/store/student-registration'
|
||||
|
||||
const store = useStudentRegistrationStore()
|
||||
|
||||
@@ -148,7 +145,7 @@ const saveRegisterMutation = useSaveRegisterDataMutation()
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(payload))
|
||||
await saveRegisterMutation.mutateAsync(payload)
|
||||
store.educational = { ...store.educational, ...payload }
|
||||
store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
|
||||
store.goToNextStep()
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
</div>
|
||||
<div class="personal-info__cell">
|
||||
<DatePickerField
|
||||
v-model="form.birthDate"
|
||||
name="birthDate"
|
||||
v-model="form.birthday"
|
||||
name="birthday"
|
||||
label="تاریخ تولد"
|
||||
:max="maxBirthDate"
|
||||
:error="errors.birthDate"
|
||||
:error="errors.birthday"
|
||||
/>
|
||||
</div>
|
||||
<div class="personal-info__cell">
|
||||
@@ -198,21 +198,25 @@ const maxBirthDate = computed(() => {
|
||||
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
|
||||
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({
|
||||
name: user.value?.name || store.personal.name || '',
|
||||
birthDate: store.personal.birthDate || '',
|
||||
birthday: user.value?.birthday || store.personal.birthday || '',
|
||||
nationalCode: initialNationalCode.value,
|
||||
maritalStatus: store.personal.maritalStatus || '',
|
||||
gender: store.personal.gender || '',
|
||||
maritalStatus: user.value?.marriageStatus || store.personal.maritalStatus || '',
|
||||
gender: user.value?.gender || store.personal.gender || '',
|
||||
phone: initialPhone.value,
|
||||
provinceId: store.personal.provinceId || null,
|
||||
cityId: store.personal.cityId || null,
|
||||
address: store.personal.address || '',
|
||||
avatar: store.personal.avatar || null,
|
||||
provinceId: user.value?.provinceId || store.personal.provinceId || null,
|
||||
cityId: user.value?.cityId || store.personal.cityId || null,
|
||||
address:
|
||||
(typeof user.value?.address === 'string' && user.value.address) || store.personal.address || '',
|
||||
avatar: initialAvatar,
|
||||
avatarId: store.personal.avatarId || '',
|
||||
})
|
||||
|
||||
const avatar = ref(store.personal.avatar || null)
|
||||
const avatar = ref(initialAvatar)
|
||||
|
||||
const { validate, validateAt, errors } = useYup(personalInformationSchema)
|
||||
|
||||
@@ -263,7 +267,7 @@ const onSubmit = async () => {
|
||||
// (http auto-converts camelCase → snake_case: birthday, national_code, …)
|
||||
const body = {
|
||||
name: payload.name,
|
||||
birthday: payload.birthDate,
|
||||
birthday: payload.birthday,
|
||||
nationalCode: payload.nationalCode,
|
||||
marriageStatus: payload.maritalStatus,
|
||||
gender: payload.gender,
|
||||
|
||||
@@ -117,10 +117,7 @@ import { useSaveRegisterDataMutation } from '@/services/query/auth'
|
||||
import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums'
|
||||
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
||||
import { professionalInformationSchema } from '@/features/auth/schema/student-register'
|
||||
import {
|
||||
toKeyValueList,
|
||||
useStudentRegistrationStore,
|
||||
} from '@/features/auth/store/student-registration'
|
||||
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
||||
|
||||
const store = useStudentRegistrationStore()
|
||||
|
||||
@@ -193,7 +190,7 @@ const onSubmit = async () => {
|
||||
specializedTopics: payload.specializedTopics,
|
||||
propagationPlatforms,
|
||||
}
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(next))
|
||||
await saveRegisterMutation.mutateAsync(next)
|
||||
store.professional = next
|
||||
store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
|
||||
store.goToNextStep()
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<SvgIcon :name="tab.icon" :size="22" color="#fff" />
|
||||
</li>
|
||||
</ul>
|
||||
<SidebarLogout color="#fff" stacked />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -25,6 +26,7 @@
|
||||
<script setup>
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
|
||||
|
||||
defineProps({
|
||||
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 { skillAssessmentPart6Schema } from '@/features/auth/schema/student-register'
|
||||
import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue'
|
||||
import {
|
||||
toKeyValueList,
|
||||
useStudentRegistrationStore,
|
||||
} from '@/features/auth/store/student-registration'
|
||||
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
||||
|
||||
const store = useStudentRegistrationStore()
|
||||
|
||||
@@ -70,7 +67,7 @@ const saveRegisterMutation = useSaveRegisterDataMutation()
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(payload))
|
||||
await saveRegisterMutation.mutateAsync(payload)
|
||||
store.skill6 = { ...store.skill6, ...payload }
|
||||
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
|
||||
store.goToNextStep()
|
||||
|
||||
@@ -46,10 +46,7 @@ import { useSaveRegisterDataMutation } from '@/services/query/auth'
|
||||
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
||||
import { skillAssessmentPart7Schema } from '@/features/auth/schema/student-register'
|
||||
import MediaRecorder from '@/features/auth/components/studentRegister/MediaRecorder.vue'
|
||||
import {
|
||||
toKeyValueList,
|
||||
useStudentRegistrationStore,
|
||||
} from '@/features/auth/store/student-registration'
|
||||
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
||||
|
||||
const store = useStudentRegistrationStore()
|
||||
|
||||
@@ -75,7 +72,7 @@ const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
try {
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(payload))
|
||||
await saveRegisterMutation.mutateAsync(payload)
|
||||
store.skill7 = { ...store.skill7, ...payload }
|
||||
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
|
||||
store.isProfileCompleted = true
|
||||
|
||||
@@ -46,10 +46,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import { useSaveRegisterDataMutation } from '@/services/query/auth'
|
||||
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
|
||||
import {
|
||||
toKeyValueList,
|
||||
useStudentRegistrationStore,
|
||||
} from '@/features/auth/store/student-registration'
|
||||
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
|
||||
|
||||
const props = defineProps({
|
||||
step: { type: Number, required: true },
|
||||
@@ -89,7 +86,7 @@ const saveRegisterMutation = useSaveRegisterDataMutation()
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
await saveRegisterMutation.mutateAsync(toKeyValueList(payload))
|
||||
await saveRegisterMutation.mutateAsync(payload)
|
||||
store[props.storeKey] = { ...store[props.storeKey], ...payload }
|
||||
store.markStepAsCompleted(props.step)
|
||||
store.goToNextStep()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ROLE_HOME_ROUTES } from '@/enums'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -12,8 +13,7 @@ export const useRedirectAfterLogin = () => {
|
||||
router.push(redirect).catch(() => router.push('/'))
|
||||
return
|
||||
}
|
||||
const target =
|
||||
auth.role === 'admin' ? { name: 'admin-dashboard' } : { name: 'student-dashboard' }
|
||||
const target = { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' }
|
||||
router.push(target).catch(() => router.push('/'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ const { isModal, openModal } = useModal()
|
||||
const { data: registerData } = useGetRegisterDataQuery()
|
||||
watch(
|
||||
registerData,
|
||||
(list) => {
|
||||
if (Array.isArray(list) && list.length > 0) store.hydrateFromKeyValueList(list)
|
||||
(value) => {
|
||||
if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ export const personalInformationSchema = object().shape({
|
||||
avatar: mixed().notRequired(),
|
||||
avatarId: mixed().notRequired(),
|
||||
name: requiredString(3),
|
||||
birthDate: string().required(),
|
||||
birthday: string().required(),
|
||||
nationalCode: nationalCodeRule,
|
||||
maritalStatus: string().required(),
|
||||
gender: string().required(),
|
||||
|
||||
@@ -10,7 +10,7 @@ const emptyState = () => ({
|
||||
avatar: null,
|
||||
avatarId: '',
|
||||
name: '',
|
||||
birthDate: '',
|
||||
birthday: '',
|
||||
nationalCode: '',
|
||||
maritalStatus: '',
|
||||
gender: '',
|
||||
@@ -75,7 +75,7 @@ const SECTION_FIELDS = {
|
||||
'avatar',
|
||||
'avatarId',
|
||||
'name',
|
||||
'birthDate',
|
||||
'birthday',
|
||||
'nationalCode',
|
||||
'maritalStatus',
|
||||
'gender',
|
||||
@@ -115,15 +115,16 @@ const FIELD_TO_SECTION = Object.entries(SECTION_FIELDS).reduce((acc, [section, f
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export const toKeyValueList = (obj = {}) =>
|
||||
Object.entries(obj).map(([key, value]) => ({ key, value }))
|
||||
|
||||
export const fromKeyValueList = (list = []) => {
|
||||
if (!Array.isArray(list)) return {}
|
||||
return list.reduce((acc, item) => {
|
||||
if (item && typeof item === 'object' && 'key' in item) acc[item.key] = item.value
|
||||
return acc
|
||||
}, {})
|
||||
// Register-data travels as a flat `{ field: value }` object. Legacy payloads
|
||||
// were a `[{ key, value }]` list — tolerate both when reading.
|
||||
export const toRegisterDataObject = (input) => {
|
||||
if (Array.isArray(input)) {
|
||||
return input.reduce((acc, item) => {
|
||||
if (item && typeof item === 'object' && 'key' in item) acc[item.key] = item.value
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
return input && typeof input === 'object' ? input : {}
|
||||
}
|
||||
|
||||
const readPersisted = () => {
|
||||
@@ -241,10 +242,11 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
|
||||
: personal.value.avatarId,
|
||||
})
|
||||
|
||||
// Take the flat `{key,value}` list from GET /me/register-data and slot each
|
||||
// value into the right section ref, leaving anything we don't recognise alone.
|
||||
const hydrateFromKeyValueList = (list) => {
|
||||
const flat = fromKeyValueList(list)
|
||||
// Take the flat `{ field: value }` map from GET /me/register-data and slot
|
||||
// each value into the right section ref, leaving anything we don't
|
||||
// recognise alone.
|
||||
const hydrateFromRegisterData = (input) => {
|
||||
const flat = toRegisterDataObject(input)
|
||||
const buckets = {}
|
||||
Object.entries(flat).forEach(([key, value]) => {
|
||||
const section = FIELD_TO_SECTION[key]
|
||||
@@ -301,7 +303,7 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
|
||||
goToPrevStep,
|
||||
canGoToStep,
|
||||
buildCompletePayload,
|
||||
hydrateFromKeyValueList,
|
||||
hydrateFromRegisterData,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,11 @@ export default [
|
||||
path: '/counselor/consultations',
|
||||
name: 'counselor-consultations',
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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: 'اینجا پل ارتباطی شما با پشتیبانی است.',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -3,90 +3,165 @@ export default [
|
||||
path: '/dashboard',
|
||||
name: 'student-dashboard',
|
||||
component: () => import('@/features/student/pages/StudentDashboardPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'پیشخوان' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'سلام بر شما',
|
||||
subtitle: 'به سامانه موسسه بانو خوش آمدید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'student-profile',
|
||||
component: () => import('@/features/student/profile/pages/EditProfilePage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'ویرایش پروفایل' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'پروفایل شما',
|
||||
subtitle: 'مشخصاتتان را ویرایش نمایید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/my-courses',
|
||||
name: 'student-courses',
|
||||
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',
|
||||
name: 'student-course-details',
|
||||
component: () => import('@/features/student/courses/pages/StudentCourseDetailsPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'یادگیری شما',
|
||||
subtitle: 'مهارتها و دورههای خود را دنبال کنید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/my-terms',
|
||||
name: 'student-terms',
|
||||
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',
|
||||
name: 'student-term-details',
|
||||
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',
|
||||
name: 'student-lesson-details',
|
||||
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',
|
||||
name: 'student-session-details',
|
||||
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',
|
||||
name: 'student-exam',
|
||||
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',
|
||||
name: 'student-take-exam',
|
||||
component: () => import('@/features/student/exams/pages/StudentTakeExamPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'شروع آزمون' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'یادگیری شما',
|
||||
subtitle: 'مهارتها و دورههای خود را دنبال کنید.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/missionary',
|
||||
name: 'student-missionary',
|
||||
component: () => import('@/features/student/missionary/pages/StudentMissionaryPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'سامانه اعزام' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'سیستم مدیریت اعزام',
|
||||
subtitle: 'سادهترین راه برای برنامهریزی و پایش مأموریتها',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/user/services',
|
||||
name: 'student-services',
|
||||
component: () => import('@/features/student/services/pages/StudentServicesPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'خدمات' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'درخواست خدمت',
|
||||
subtitle: 'فقط یک مرحله تا شروع کار شما.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/user/consultants',
|
||||
name: 'student-consultants',
|
||||
component: () => import('@/features/student/consultants/pages/StudentConsultantsPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'مشاوره' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'مشاوره سریع',
|
||||
subtitle: 'چند ثانیه برای دریافت مشاوره کافیست.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/user/certificates',
|
||||
name: 'student-certificates',
|
||||
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
|
||||
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' },
|
||||
meta: {
|
||||
layout: 'student',
|
||||
role: 'student',
|
||||
title: 'گواهینامه معتبر',
|
||||
subtitle: 'نشان اعتماد و تخصص ما در هر حوزه',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/my-tickets',
|
||||
name: 'student-tickets',
|
||||
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>
|
||||
|
||||
<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
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
@@ -22,6 +32,7 @@
|
||||
:class="`ticket-details__row--${senderClass(message)}`"
|
||||
>
|
||||
<div class="ticket-details__bubble">
|
||||
<MessageAttachments v-if="message.media?.length" :media="message.media" />
|
||||
<p class="ticket-details__text">{{ message.message }}</p>
|
||||
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
||||
</div>
|
||||
@@ -60,6 +71,7 @@ import BasicModal from '@/components/BasicModal.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import MessageAttachments from '@/components/blocks/MessageAttachments.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
studentTicketsKeys,
|
||||
@@ -81,6 +93,8 @@ const { data: ticket, isLoading } = useStudentTicketQuery(ticketId, {
|
||||
|
||||
const messages = computed(() => ticket.value?.messages ?? [])
|
||||
|
||||
const ticketMedia = computed(() => ticket.value?.media ?? [])
|
||||
|
||||
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
||||
|
||||
// Student viewing their own ticket — their own messages get the highlighted
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
<div class="admin-layout__content">
|
||||
<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">
|
||||
<RouterView />
|
||||
</div>
|
||||
@@ -53,6 +57,7 @@ onMounted(() => window.addEventListener('resize', onResize))
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
const pageTitle = computed(() => route.meta?.title || 'پیشخوان')
|
||||
const pageSubtitle = computed(() => route.meta?.subtitle || '')
|
||||
|
||||
const educationRoutes = new Set([
|
||||
'admin-terms',
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
<div class="counselor-layout__content">
|
||||
<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">
|
||||
<RouterView />
|
||||
</div>
|
||||
@@ -53,6 +57,7 @@ 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(() => [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -21,7 +21,11 @@
|
||||
|
||||
<div class="student-layout__content">
|
||||
<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 />
|
||||
<div class="student-layout__footer">
|
||||
<Copyright />
|
||||
@@ -51,8 +55,15 @@ 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: 'monitor',
|
||||
to: { name: 'student-dashboard' },
|
||||
active: route.name === 'student-dashboard',
|
||||
},
|
||||
{
|
||||
title: 'آموزش',
|
||||
icon: 'graduation-cap',
|
||||
@@ -65,7 +76,6 @@ const menuItems = computed(() => [
|
||||
'student-exam',
|
||||
'student-courses',
|
||||
'student-course-details',
|
||||
'student-dashboard',
|
||||
].includes(route.name),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<SidebarLogout />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -36,6 +37,7 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
|
||||
|
||||
defineProps({
|
||||
menuItems: { type: Array, default: () => [] },
|
||||
@@ -46,7 +48,7 @@ const logo = gallery.logoPinkishRed
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-sidebar {
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
padding: 1.5rem 1rem;
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<SidebarLogout />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
|
||||
|
||||
defineProps({
|
||||
menuItems: { type: Array, default: () => [] },
|
||||
|
||||
@@ -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>
|
||||
@@ -18,12 +18,14 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<SidebarLogout color="#fff" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
|
||||
|
||||
defineProps({
|
||||
menuItems: { type: Array, default: () => [] },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ROLE_HOME_ROUTES } from '@/enums'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { tokenService } from '@/services/api/token-service'
|
||||
|
||||
@@ -18,11 +19,25 @@ export const guestGuard = (to) => {
|
||||
if (!to.meta?.requiresGuest) return
|
||||
if (tokenService.hasToken()) {
|
||||
const auth = useAuthStore()
|
||||
return auth.role === 'admin' ? { name: 'admin-dashboard' } : { name: 'student-dashboard' }
|
||||
return { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' }
|
||||
}
|
||||
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) => {
|
||||
if (!to.meta?.role) return
|
||||
const auth = useAuthStore()
|
||||
@@ -37,4 +52,4 @@ export const titleGuard = (to) => {
|
||||
document.title = title ? `${APP_TITLE} - ${title}` : APP_TITLE
|
||||
}
|
||||
|
||||
export const guards = [authGuard, guestGuard, roleGuard]
|
||||
export const guards = [authGuard, guestGuard, statusGuard, roleGuard]
|
||||
|
||||
@@ -15,13 +15,13 @@ const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
// router.beforeEach((to, from) => {
|
||||
// for (const guard of guards) {
|
||||
// const result = guard(to, from)
|
||||
// if (result !== undefined) return result
|
||||
// }
|
||||
// return
|
||||
// })
|
||||
router.beforeEach((to, from) => {
|
||||
for (const guard of guards) {
|
||||
const result = guard(to, from)
|
||||
if (result !== undefined) return result
|
||||
}
|
||||
return
|
||||
})
|
||||
|
||||
router.afterEach((to) => titleGuard(to))
|
||||
|
||||
|
||||
@@ -164,6 +164,10 @@ export const endpoints = {
|
||||
showCounselorTicket: '/counselor/tickets/:id',
|
||||
sendCounselorTicketMessage: '/counselor/tickets/:id/messages',
|
||||
changeCounselorTicketStatus: '/counselor/tickets/:id/status',
|
||||
|
||||
listMissionaryRequests: '/missionary/requests',
|
||||
showMissionaryRequest: '/missionary/requests/:id',
|
||||
changeMissionaryRequestStatus: '/missionary/requests/:id/status',
|
||||
}
|
||||
|
||||
export const buildUrl = (template, params = {}) =>
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -70,16 +70,25 @@ export const useGetRegistrationVideoQuery = (options = {}) =>
|
||||
|
||||
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 = {}) =>
|
||||
useQuery({
|
||||
queryKey: authKeys.registerData(),
|
||||
queryFn: () => apiGetRegisterData(),
|
||||
select: (response) => {
|
||||
const data = response?.data ?? response
|
||||
if (Array.isArray(data)) return data
|
||||
if (Array.isArray(data?.data)) return data.data
|
||||
return []
|
||||
},
|
||||
select: toRegisterDataObject,
|
||||
...options,
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import VideoCover from '@/assets/images/cover.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 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 logoPinkishRed from '@/assets/images/logo-pinkish-red.png'
|
||||
import registrationCompleteFlower from '@/assets/images/auth/flower.png'
|
||||
@@ -15,6 +18,9 @@ export const gallery = {
|
||||
loginPatternWithLogo,
|
||||
logoWhite,
|
||||
logoPinkishRed,
|
||||
courseIcon,
|
||||
sessionIcon,
|
||||
assignmentIcon,
|
||||
VideoCover,
|
||||
registrationCompleteFlower,
|
||||
studentSidebarPattern,
|
||||
|
||||