12 Commits

Author SHA1 Message Date
madresebanoo fe8ce68b15 feat(admin): add certificates page to admin sidebar and router
- AdminLayout: add گواهینامه‌ها menu item
- admin router: register /certificates route → AdminCertificatesPage
- endpoints: add getAdminCertificates, addAdminCertificate, deleteAdminCertificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:37:41 +04:30
madresebanoo f5620a86f9 feat(certificates): admin certificate management UI
Admin can view, issue, and delete certificates. Student certificate
query now normalises the response shape (works with both paginated
and non-paginated API responses).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:20 +04:30
madresebanoo 72d92c24d5 feat(admin/users): support multiple roles in user table and form
UsersTable now shows all role labels joined (not just the first role),
and its CSS badge class is based on the primary role. UserFormPage
uses a multi-select for roles instead of a single-select, and the
form validation requires at least one role. TermItem layout fix for
inactive badge alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:04 +04:30
madresebanoo 97adb2923e fix: branding, 401 logout, and blocked-user message on register page
- Copyright.vue / AuthCopyright.vue: change link from mugweb.ir to madresebanoo.ir
- SelectField.vue: add min-width:0 to prevent flex overflow in narrow layouts
- useRedirectAfterLogin: check roles array (multi-role support) instead of single role
- StudentRegisterPage: show block reason when user's status is blocked
- http.js: only clear session on 401 if a token already existed (login
  form 401 should not trigger logout); clear via authStore so WordPress
  cookie is also cleaned up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:15:46 +04:30
madresebanoo c17dbe2ae9 fix(courses): normalize "my courses" list response and cover image
The list query returned the raw paginated payload unmapped, so
current/ended filtering and pagination metadata didn't work; align it
with the same coverUrl fallback the detail query already used.
2026-09-02 15:04:31 +04:30
madresebanoo fb629a54a5 feat(users): add user evaluation panel with PDF export
Shows the admin a summary of a user's sessions viewed, exam/homework
performance, and current term status, with a PDF export action.
2026-09-02 15:03:55 +04:30
madresebanoo 2b3e8ee322 feat(services): add Service Types management UI
Admin CRUD page for service types, plus wiring the student "request
service" form's category select to the real list instead of a
hardcoded pair of options.
2026-09-02 15:03:15 +04:30
madresebanoo a8b24ca46e fix(courses): course cover image not saved on create/edit
The admin course form sent a stray upload id (imageId/uploadId) that
didn't match any field the API accepts; the API expects cover_media_id
with the media id returned from the upload endpoint. Also switch course
displays to the coverUrl field the API actually returns.
2026-09-02 15:02:02 +04:30
madresebanoo 0bd7c44c6c Merge branch 'master' of https://git.madresebanoo.ir/front-end/banu-front
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-09-02 14:59:50 +04:30
madresebanoo 9064de4c7d fix: support onUploadProgress in useUploadMediaMutation
SessionFormPage now passes { formData, onUploadProgress } to mutateAsync
for progress tracking. Update uploadMediaMutationFn to destructure the
object and forward onUploadProgress to apiUploadMedia; update apiUploadMedia
to accept and pass the callback to axios.

[batch-1-hotfix]
2026-09-02 14:19:51 +04:30
madresebanoo ab99daeec1 Merge pull request 'feat(batch-1): multi-role sidebar, video display, certificate layout, upload progress, counselor access' (#1) from deploy/batch-1-low-risk into master
Reviewed-on: #1
2026-09-02 08:06:21 +00:00
madresebanoo d63ab86e70 feat(batch-1): multi-role sidebar, video display, certificate layout, upload progress, counselor access
- roleMenuItems.js: merged menu builder that unions items from all user roles
  (student + missionary, student + counselor, etc.) additively
- auth store: expose `roles` array in addition to singular `role`; wire in
  WordPress auth-status cookie sync so the public site can show/hide the
  account button
- router guards: use roles.includes() instead of role === for multi-role access
- layouts (Student/Counselor/Missionary): delegate menuItems to
  buildMergedMenuItems so dual-role users see sections from both roles
- StudentSessionDetailsPage: contentKind now returns 'video' (not 'link')
  when session.videoUrl is set; adds embed iframe branch for online sessions
- CertificateItem.vue: fix CSS order/alignment for desktop + mobile layout
- StudentCertificatesPage.vue: map backend payload fields to component props
  with Jalaali date formatting
- SessionFormPage: upload progress bar (%) during video uploads; embed_code
  textarea for online sessions (frontend-only; dormant until batch-2 backend
  migration is deployed)
- wp-sync/auth-status-cookie.js: cookie helper for WordPress login-state sync

[batch-1-low-risk]
2026-09-02 11:17:20 +04:30
52 changed files with 1990 additions and 190 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="copyright">
<div class="copyright__text">
<a href="https://mugweb.ir/" target="_blank" rel="noopener">
<a href="https://madresebanoo.ir/" target="_blank" rel="noopener">
<p class="copyright__en">All Rights Reserved. 2026</p>
<p class="copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست.</p>
</a>
+1
View File
@@ -283,6 +283,7 @@ onBeforeUnmount(() => unmountFloating())
&__value {
flex: 1;
min-width: 0;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
@@ -0,0 +1,137 @@
<template>
<div class="certificate-item">
<div class="certificate-item__main">
<p class="certificate-item__title">{{ certificate.title || '—' }}</p>
<p class="certificate-item__student">{{ certificate.user?.name || '—' }}</p>
</div>
<div class="certificate-item__meta">
<div class="certificate-item__pill">
<span class="certificate-item__pill-label">تاریخ صدور:</span>
<span class="certificate-item__pill-value">{{ issuedAt }}</span>
</div>
<div class="certificate-item__pill">
<span class="certificate-item__pill-label">فایل‌ها:</span>
<span class="certificate-item__pill-value">{{ filesLabel }}</span>
</div>
</div>
<div class="certificate-item__actions">
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('delete', certificate)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
certificate: { type: Object, required: true },
})
const emit = defineEmits(['delete'])
const issuedAt = computed(() => formatJalaaliDate(props.certificate.issuedAt) || '—')
const filesLabel = computed(() => {
const parts = []
if (props.certificate.hasImage) parts.push('تصویر')
if (props.certificate.hasPdf) parts.push('PDF')
return parts.length > 0 ? parts.join(' / ') : '—'
})
</script>
<style lang="scss" scoped>
.certificate-item {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 50%);
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-flow: row wrap;
align-items: center;
}
&__main {
flex: 1 1 33%;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__student {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #848484;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
flex: 1 1 33%;
}
&__pill {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
flex: 1 1 100%;
@media (min-width: 1024px) {
flex: 1 1 auto;
}
}
}
</style>
@@ -0,0 +1,292 @@
<template>
<BasicModal
title="صدور گواهینامه"
title-en="Issue Certificate"
width="95%"
max-width="56rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="issue-certificate" @submit.prevent="onSubmit(close)">
<div class="issue-certificate__grid">
<SelectField
v-model="form.userId"
name="userId"
label="طلبه"
placeholder="نام طلبه را جستجو کنید"
:options="userOptions"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchUsers"
:error="errors.userId"
/>
<SelectField
v-model="form.termId"
name="termId"
label="ترم گذرانده‌شده"
placeholder="ترم را انتخاب کنید"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
:error="errors.termId"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره گذرانده‌شده"
placeholder="دوره را انتخاب کنید"
:options="courseOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchCourses"
/>
<DatePickerField v-model="form.issuedAt" name="issuedAt" label="تاریخ صدور" />
<DatePickerField v-model="form.startedAt" name="startedAt" label="تاریخ شروع" />
<DatePickerField v-model="form.endedAt" name="endedAt" label="تاریخ پایان" />
</div>
<p v-if="errors.termId" class="issue-certificate__hint">{{ errors.termId }}</p>
<div class="issue-certificate__files">
<div class="issue-certificate__file-col">
<label class="issue-certificate__file-label">تصویر گواهینامه (JPG/PNG)</label>
<FileUploader
v-model="imageFiles"
accept="image/*"
:multiple="false"
:max-files="1"
@select="onImageSelect"
@error="onFileError"
/>
</div>
<div class="issue-certificate__file-col">
<label class="issue-certificate__file-label">فایل PDF گواهینامه</label>
<FileUploader
v-model="pdfFiles"
accept="application/pdf"
:multiple="false"
:max-files="1"
@select="onPdfSelect"
@error="onFileError"
/>
</div>
</div>
<p v-if="errors.imageMediaId" class="issue-certificate__hint">{{ errors.imageMediaId }}</p>
<div class="issue-certificate__divider" />
<div class="issue-certificate__actions">
<BaseButton
variant="transparent"
text="منصرف شدم"
custom-class="issue-certificate__btn-cancel"
@click="close"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
</template>
</BaseButton>
<BaseButton
type="submit"
text="صدور گواهینامه"
:loading="submitting"
custom-class="issue-certificate__btn-submit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { certificateSchema } from '@/features/admin/certificates/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import {
adminCertificatesKeys,
useAddAdminCertificateMutation,
} from '@/services/query/admin-certificates'
defineOptions({ name: 'IssueCertificateModal' })
const queryClient = useQueryClient()
const form = ref({
userId: '',
termId: '',
courseId: '',
issuedAt: '',
startedAt: '',
endedAt: '',
})
const { validate, errors, resetErrors } = useYup(certificateSchema)
const userSearch = ref('')
const userFilters = computed(() => ({ search: userSearch.value, roles: 'student' }))
const userPagination = ref({ page: 1, perPage: 10 })
const { data: usersResponse } = useAdminUsersListQuery(userFilters, userPagination)
const userOptions = computed(() => usersResponse.value?.data ?? [])
const searchUsers = useDebounce((q) => {
userSearch.value = q || ''
}, 400)
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const courseSearch = ref('')
const courseFilters = computed(() => ({ title: courseSearch.value }))
const coursePagination = ref({ page: 1, perPage: 10 })
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
const courseOptions = computed(() => coursesResponse.value?.data ?? [])
const searchCourses = useDebounce((q) => {
courseSearch.value = q || ''
}, 400)
const imageFiles = ref([])
const pdfFiles = ref([])
const uploadMediaMutation = useUploadMediaMutation()
const onImageSelect = async (files) => {
const file = files[0]
if (!file) return
try {
const fd = objectToFormData({ file, purpose: 'certificate_image' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
imageFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
} catch {
toast.error('بارگذاری تصویر گواهینامه با خطا مواجه شد.')
}
}
const onPdfSelect = async (files) => {
const file = files[0]
if (!file) return
try {
const fd = objectToFormData({ file, purpose: 'certificate_pdf' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
pdfFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
} catch {
toast.error('بارگذاری فایل PDF گواهینامه با خطا مواجه شد.')
}
}
const onFileError = (msg) => toast.error(msg)
const addMutation = useAddAdminCertificateMutation()
const submitting = computed(() => addMutation.isPending.value)
const onSubmit = async (close) => {
const payload = {
...form.value,
imageMediaId: imageFiles.value[0]?.id ?? null,
pdfMediaId: pdfFiles.value[0]?.id ?? null,
}
const { isValid } = await validate(payload)
if (!isValid) return
await addMutation.mutateAsync(payload)
await queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
resetErrors()
close()
}
</script>
<style lang="scss" scoped>
.issue-certificate {
width: 100%;
text-align: start;
padding: 0.5rem 0;
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
margin-block: 0.75rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1280px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__hint {
color: var(--color-error);
font-family: var(--font-family-fa);
font-size: 0.75rem;
margin: -0.25rem 0 0.5rem;
}
&__files {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 0.5rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__file-label {
display: block;
font-family: var(--font-family-fa);
font-size: 0.8125rem;
color: #4b5563;
margin-bottom: 0.375rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1.5rem;
}
&__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 12rem;
}
}
</style>
@@ -0,0 +1,129 @@
<template>
<div class="certificates-page">
<BoxedIconTitleBlock
class="certificates-page__heading"
title="مدیریت گواهینامه‌ها"
desc="در این قسمت می‌توانید برای طلبه‌ها گواهینامه صادر کنید"
>
<template #icon>
<SvgIcon name="certificate" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="certificates-page__list-header">
<SimpleTitleIconBlock
title="لیست گواهینامه‌های صادرشده"
class="certificates-page__list-title"
>
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton
text="صدور گواهینامه جدید"
custom-class="certificates-page__add-btn"
@click="onAdd"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="certificates.length > 0">
<CertificateItem
v-for="certificate in certificates"
:key="certificate.id"
:certificate="certificate"
@delete="onAskDelete"
/>
</div>
<NoItems v-else title="متاسفیم" desc="گواهینامه‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
<IssueCertificateModal v-if="isModal('IssueCertificateModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CertificateItem from '@/features/admin/certificates/components/CertificateItem.vue'
import IssueCertificateModal from '@/features/admin/certificates/components/modals/IssueCertificateModal.vue'
import {
adminCertificatesKeys,
useAdminCertificatesListQuery,
useDeleteAdminCertificateMutation,
} from '@/services/query/admin-certificates'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const filters = ref({})
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminCertificatesListQuery(filters, pagination, {
keepPreviousData: true,
})
const certificates = computed(() => data.value?.data ?? [])
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const onAdd = () => openModal('IssueCertificateModal')
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
const deleteMutation = useDeleteAdminCertificateMutation()
const onAskDelete = (certificate) => {
openModal('ConfirmModal', {
title: `حذف گواهینامه ${certificate.user?.name || ''}`,
message:
'بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید این گواهینامه را حذف کنید؟',
onConfirm: () => deleteMutation.mutate(certificate.id, { onSuccess: invalidate }),
})
}
</script>
<style lang="scss" scoped>
.certificates-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.375rem;
}
&__list-title {
flex: 1;
margin-bottom: 0;
}
&__add-btn {
min-width: fit-content;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,24 @@
import { mixed, number, object, string } from 'yup'
export const certificateSchema = object()
.shape({
userId: number().typeError('طلبه را انتخاب کنید').required('طلبه را انتخاب کنید'),
termId: mixed().nullable().notRequired(),
courseId: mixed().nullable().notRequired(),
issuedAt: string().nullable().notRequired(),
startedAt: string().nullable().notRequired(),
endedAt: string().nullable().notRequired(),
imageMediaId: number().nullable().notRequired(),
pdfMediaId: number().nullable().notRequired(),
})
.test('term-or-course', function (value) {
if (value?.termId || value?.courseId) return true
return this.createError({ path: 'termId', message: 'یکی از ترم یا دوره را انتخاب کنید' })
})
.test('image-or-pdf', function (value) {
if (value?.imageMediaId || value?.pdfMediaId) return true
return this.createError({
path: 'imageMediaId',
message: 'حداقل یک فایل (تصویر یا PDF) بارگذاری کنید',
})
})
@@ -1,8 +1,8 @@
<template>
<div class="course-item">
<div class="course-item__main">
<div v-if="course.image" class="course-item__image">
<img :src="course.image" :alt="course.title" />
<div v-if="course.coverUrl" class="course-item__image">
<img :src="course.coverUrl" :alt="course.title" />
</div>
<div v-else class="course-item__image course-item__image--placeholder">
<SvgIcon name="book" :size="22" color="#bcbcbc" />
@@ -30,14 +30,13 @@
/>
</div>
<div class="course-item__actions">
<div v-if="!isActive" class="course-item__status">
<span class="course-item__status-badge">
<span class="course-item__status-dot" />
غیرفعال
</span>
</div>
<div class="course-item__actions">
<ToggleSwitch
:model-value="!!isActive"
@update:model-value="emit('change-status', { id: course.id, isActive: !isActive })"
@@ -192,10 +191,6 @@ const isActive = computed(() => props.course.isActive ?? false)
justify-content: flex-start;
}
&__status {
flex: 0 0 auto;
}
&__status-badge {
display: inline-flex;
align-items: center;
@@ -217,6 +212,7 @@ const isActive = computed(() => props.course.isActive ?? false)
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
@@ -154,7 +154,7 @@ const form = ref({
templateId: '',
title: '',
capacity: '',
imageId: null,
coverMediaId: null,
isActive: false,
})
@@ -212,10 +212,10 @@ watch(existingCourse, (course) => {
templateId: '',
title: course.title || '',
capacity: course.capacity ?? '',
imageId: course.imageId || null,
coverMediaId: course.coverMediaId || null,
isActive: course.isActive ?? false,
}
if (course.image) image.value = { url: course.image }
if (course.coverUrl) image.value = { url: course.coverUrl }
})
const uploadMutation = useUploadMediaMutation()
@@ -225,8 +225,8 @@ const onImageCropped = async (file) => {
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.imageId = payload?.uploadId || payload?.id
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
} catch {
/* handled globally */
}
@@ -14,7 +14,7 @@
<template v-else-if="course">
<div class="course-details__hero">
<div class="course-details__image">
<img v-if="course.image" :src="course.image" :alt="course.title" />
<img v-if="course.coverUrl" :src="course.coverUrl" :alt="course.title" />
<SvgIcon v-else name="book" :size="32" color="#bcbcbc" />
</div>
<div class="course-details__grid">
+1 -1
View File
@@ -24,6 +24,6 @@ export const offeredCourseSchema = object().shape({
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
imageId: string().nullable().notRequired(),
coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
isActive: boolean().nullable().notRequired(),
})
+22
View File
@@ -241,6 +241,17 @@ export default [
subtitle: 'لیست خدمات را مشاهده و مدیریت نمایید.',
},
},
{
path: '/service-types',
name: 'admin-service-types',
component: () => import('@/features/admin/services/pages/ServiceTypesPage.vue'),
meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز خدمات',
subtitle: 'انواع خدمات قابل درخواست توسط کاربران را مدیریت کنید.',
},
},
{
path: '/consultations',
name: 'admin-consultations',
@@ -263,4 +274,15 @@ export default [
subtitle: 'درخواست‌های اعزام مبلغین را مشاهده و مدیریت نمایید.',
},
},
{
path: '/certificates',
name: 'admin-certificates',
component: () => import('@/features/admin/certificates/pages/AdminCertificatesPage.vue'),
meta: {
layout: 'admin',
role: 'admin',
title: 'گواهینامه‌ها',
subtitle: 'گواهینامه‌های دوره‌ها را صادر و مدیریت نمایید.',
},
},
]
@@ -0,0 +1,86 @@
<template>
<div class="service-type-item">
<div class="service-type-item__main">
<p class="service-type-item__title">{{ serviceType.title }}</p>
<p class="service-type-item__desc">{{ serviceType.description || '—' }}</p>
</div>
<div class="service-type-item__actions">
<CircleButton
tooltip="ویرایش"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('edit', serviceType)"
>
<template #icon>
<SvgIcon name="pencil" :size="18" color="var(--color-primary)" />
</template>
</CircleButton>
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('delete', serviceType)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
serviceType: { type: Object, required: true },
})
const emit = defineEmits(['edit', 'delete'])
</script>
<style lang="scss" scoped>
.service-type-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 50%);
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
&__main {
flex: 1;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
}
&__desc {
font-family: var(--font-family-fa);
font-size: 0.8125rem;
color: #848484;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
}
</style>
@@ -0,0 +1,138 @@
<template>
<BasicModal
:title="isEditMode ? 'ویرایش نوع خدمت' : 'افزودن نوع خدمت'"
title-en="Service Type"
width="95%"
max-width="40rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="service-type-form" @submit.prevent="onSubmit(close)">
<TextField
v-model="form.title"
name="title"
label="عنوان نوع خدمت"
placeholder="مثال: خدمات رفاهی"
:error="errors.title"
@blur="validateAt('title', form.title)"
/>
<TextareaField
v-model="form.description"
name="description"
label="توضیحات"
placeholder="توضیح کوتاهی درباره این نوع خدمت بنویسید"
:row="4"
:error="errors.description"
/>
<div class="service-type-form__divider" />
<div class="service-type-form__actions">
<BaseButton
variant="transparent"
text="منصرف شدم"
custom-class="service-type-form__btn-cancel"
@click="close"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
</template>
</BaseButton>
<BaseButton
type="submit"
:text="isEditMode ? 'ذخیره تغییرات' : 'افزودن'"
:loading="submitting"
custom-class="service-type-form__btn-submit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BasicModal from '@/components/BasicModal.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { serviceTypeSchema } from '@/features/admin/services/schema/service-type'
import {
serviceTypesKeys,
useAddServiceTypeMutation,
useUpdateServiceTypeMutation,
} from '@/services/query/service-types'
defineOptions({ name: 'ServiceTypeFormModal' })
const queryClient = useQueryClient()
const { getModal } = useModal()
const modalData = computed(() => getModal('ServiceTypeFormModal')?.data ?? {})
const editingServiceType = computed(() => modalData.value.serviceType ?? null)
const isEditMode = computed(() => !!editingServiceType.value)
const form = ref({
title: editingServiceType.value?.title ?? '',
description: editingServiceType.value?.description ?? '',
})
const { validate, validateAt, errors } = useYup(serviceTypeSchema)
const addMutation = useAddServiceTypeMutation()
const updateMutation = useUpdateServiceTypeMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: editingServiceType.value.id, payload })
} else {
await addMutation.mutateAsync(payload)
}
await queryClient.invalidateQueries({ queryKey: serviceTypesKeys.all })
close()
}
</script>
<style lang="scss" scoped>
.service-type-form {
width: 100%;
text-align: start;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1rem;
}
&__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 10rem;
}
}
</style>
+2
View File
@@ -1,4 +1,6 @@
export { default as SendMessageModal } from './components/modals/SendMessageModal.vue'
export { default as ServiceDetailsModal } from './components/modals/ServiceDetailsModal.vue'
export { default as ServiceTypeFormModal } from './components/modals/ServiceTypeFormModal.vue'
export { default as ServiceItem } from './components/ServiceItem.vue'
export { default as ServiceTypeItem } from './components/ServiceTypeItem.vue'
export { default as ServicesFilters } from './components/ServicesFilters.vue'
@@ -0,0 +1,109 @@
<template>
<div class="service-types-page">
<BoxedIconTitleBlock
class="service-types-page__heading"
title="انواع خدمات"
desc="در این قسمت می‌توانید نوع‌های خدمت قابل درخواست توسط کاربران را تعریف کنید."
>
<template #icon>
<SvgIcon name="users-three" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="service-types-page__list-header">
<SimpleTitleIconBlock title="لیست انواع خدمات" class="service-types-page__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton text="افزودن نوع خدمت" custom-class="service-types-page__add-btn" @click="onAdd">
<template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
<div v-else-if="serviceTypes.length > 0">
<ServiceTypeItem
v-for="serviceType in serviceTypes"
:key="serviceType.id"
:service-type="serviceType"
@edit="onEdit"
@delete="onAskDelete"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز هیچ نوع خدمتی تعریف نشده است." />
<ServiceTypeFormModal v-if="isModal('ServiceTypeFormModal')" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
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 ServiceTypeItem from '@/features/admin/services/components/ServiceTypeItem.vue'
import ServiceTypeFormModal from '@/features/admin/services/components/modals/ServiceTypeFormModal.vue'
import {
serviceTypesKeys,
useServiceTypesListQuery,
useDeleteServiceTypeMutation,
} from '@/services/query/service-types'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const { data, isLoading } = useServiceTypesListQuery()
const serviceTypes = computed(() => data.value ?? [])
const onAdd = () => openModal('ServiceTypeFormModal')
const onEdit = (serviceType) => openModal('ServiceTypeFormModal', { serviceType })
const invalidate = () => queryClient.invalidateQueries({ queryKey: serviceTypesKeys.all })
const deleteMutation = useDeleteServiceTypeMutation()
const onAskDelete = (serviceType) => {
openModal('ConfirmModal', {
title: `حذف نوع خدمت «${serviceType.title}»`,
message:
'بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید این نوع خدمت را حذف کنید؟',
onConfirm: () => deleteMutation.mutate(serviceType.id, { onSuccess: invalidate }),
})
}
</script>
<style lang="scss" scoped>
.service-types-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.375rem;
}
&__list-title {
flex: 1;
margin-bottom: 0;
}
&__add-btn {
min-width: fit-content;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,6 @@
import { object, string } from 'yup'
export const serviceTypeSchema = object().shape({
title: string().trim().required('عنوان نوع خدمت را وارد کنید').max(255, 'عنوان طولانی است'),
description: string().trim().nullable().notRequired().max(2000, 'توضیحات طولانی است'),
})
@@ -77,6 +77,19 @@
/>
</div>
<!-- row 3b — embed code (online sessions only) -->
<div class="session-form__row" v-if="form.type === 'online'">
<TextareaField
v-model="form.embedCode"
name="embedCode"
label="کد امبد (Embed Code)"
placeholder="در صورت وجود، کد iframe/embed جلسه (مثلاً از آپارات یا سرویس ویدئوکنفرانس) را اینجا وارد کنید تا به‌جای لینک، به‌صورت مستقیم در صفحه نمایش داده شود."
:row="3"
:error="errors.embedCode"
@blur="validateAt('embedCode', form.embedCode)"
/>
</div>
<!-- row 4 — description -->
<div class="session-form__row">
<TextareaField
@@ -101,6 +114,17 @@
@remove="onContentRemove"
@error="onContentError"
/>
<div v-if="contentUploadMutation.isPending.value" class="session-form__upload-progress">
<div class="session-form__upload-progress-track">
<div
class="session-form__upload-progress-bar"
:style="{ width: contentUploadProgress + '%' }"
/>
</div>
<span class="session-form__upload-progress-text">
{{ contentUploadProgress }}% بارگذاری شده
</span>
</div>
</div>
</div>
@@ -200,6 +224,7 @@ const form = ref({
contentType: '',
type: '',
link: '',
embedCode: '',
description: '',
})
@@ -207,6 +232,7 @@ const image = ref(null)
const contentFiles = ref([])
const coverMediaId = ref(null)
const contentMediaId = ref(null)
const contentUploadProgress = ref(0)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const contentMaxSize = computed(
@@ -238,7 +264,10 @@ const onContentTypeChange = () => {
}
const onSessionTypeChange = () => {
if (form.value.type !== 'online') form.value.link = ''
if (form.value.type !== 'online') {
form.value.link = ''
form.value.embedCode = ''
}
}
const { data: existingSession } = useAdminSessionQuery(sessionId, {
@@ -267,6 +296,7 @@ watch(
contentType,
type: session.type ?? '',
link: session.link ?? '',
embedCode: session.embedCode ?? '',
description: session.description ?? '',
}
coverMediaId.value = coverMedia?.id ?? null
@@ -298,13 +328,20 @@ const purposeForContentType = (contentType) => {
const onContentSelect = async (files) => {
const file = files?.[0]
if (!file) return
contentUploadProgress.value = 0
try {
const formData = objectToFormData({
file,
purpose: purposeForContentType(form.value.contentType),
context: 'session',
})
const response = await contentUploadMutation.mutateAsync(formData)
const response = await contentUploadMutation.mutateAsync({
formData,
onUploadProgress: (event) => {
if (!event.total) return
contentUploadProgress.value = Math.round((event.loaded / event.total) * 100)
},
})
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
@@ -342,6 +379,7 @@ const buildPayload = (values) => {
courseId: values.courseId,
type: values.type,
link: values.type === 'online' ? values.link : undefined,
embedCode: values.type === 'online' ? values.embedCode : undefined,
description: values.description,
mediaIds,
}
@@ -477,6 +515,38 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
color: var(--color-prim-gray);
}
&__upload-progress {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: -0.25rem;
margin-bottom: 0.75rem;
}
&__upload-progress-track {
flex: 1;
height: 0.5rem;
border-radius: 999px;
background: var(--color-thd-gray);
overflow: hidden;
}
&__upload-progress-bar {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.2s ease;
}
&__upload-progress-text {
flex-shrink: 0;
font-family: var(--font-family-en);
font-size: 0.75rem;
color: var(--color-sec-gray);
min-width: 3.5rem;
text-align: left;
}
&__preview {
place-items: center center;
}
@@ -9,5 +9,6 @@ export const sessionSchema = object().shape({
then: (schema) => schema.required(),
otherwise: (schema) => schema.nullable().notRequired(),
}),
embedCode: string().nullable().notRequired(),
description: string().nullable().notRequired(),
})
@@ -1,8 +1,8 @@
<template>
<div class="course-item">
<div class="course-item__main">
<div v-if="course.image" class="course-item__image">
<img :src="course.image" :alt="course.title" />
<div v-if="course.coverUrl" class="course-item__image">
<img :src="course.coverUrl" :alt="course.title" />
</div>
<div v-else class="course-item__image course-item__image--placeholder">
<SvgIcon name="book" :size="22" color="#bcbcbc" />
@@ -17,14 +17,13 @@
</div>
</div>
<div class="term-item__actions">
<div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge">
<span class="term-item__status-dot" />
غیرفعال
</span>
</div>
<div class="term-item__actions">
<ToggleSwitch
:model-value="!!term.isActive"
@update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })"
@@ -166,10 +165,6 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
font-size: 0.75rem;
}
&__status {
flex: 0 0 auto;
}
&__status-badge {
display: inline-flex;
align-items: center;
@@ -191,6 +186,7 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
@@ -0,0 +1,392 @@
<template>
<div class="user-evaluation">
<div class="user-evaluation__header">
<LineTitleBlock
class="user-evaluation__title"
title="سیستم ارزیابی کاربر"
title-en="User Evaluation System"
/>
<BaseButton
text="دانلود PDF"
:loading="pdfLoading"
:disabled="isLoading || !evaluation"
custom-class="user-evaluation__pdf-btn"
data-pdf-ignore
@click="onSaveAsPdf"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="2" :cols-per-row="3" />
<div v-else-if="evaluation" ref="pdfContent" class="user-evaluation__content">
<header class="user-evaluation__pdf-header">
<h1>سیستم ارزیابی کاربر</h1>
<p>{{ fullName }}</p>
</header>
<section class="user-evaluation__section">
<p class="user-evaluation__section-title">مشخصات هویتی</p>
<div class="user-evaluation__identity-grid">
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
<LineInfoBlock title="شماره تماس" :numeric-desc="user?.phone || '—'" />
<LineInfoBlock title="کد ملی" :numeric-desc="user?.nationalCode || '—'" />
<LineInfoBlock title="نقش" :desc="roleText" />
<LineInfoBlock title="تاریخ عضویت" :numeric-desc="joinDateLabel" />
</div>
</section>
<section class="user-evaluation__section">
<p class="user-evaluation__section-title">خلاصه عملکرد</p>
<div class="user-evaluation__stats">
<div v-for="(stat, i) in stats" :key="i" class="user-evaluation__stat-card">
<div class="user-evaluation__stat-icon">
<SvgIcon :name="stat.icon" :size="20" color="var(--color-primary)" />
</div>
<div class="user-evaluation__stat-text">
<p class="user-evaluation__stat-label">{{ stat.label }}</p>
<p class="user-evaluation__stat-value">
{{ stat.value }}
<span v-if="stat.unit" class="user-evaluation__stat-unit">{{ stat.unit }}</span>
</p>
<p v-if="stat.subtitle" class="user-evaluation__stat-subtitle">
{{ stat.subtitle }}
</p>
</div>
</div>
</div>
</section>
</div>
<NoItems v-else title="موردی نیست" desc="اطلاعات ارزیابی این کاربر در دسترس نیست." />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { ROLE_LABELS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { useAdminUserEvaluationQuery } from '@/services/query/admin-users'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
const props = defineProps({
userId: { type: [Number, String], required: true },
user: { type: Object, default: null },
})
const userIdRef = computed(() => props.userId)
const { data: evaluation, isLoading } = useAdminUserEvaluationQuery(userIdRef, {
enabled: () => !!userIdRef.value,
})
const fullName = computed(() => props.user?.name || '—')
const roleText = computed(() => {
const names = (props.user?.roles || [])
.map((r) => (typeof r === 'string' ? r : r?.name))
.filter(Boolean)
if (names.length === 0) return 'بدون نقش'
return names.map((name) => ROLE_LABELS[name] || name).join('، ')
})
const joinDateLabel = computed(() => formatJalaaliDate(props.user?.createdAt) || '—')
const formatMembershipDuration = (days) => {
if (days == null) return '—'
if (days < 1) return 'کمتر از یک روز'
const years = Math.floor(days / 365)
const months = Math.floor((days % 365) / 30)
const remDays = Math.floor(days % 30)
const parts = []
if (years) parts.push(`${years} سال`)
if (months) parts.push(`${months} ماه`)
if (!years && !months) parts.push(`${remDays} روز`)
return parts.join(' و ')
}
const stats = computed(() => {
const e = evaluation.value || {}
const exams = e.exams || {}
const assignments = e.assignments || {}
return [
{
icon: 'video',
label: 'جلسات مشاهده‌شده',
value: e.sessions_viewed ?? 0,
unit: 'جلسه',
},
{
icon: 'check-square',
label: 'آزمون‌های شرکت‌کرده',
value: exams.taken ?? 0,
unit: 'آزمون',
subtitle: `قبول‌شده: ${exams.passed ?? 0}`,
},
{
icon: 'certificate',
label: 'میانگین امتیاز',
value: exams.average_score_percent ?? '—',
unit: exams.average_score_percent == null ? '' : '%',
},
{
icon: 'file',
label: 'تکالیف انجام‌شده',
value: assignments.completed ?? 0,
unit: 'تکلیف',
subtitle: `تاییدشده: ${assignments.accepted ?? 0}`,
},
{
icon: 'book',
label: 'ترم فعلی',
value: e.current_term?.title || 'بدون ترم',
unit: '',
},
{
icon: 'clock',
label: 'مدت عضویت',
value: formatMembershipDuration(e.member_since?.days),
unit: '',
},
]
})
const pdfContent = ref(null)
const pdfLoading = ref(false)
const onSaveAsPdf = async () => {
if (!pdfContent.value || isLoading.value || pdfLoading.value) return
pdfLoading.value = true
try {
await document.fonts?.ready
const pdfSource = pdfContent.value.cloneNode(true)
pdfSource.classList.add('user-evaluation__content--pdf')
pdfSource.querySelectorAll('[data-pdf-ignore]').forEach((element) => element.remove())
const html2pdfModule = await import('html2pdf.js')
const html2pdf = html2pdfModule.default || html2pdfModule
await html2pdf()
.set({
margin: [8, 8, 8, 8],
filename: `user-evaluation-${props.userId}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
},
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['.user-evaluation__section', '.user-evaluation__stat-card'],
},
})
.from(pdfSource)
.save()
} catch {
toast.error('ساخت فایل PDF با خطا مواجه شد.')
} finally {
pdfLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.user-evaluation {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: start;
&__header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.625rem;
}
&__title {
flex: 1;
min-width: 12rem;
padding: 0;
}
&__pdf-btn {
min-width: 9rem;
flex-shrink: 0;
}
&__pdf-header {
display: none;
}
&__section {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
&__section-title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.8125rem;
color: #8a8a8a;
margin: 0 0 0.625rem;
}
&__identity-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__stats {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__stat-card {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.875rem;
background: rgba(255, 255, 255, 55%);
box-shadow: 0 4px 10px -6px rgba(0, 0, 0, 8%);
border-radius: 0.875rem;
min-width: 0;
}
&__stat-icon {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
background: rgba(243, 102, 117, 8%);
display: flex;
align-items: center;
justify-content: center;
}
&__stat-text {
min-width: 0;
flex: 1;
}
&__stat-label {
margin: 0 0 0.25rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
line-height: 1.3;
color: #9a9a9a;
}
&__stat-value {
margin: 0;
font-family: var(--font-family-fa);
font-weight: 700;
font-size: 1.0625rem;
line-height: 1.4;
color: #4b4b4b;
word-break: break-word;
}
&__stat-unit {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #8a8a8a;
}
&__stat-subtitle {
margin: 0.25rem 0 0;
font-family: var(--font-family-fa);
font-size: 0.6875rem;
color: #9a9a9a;
}
&__content--pdf {
width: 100%;
padding: 0.25rem;
background: #fff;
box-sizing: border-box;
direction: rtl;
}
&__content--pdf &__pdf-header {
display: block;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
text-align: right;
break-after: avoid;
h1 {
margin: 0;
font-family: var(--font-family-fa);
font-size: 1.25rem;
font-weight: 500;
color: #4b4b4b;
}
p {
margin: 0.25rem 0 0;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #808080;
}
}
&__content--pdf &__section {
margin-bottom: 1rem;
break-inside: avoid;
page-break-inside: avoid;
}
&__content--pdf &__identity-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&__content--pdf &__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&__content--pdf &__stat-card {
break-inside: avoid;
page-break-inside: avoid;
}
}
</style>
@@ -25,12 +25,12 @@
<button
type="button"
class="users-table__role-badge"
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`"
:class="`users-table__role-badge--${roleNames(user)[0] || 'unknown'}`"
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
@click="onRoleClick($event, user)"
>
<span class="users-table__role-dot" />
<span>{{ roleLabel(user) || 'بدون نقش' }}</span>
<span>{{ roleLabelsText(user) || 'بدون نقش' }}</span>
<SvgIcon name="caret-down" :size="12" color="currentColor" />
</button>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
@@ -96,15 +96,13 @@ const queryClient = useQueryClient()
const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details'])
const roleName = (user) => {
const first = user.roles?.[0]
return typeof first === 'string' ? first : first?.name
}
const roleNames = (user) =>
(user.roles || []).map((r) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
const roleLabel = (user) => {
const name = roleName(user)
if (!name) return ''
return ROLES.find((r) => r.value === name)?.label || name
const roleLabelsText = (user) => {
const names = roleNames(user)
if (names.length === 0) return ''
return names.map((name) => ROLES.find((r) => r.value === name)?.label || name).join('، ')
}
const menuOpen = ref(false)
@@ -140,23 +138,39 @@ const onRoleClick = (event, user) => {
roleMenuOpen.value = true
}
const onChangeRole = (targetRoleName) => {
// Toggles one role's membership without touching the user's other roles —
// access must stay additive, so this can never collapse into a single-role
// replace the way the old "switch to X" action used to.
const onToggleRole = (targetRoleName, isAssigned) => {
const user = activeUser.value
if (!user || !targetRoleName) return
const current = roleNames(user)
const nextRoles = isAssigned
? current.filter((r) => r !== targetRoleName)
: [...current, targetRoleName]
if (nextRoles.length === 0) return
updateRoleMutation.mutate(
{ id: user.id, payload: { roles: [targetRoleName] } },
{ id: user.id, payload: { roles: nextRoles } },
{ onSuccess: invalidateUsers }
)
}
const roleMenuItems = computed(() => {
const current = activeUser.value ? roleName(activeUser.value) : null
return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({
const user = activeUser.value
if (!user) return []
const current = roleNames(user)
return ROLES.filter((r) => r.changeable).map((r) => {
const isAssigned = current.includes(r.value)
const isLastRole = isAssigned && current.length <= 1
return {
key: `role-${r.value}`,
label: `تغییر به ${r.label}`,
icon: 'arrows-clockwise',
onClick: () => onChangeRole(r.value),
}))
label: isAssigned ? `حذف نقش ${r.label}` : `افزودن نقش ${r.label}`,
icon: isAssigned ? 'close' : 'check',
danger: isAssigned,
disabled: isLastRole,
onClick: () => onToggleRole(r.value, isAssigned),
}
})
})
const menuItems = computed(() => {
@@ -76,6 +76,10 @@
</div>
<LineInfoBlock title="گرایش/رشته تحصیلی" :desc="user.fieldOfStudy || '—'" />
</div>
<div class="user-details__section">
<UserEvaluationPanel :user-id="userId" :user="user" />
</div>
</template>
<NoItems v-else title="یافت نشد" desc="اطلاعات این کاربر در دسترس نیست." />
@@ -106,6 +110,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useAdminUserQuery } from '@/services/query/admin-users'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import UserEvaluationPanel from '@/features/admin/users/components/UserEvaluationPanel.vue'
import {
EDUCATION_STATUS,
GENDER,
+12 -10
View File
@@ -19,14 +19,15 @@
<div class="user-form__row">
<div class="user-form__cell user-form__cell--third">
<SelectField
v-model="form.role"
name="role"
label="نقش"
v-model="form.roles"
name="roles"
label="نقش‌ها"
:multiple="true"
:options="ROLES"
option-label="label"
option-value="value"
:error="errors.role"
@change="validateAt('role', form.role)"
:error="errors.roles"
@change="validateAt('roles', form.roles)"
/>
</div>
<div class="user-form__cell user-form__cell--third">
@@ -265,7 +266,7 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
const initialNationalCode = ref('')
const form = ref({
role: '',
roles: [],
name: '',
birthday: '',
nationalCode: '',
@@ -307,11 +308,12 @@ watch(
existingUser,
(user) => {
if (!user) return
const firstRole = Array.isArray(user.roles) ? user.roles[0] : null
const roleValue = typeof firstRole === 'object' ? firstRole?.name : firstRole
const roleValues = Array.isArray(user.roles)
? user.roles.map((r) => (typeof r === 'object' ? r?.name : r)).filter(Boolean)
: []
form.value = {
...form.value,
role: roleValue || '',
roles: roleValues,
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
birthday: user.birthday || '',
nationalCode: user.nationalCode || '',
@@ -360,7 +362,7 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
const buildUpdatePayload = () => {
return {
name: form.value.name,
roles: form.value.role ? [form.value.role] : [],
roles: form.value.roles,
provinceId: form.value.provinceId || null,
cityId: form.value.cityId || null,
marriageStatus: form.value.maritalStatus || null,
+2 -2
View File
@@ -1,4 +1,4 @@
import { number, object, string } from 'yup'
import { array, number, object, string } from 'yup'
import { passwordRule } from '@/constants/rules/passwordRule'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
@@ -9,7 +9,7 @@ export const rejectionReasonSchema = object().shape({
})
const baseUserShape = {
role: string().required(),
roles: array().of(string().required()).min(1, 'حداقل یک نقش را انتخاب کنید').required(),
name: string().required().min(3),
phone: phoneNumberRule,
nationalCode: nationalCodeRule,
@@ -1,6 +1,6 @@
<template>
<div class="auth-copyright">
<a href="https://mugweb.ir/" target="_blank" rel="noopener">
<a href="https://madresebanoo.ir/" target="_blank" rel="noopener">
<p class="auth-copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست</p>
<p class="auth-copyright__en">All Rights Reserved. 2026</p>
</a>
@@ -2,7 +2,8 @@ import { useAuthStore } from '@/store/auth'
import { useRoute, useRouter } from 'vue-router'
import { MAIN_SITE_URL, ROLE_HOME_ROUTES } from '@/enums'
const shouldLandOnMainSite = (auth) => auth.role !== 'admin' && auth.user?.status === 'approved'
const shouldLandOnMainSite = (auth) =>
!auth.roles.includes('admin') && auth.user?.status === 'approved'
export const useRedirectAfterLogin = () => {
const router = useRouter()
@@ -18,6 +18,17 @@
<p class="student-register__desc">{{ stepLabel.desc }}</p>
</header>
<div
v-if="auth.user?.status === 'blocked'"
class="student-register__rejection"
role="alert"
>
<p class="student-register__rejection-title">احراز هویت شما توسط ادمین رد شده است.</p>
<p class="student-register__rejection-reason">
{{ auth.user?.blockReason || 'دلیلی ثبت نشده است.' }}
</p>
</div>
<div v-if="isRegistrationDataPending" class="student-register__loading" aria-live="polite">
در حال بازیابی اطلاعات ثبت‌نام…
</div>
@@ -30,6 +41,7 @@
</template>
<script setup>
import { useAuthStore } from '@/store/auth'
import useModal from '@/composables/useModal'
import { computed, markRaw, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
@@ -49,6 +61,7 @@ import EducationalInformation from '@/features/auth/components/studentRegister/E
import ProfessionalInformation from '@/features/auth/components/studentRegister/ProfessionalInformation.vue'
const store = useStudentRegistrationStore()
const auth = useAuthStore()
const { isModal, openModal } = useModal()
const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery()
@@ -273,6 +286,32 @@ watch(
text-align: center;
}
&__rejection {
background: #fdecec;
border: 1px solid #f5b8b8;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
&__rejection-title {
font-family: var(--font-family-fa);
font-weight: 600;
font-size: 0.9375rem;
color: #c62828;
margin: 0 0 0.375rem;
}
&__rejection-reason {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.8;
color: #7a2f2f;
margin: 0;
text-align: justify;
}
&__main :deep(.text-field__label),
&__main :deep(.textarea-field__label),
&__main :deep(.select-field__label),
@@ -120,7 +120,7 @@ const downloadItems = computed(() => [
@media (min-width: 1024px) {
flex: 1;
order: 3;
order: 1;
min-width: 12rem;
}
}
@@ -146,8 +146,8 @@ const downloadItems = computed(() => [
&__course-info {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
align-items: flex-end;
text-align: right;
gap: 0.25rem;
min-width: 0;
}
@@ -186,6 +186,7 @@ const downloadItems = computed(() => [
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
align-self: center;
@media (min-width: 1024px) {
order: 2;
@@ -222,15 +223,20 @@ const downloadItems = computed(() => [
justify-content: center;
@media (min-width: 1024px) {
order: 1;
justify-content: flex-start;
order: 3;
justify-content: flex-end;
}
}
&__download-btn {
min-width: 9rem;
width: 100%;
height: 2rem;
padding: 0 1.25rem;
@media (min-width: 1024px) {
width: auto;
min-width: 9rem;
}
}
}
</style>
@@ -35,6 +35,7 @@ import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
@@ -53,7 +54,17 @@ const { data, isLoading } = useStudentCertificatesQuery(filters, pagination, {
keepPreviousData: true,
})
const certificates = computed(() => data.value?.data ?? [])
// Bridge the backend payload (title/teacherName/startedAt/endedAt) into the
// display shape CertificateItem expects, without touching its template.
const certificates = computed(() =>
(data.value?.data ?? []).map((c) => ({
...c,
courseTitle: c.title ?? '',
teacherName: c.teacherName ?? '',
startDate: formatJalaaliDate(c.startedAt) || '',
endDate: formatJalaaliDate(c.endedAt) || '',
}))
)
const meta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
@@ -67,8 +67,8 @@
</template>
<script setup>
import { ref } from 'vue'
import * as yup from 'yup'
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import BaseButton from '@/components/BaseButton.vue'
@@ -79,6 +79,7 @@ import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import { useServiceTypesListQuery } from '@/services/query/service-types'
defineProps({
submitting: { type: Boolean, default: false },
@@ -86,10 +87,10 @@ defineProps({
const emit = defineEmits(['submit', 'file-error'])
const serviceTypeOptions = [
{ value: 'خدمات رفاهی', label: 'خدمات رفاهی' },
{ value: 'خدمات آموزشی', label: 'خدمات آموزشی' },
]
const { data: serviceTypes } = useServiceTypesListQuery()
const serviceTypeOptions = computed(() =>
(serviceTypes.value ?? []).map((st) => ({ value: st.title, label: st.title }))
)
const form = ref({
category: '',
@@ -37,12 +37,23 @@
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
<p class="ssd__text-body">{{ session.text }}</p>
</div>
<div v-else-if="contentKind === 'embed'" class="ssd__embed">
<iframe
class="ssd__embed-frame"
:srcdoc="session.embedCode"
sandbox="allow-scripts allow-popups allow-forms allow-presentation"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
<div v-else-if="contentKind === 'link'" class="ssd__link">
<div class="ssd__link__box">
<span>https://link</span>
<span>{{ session?.link || 'لینکی ثبت نشده است' }}</span>
<SvgIcon name="link" :size="22" class="ssd__link-icon" />
</div>
<BaseButton text="ورود به جلسه " custom-class="ssd__link-btn">
<BaseButton text="ورود به جلسه " custom-class="ssd__link-btn" @click="openSessionLink">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
@@ -129,13 +140,17 @@ const homework = computed(() => (homeworksData.value?.data ?? [])[0] ?? null)
const contentKind = computed(() => {
const s = session.value
if (!s) return ''
if (s.type === 'online') return 'link'
if (s.videoUrl) return 'link'
if (s.type === 'online') return s.embedCode?.trim() ? 'embed' : 'link'
if (s.videoUrl) return 'video'
if (s.audioUrl) return 'audio'
if (s.text) return 'text'
return ''
})
const openSessionLink = () => {
if (session.value?.link) window.open(session.value.link, '_blank', 'noopener,noreferrer')
}
const homeworkAudio = ref(null)
const homeworkImage = ref(null)
const errors = reactive({ audio: '', image: '' })
@@ -260,6 +275,18 @@ const onSubmit = async () => {
}
}
&__embed {
width: 100%;
}
&__embed-frame {
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
border-radius: 1rem;
background: #000;
}
&__link {
display: flex;
align-items: center;
+17
View File
@@ -147,9 +147,20 @@ const menuItems = computed(() => [
{
title: 'مدیریت خدمات',
icon: 'users-three',
active: ['admin-services', 'admin-service-types'].includes(route.name),
children: [
{
title: 'درخواست‌های خدمت',
to: { name: 'admin-services' },
active: route.name === 'admin-services',
},
{
title: 'انواع خدمات',
to: { name: 'admin-service-types' },
active: route.name === 'admin-service-types',
},
],
},
{
title: 'مدیریت مشاوره',
icon: 'chat',
@@ -162,6 +173,12 @@ const menuItems = computed(() => [
to: { name: 'admin-missionary-requests' },
active: route.name === 'admin-missionary-requests',
},
{
title: 'گواهینامه‌ها',
icon: 'certificate',
to: { name: 'admin-certificates' },
active: route.name === 'admin-certificates',
},
])
</script>
+4 -14
View File
@@ -40,13 +40,16 @@
<script setup>
import { useUIStore } from '@/store/ui'
import { useAuthStore } from '@/store/auth'
import { RouterView, useRoute } from 'vue-router'
import Copyright from '@/components/Copyright.vue'
import HeaderBlock from '@/components/HeaderBlock.vue'
import { buildMergedMenuItems } from '@/layouts/roleMenuItems'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CounselorSidebar from '@/layouts/sidebars/CounselorSidebar.vue'
const ui = useUIStore()
const auth = useAuthStore()
const route = useRoute()
const isMobile = ref(window.innerWidth < 1024)
@@ -59,20 +62,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'مشاوره')
const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'counselor-dashboard' },
active: route.name === 'counselor-dashboard',
},
{
title: 'مشاوره',
icon: 'chat',
to: { name: 'counselor-consultations' },
active: route.name === 'counselor-consultations',
},
])
const menuItems = computed(() => buildMergedMenuItems(auth.roles, route))
</script>
<style lang="scss" scoped>
+4 -26
View File
@@ -38,13 +38,16 @@
<script setup>
import { useUIStore } from '@/store/ui'
import { useAuthStore } from '@/store/auth'
import { RouterView, useRoute } from 'vue-router'
import Copyright from '@/components/Copyright.vue'
import HeaderBlock from '@/components/HeaderBlock.vue'
import { buildMergedMenuItems } from '@/layouts/roleMenuItems'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import StudentSidebar from '@/layouts/sidebars/StudentSidebar.vue'
const ui = useUIStore()
const auth = useAuthStore()
const route = useRoute()
const isMobile = ref(window.innerWidth < 1024)
@@ -57,32 +60,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'سیستم مدیریت اعزام')
const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'missionary-dashboard' },
active: route.name === 'missionary-dashboard',
},
{
title: 'سامانه اعزام',
icon: 'user-sound',
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
},
{
title: 'آموزش من',
icon: 'graduation-cap',
to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'),
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets',
},
])
const menuItems = computed(() => buildMergedMenuItems(auth.roles, route))
</script>
<style lang="scss" scoped>
+4 -46
View File
@@ -38,13 +38,16 @@
<script setup>
import { useUIStore } from '@/store/ui'
import { useAuthStore } from '@/store/auth'
import { RouterView, useRoute } from 'vue-router'
import Copyright from '@/components/Copyright.vue'
import HeaderBlock from '@/components/HeaderBlock.vue'
import { buildMergedMenuItems } from '@/layouts/roleMenuItems'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import StudentSidebar from '@/layouts/sidebars/StudentSidebar.vue'
const ui = useUIStore()
const auth = useAuthStore()
const route = useRoute()
const isMobile = ref(window.innerWidth < 1024)
@@ -57,52 +60,7 @@ 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',
to: { name: 'student-terms' },
active: [
'student-terms',
'student-term-details',
'student-lesson-details',
'student-session-details',
'student-exam',
'student-courses',
'student-course-details',
].includes(route.name),
},
{
title: 'خدمات',
icon: 'folder-simple-star',
to: { name: 'student-services' },
active: route.name === 'student-services',
},
{
title: 'گواهینامه',
icon: 'certificate',
to: { name: 'student-certificates' },
active: route.name === 'student-certificates',
},
{
title: 'ویرایش پروفایل',
icon: 'user-circle-check',
to: { name: 'student-profile' },
active: route.name === 'student-profile',
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'student-tickets' },
active: route.name === 'student-tickets',
},
])
const menuItems = computed(() => buildMergedMenuItems(auth.roles, route))
</script>
<style lang="scss" scoped>
+124
View File
@@ -0,0 +1,124 @@
// Per-role sidebar sections, kept as plain builder functions so any layout
// can request the union of sections for every role a user holds — additive
// multi-role access without each layout hardcoding its own single-role list.
// Only roles with a registered builder participate in the merge; a role
// without one (e.g. admin) contributes nothing and keeps its own standalone
// menu untouched.
export const buildStudentMenuItems = (route) => [
{
title: 'داشبورد',
icon: 'monitor',
to: { name: 'student-dashboard' },
active: route.name === 'student-dashboard',
},
{
title: 'آموزش',
icon: 'graduation-cap',
to: { name: 'student-terms' },
active: [
'student-terms',
'student-term-details',
'student-lesson-details',
'student-session-details',
'student-exam',
'student-courses',
'student-course-details',
].includes(route.name),
},
{
title: 'خدمات',
icon: 'folder-simple-star',
to: { name: 'student-services' },
active: route.name === 'student-services',
},
{
title: 'مشاوره',
icon: 'chat-teardrop-text',
to: { name: 'student-consultants' },
active: route.name === 'student-consultants',
},
{
title: 'گواهینامه',
icon: 'certificate',
to: { name: 'student-certificates' },
active: route.name === 'student-certificates',
},
{
title: 'ویرایش پروفایل',
icon: 'user-circle-check',
to: { name: 'student-profile' },
active: route.name === 'student-profile',
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'student-tickets' },
active: route.name === 'student-tickets',
},
]
export const buildMissionaryMenuItems = (route) => [
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'missionary-dashboard' },
active: route.name === 'missionary-dashboard',
},
{
title: 'سامانه اعزام',
icon: 'user-sound',
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
},
{
title: 'آموزش من',
icon: 'graduation-cap',
to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'),
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets',
},
]
export const buildCounselorMenuItems = (route) => [
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'counselor-dashboard' },
active: route.name === 'counselor-dashboard',
},
{
title: 'مشاوره',
icon: 'chat',
to: { name: 'counselor-consultations' },
active: route.name === 'counselor-consultations',
},
]
const MENU_BUILDERS = {
student: buildStudentMenuItems,
missionary: buildMissionaryMenuItems,
counselor: buildCounselorMenuItems,
}
/**
* Union of every panel section unlocked by `roles`, in role order (roles
* come back from the API ordered by role id, so a user's base role's
* sections lead and each additional role's sections are appended after).
*/
export const buildMergedMenuItems = (roles, route) => {
const seen = new Set()
const items = []
for (const roleName of roles ?? []) {
const builder = MENU_BUILDERS[roleName]
if (!builder || seen.has(roleName)) continue
seen.add(roleName)
items.push(...builder(route))
}
return items
}
+2 -2
View File
@@ -33,7 +33,7 @@ export const statusGuard = (to) => {
if (!tokenService.hasToken()) return
const auth = useAuthStore()
if (!auth.user) return
if (auth.role && auth.role !== 'student') return
if (!auth.roles.includes('student')) return
if (auth.user.status === 'approved') return
return { name: 'student-register' }
}
@@ -41,7 +41,7 @@ export const statusGuard = (to) => {
export const roleGuard = (to) => {
if (!to.meta?.role) return
const auth = useAuthStore()
if (auth.role !== to.meta.role) {
if (!auth.roles.includes(to.meta.role)) {
return { name: 'home' }
}
return
+10
View File
@@ -0,0 +1,10 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetAdminCertificates = (params) =>
http.get(endpoints.getAdminCertificates, { params })
export const apiAddAdminCertificate = (payload) => http.post(endpoints.addAdminCertificate, payload)
export const apiDeleteAdminCertificate = (id) =>
http.delete(buildUrl(endpoints.deleteAdminCertificate, { id }))
+3
View File
@@ -8,6 +8,9 @@ export const apiShowAdminUser = (id) => http.get(buildUrl(endpoints.showUserDeta
export const apiGetAdminUserRegisterData = (id) =>
http.get(buildUrl(endpoints.getUserRegisterData, { id }))
export const apiGetAdminUserEvaluation = (id) =>
http.get(buildUrl(endpoints.getUserEvaluation, { id }))
export const apiAddAdminUser = (payload) => http.post(endpoints.addNewUser, payload)
export const apiUpdateAdminUser = (id, payload) =>
+7 -2
View File
@@ -46,5 +46,10 @@ export const apiGetRegistrationQuestionVideo = () =>
// Media uploads (especially course videos) can legitimately take several
// minutes on slower connections. Keep the normal API timeout short, but give
// this endpoint enough time to complete the backend's 300 MB upload contract.
export const apiUploadMedia = (formData) =>
http.post(endpoints.uploadMedia, formData, { timeout: 10 * 60 * 1000 })
// `onUploadProgress` is optional and forwarded straight to axios so callers
// can render an upload progress bar (e.g. XHR progress events).
export const apiUploadMedia = (formData, onUploadProgress) =>
http.post(endpoints.uploadMedia, formData, {
timeout: 10 * 60 * 1000,
...(onUploadProgress ? { onUploadProgress } : {}),
})
+14
View File
@@ -55,6 +55,14 @@ export const endpoints = {
getStudentCertificates: '/student/certificates',
downloadStudentCertificate: '/student/certificates/:id/download',
// ─── Backend-aligned: Service types (settings) ────────────────────────────
// Read is open to any authenticated role (populates the student "request
// service" category select); create/update/delete are admin-only.
getServiceTypesList: '/service-types',
addServiceType: '/service-types',
updateServiceType: '/service-types/:id',
deleteServiceType: '/service-types/:id',
// ─── Per-role aggregated dashboards ────────────────────────────────────────
// Each requires the matching role's token; backend scopes the payload.
getAdminDashboard: '/admin/dashboard',
@@ -74,6 +82,7 @@ export const endpoints = {
changeUserStatus: '/admin/users/:id/status',
deleteUser: '/admin/users/:id',
getUserRegisterData: '/admin/users/:id/register-data',
getUserEvaluation: '/admin/users/:id/evaluation',
// ─── Backend-aligned: Terms ────────────────────────────────────────────────
getTermsList: '/terms',
@@ -187,6 +196,11 @@ export const endpoints = {
listAdminMissionaryRequests: '/admin/missionary-requests',
showAdminMissionaryRequest: '/admin/missionary-requests/:id',
changeAdminMissionaryRequestStatus: '/admin/missionary-requests/:id/status',
// ─── Admin: certificates ───────────────────────────────────────────────────
getAdminCertificates: '/admin/certificates',
addAdminCertificate: '/admin/certificates',
deleteAdminCertificate: '/admin/certificates/:id',
}
export const buildUrl = (template, params = {}) =>
+16 -3
View File
@@ -61,12 +61,25 @@ const addCamelizeInterceptor = (instance) => {
)
}
const _addUnauthorizeInterceptor = (instance) => {
const addUnauthorizeInterceptor = (instance) => {
instance.interceptors.response.use(
(res) => res,
async (e) => {
if (e?.response?.status === 401) {
// Login/OTP-verify endpoints also answer 401 for a plain wrong
// password or code — that's a credential failure the login form
// should handle itself, not a session expiring. Only treat this as
// "the session died" when we actually had a token going in.
if (e?.response?.status === 401 && tokenService.hasToken()) {
// Route through the auth store, not tokenService directly, so the
// reactive user state and the WordPress-facing mb_auth_status cookie
// both get cleared along with the token — an expired/invalid session
// must not leave WordPress showing a stale "logged in" state.
try {
const { useAuthStore } = await import('@/store/auth')
useAuthStore().clearUser()
} catch {
tokenService.clearAll()
}
toast('نشست شما تایید نشد، لطفا مجدد وارد شوید')
try {
const { default: router } = await import('@/router')
@@ -89,7 +102,7 @@ const createInstance = (baseUrl, { headers = {}, ...configs } = {}) => {
addResponseUnwrapInterceptor(instance)
addCamelizeInterceptor(instance)
addSnakizeInterceptor(instance)
// _addUnauthorizeInterceptor(instance)
addUnauthorizeInterceptor(instance)
return instance
}
+12
View File
@@ -0,0 +1,12 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetServiceTypes = () => http.get(endpoints.getServiceTypesList)
export const apiAddServiceType = (payload) => http.post(endpoints.addServiceType, payload)
export const apiUpdateServiceType = (id, payload) =>
http.patch(buildUrl(endpoints.updateServiceType, { id }), payload)
export const apiDeleteServiceType = (id) =>
http.delete(buildUrl(endpoints.deleteServiceType, { id }))
+30
View File
@@ -0,0 +1,30 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiAddAdminCertificate,
apiDeleteAdminCertificate,
apiGetAdminCertificates,
} from '@/services/api/admin-certificates'
export const adminCertificatesKeys = {
all: ['admin', 'certificates'],
list: (filters, pagination) => ['admin', 'certificates', 'list', filters, pagination],
}
export const useAdminCertificatesListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['admin', 'certificates', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetAdminCertificates({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
export const useAddAdminCertificateMutation = () =>
useMutation({ mutationFn: (payload) => apiAddAdminCertificate(payload) })
export const useDeleteAdminCertificateMutation = () =>
useMutation({ mutationFn: (id) => apiDeleteAdminCertificate(id) })
+10
View File
@@ -4,6 +4,7 @@ import {
apiAddAdminUser,
apiChangeAdminUserStatus,
apiDeleteAdminUser,
apiGetAdminUserEvaluation,
apiGetAdminUserRegisterData,
apiGetAdminUsers,
apiShowAdminUser,
@@ -16,6 +17,7 @@ export const adminUsersKeys = {
list: (filters, pagination) => ['admin', 'users', 'list', filters, pagination],
detail: (id) => ['admin', 'users', 'detail', id],
registerData: (id) => ['admin', 'users', 'register-data', id],
evaluation: (id) => ['admin', 'users', 'evaluation', id],
}
export const useAdminUsersListQuery = (filtersRef, paginationRef, options = {}) =>
@@ -59,6 +61,14 @@ export const useAdminUserRegisterDataQuery = (idRef, options = {}) =>
...options,
})
export const useAdminUserEvaluationQuery = (idRef, options = {}) =>
useQuery({
queryKey: ['admin', 'users', 'evaluation', idRef],
queryFn: () => apiGetAdminUserEvaluation(idRef.value),
select: (response) => response?.data ?? response,
...options,
})
export const useAddAdminUserMutation = () =>
useMutation({ mutationFn: (payload) => apiAddAdminUser(payload) })
+10 -1
View File
@@ -79,7 +79,16 @@ export const useGetRegistrationVideoQuery = (options = {}) =>
...options,
})
export const useUploadMediaMutation = () => useMutation({ mutationFn: apiUploadMedia })
// `mutateAsync` accepts either a raw FormData (existing call sites, no
// progress tracking) or `{ formData, onUploadProgress }` for callers that
// want to render upload progress (e.g. a video upload progress bar).
const uploadMediaMutationFn = (variables) => {
if (variables instanceof FormData) return apiUploadMedia(variables)
const { formData, onUploadProgress } = variables
return apiUploadMedia(formData, onUploadProgress)
}
export const useUploadMediaMutation = () => useMutation({ mutationFn: uploadMediaMutationFn })
// Register-data is a flat `{ field: value }` object; legacy responses were a
// `[{ key, value }]` list. Normalize both into the object shape.
+29
View File
@@ -0,0 +1,29 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiAddServiceType,
apiDeleteServiceType,
apiGetServiceTypes,
apiUpdateServiceType,
} from '@/services/api/service-types'
export const serviceTypesKeys = {
all: ['service-types'],
list: () => ['service-types', 'list'],
}
export const useServiceTypesListQuery = (options = {}) =>
useQuery({
queryKey: serviceTypesKeys.list(),
queryFn: () => apiGetServiceTypes(),
select: (response) => response?.data ?? response ?? [],
...options,
})
export const useAddServiceTypeMutation = () =>
useMutation({ mutationFn: (payload) => apiAddServiceType(payload) })
export const useUpdateServiceTypeMutation = () =>
useMutation({ mutationFn: ({ id, payload }) => apiUpdateServiceType(id, payload) })
export const useDeleteServiceTypeMutation = () =>
useMutation({ mutationFn: (id) => apiDeleteServiceType(id) })
@@ -18,6 +18,10 @@ export const useStudentCertificatesQuery = (filtersRef, paginationRef, options =
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
+12 -4
View File
@@ -8,15 +8,22 @@ export const studentCoursesKeys = {
detail: (id) => ['student', 'courses', 'detail', id],
}
const withCoverImage = (course) => ({
...course,
image: course.image ?? course.coverUrl ?? null,
})
const adaptCourseResponse = (response) => {
const course = response?.data ?? response
if (!course) return course
return {
...course,
image: course.image ?? course.coverUrl ?? null,
}
return withCoverImage(course)
}
const adaptCourseListResponse = (response) => ({
data: (response?.data?.items ?? response?.data ?? []).map((course) => withCoverImage(course)),
meta: response?.data?.meta ?? response?.meta,
})
export const useStudentCoursesListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'courses', 'list', filtersRef, paginationRef],
@@ -25,6 +32,7 @@ export const useStudentCoursesListQuery = (filtersRef, paginationRef, options =
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
select: adaptCourseListResponse,
...options,
})
@@ -0,0 +1,61 @@
import { ROLE_HOME_ROUTES } from '@/enums'
// Read by the WordPress site (madresebanoo.ir) to show/hide its "account"
// button and link it to the right dashboard, without ever seeing a real
// auth token. Must stay in sync with whatever the WP/Elementor side expects
// — see the implementation report for the exact contract.
const COOKIE_NAME = 'mb_auth_status'
const ROOT_DOMAIN = 'madresebanoo.ir'
const isOnRootDomain = () => {
const host = window.location.hostname
return host === ROOT_DOMAIN || host.endsWith(`.${ROOT_DOMAIN}`)
}
const cookieAttributes = () => {
const attrs = ['Path=/', 'SameSite=Lax']
// A Domain=.madresebanoo.ir cookie is only accepted by the browser when the
// current page is actually on that domain (prod). On local dev
// (127.0.0.1/localhost) we fall back to a host-only cookie so the sync
// logic is still exercised, instead of silently failing to set anything.
if (isOnRootDomain()) attrs.push(`Domain=.${ROOT_DOMAIN}`)
// Secure cookies are dropped by the browser on plain http, which local dev
// serves over. Only require it where it can actually work (prod, https).
if (window.location.protocol === 'https:') attrs.push('Secure')
return attrs
}
const writeCookie = (value, { expire = false } = {}) => {
const attrs = cookieAttributes()
attrs.push(expire ? 'Max-Age=0' : '') // no Max-Age on set = session cookie
// This cookie must be readable by a separate WordPress site with plain JS
// (no httpOnly), and needs Domain/SameSite attributes the CookieStore API
// doesn't uniformly support yet — document.cookie is the correct tool here.
// eslint-disable-next-line unicorn/no-document-cookie
document.cookie = [`${COOKIE_NAME}=${value}`, ...attrs].filter(Boolean).join('; ')
}
const dashboardPathFor = async (role) => {
try {
const { default: router } = await import('@/router')
const routeName = ROLE_HOME_ROUTES[role] || 'student-dashboard'
return router.resolve({ name: routeName }).path
} catch {
return '/dashboard'
}
}
/**
* Sets mb_auth_status to a non-sensitive JSON blob describing display state
* only: `{ loggedIn: true, dashboardPath }`. Never put the access token,
* password, or any credential in this cookie.
*/
export const syncAuthStatusCookie = async (role) => {
const dashboardPath = await dashboardPathFor(role)
const value = encodeURIComponent(JSON.stringify({ loggedIn: true, dashboardPath }))
writeCookie(value)
}
export const clearAuthStatusCookie = () => {
writeCookie('', { expire: true })
}
+22 -4
View File
@@ -2,31 +2,49 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { tokenService } from '@/services/api/token-service'
import { syncAuthStatusCookie, clearAuthStatusCookie } from '@/services/wp-sync/auth-status-cookie'
export const useAuthStore = defineStore('auth', () => {
const user = ref(tokenService.getUserInfo())
const isAuthenticated = computed(() => !!tokenService.getToken() && !!user.value)
const role = computed(() => {
// Full set of roles this user holds — access should be additive across all
// of them, never just their first/"primary" one.
const roles = computed(() => {
const u = user.value
if (!u) return null
if (Array.isArray(u.roles) && u.roles.length > 0) return u.roles[0]
return u.role || u.roleName || null
if (!u) return []
if (Array.isArray(u.roles) && u.roles.length > 0) return u.roles
if (u.role) return [u.role]
if (u.roleName) return [u.roleName]
return []
})
// Primary role, used only where a single default is needed (e.g. the
// landing route right after login) — never for access control.
const role = computed(() => roles.value[0] || null)
const setUser = (userData) => {
user.value = userData
tokenService.saveUserInfo(userData)
// Only a real, token-backed session should tell WordPress "logged in" —
// setUser is also used to stash a phone number mid-signup, before any
// token exists, so gate the display-state cookie on the actual token.
if (tokenService.hasToken()) {
syncAuthStatusCookie(role.value)
} else {
clearAuthStatusCookie()
}
}
const clearUser = () => {
user.value = null
tokenService.clearAll()
clearAuthStatusCookie()
}
return {
user,
role,
roles,
isAuthenticated,
setUser,
clearUser,