29 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
sajjadtalkhabi 81332abdb0 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m47s
2026-08-18 09:08:45 +03:30
sajjadtalkhabi e4a0eb463b Merge branch 'master' of ssh://git.madresebanoo.ir:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 2m0s
2026-08-17 16:59:27 +03:30
sajjadtalkhabi 20551fee63 fix: bugs 2026-08-17 16:59:24 +03:30
Server Migration 612a6e3dfe Merge remote-tracking branch 'origin/master'
Deploy banu-front / deploy (push) Successful in 2m42s
2026-08-16 17:07:32 +03:30
Server Migration 861773b05b fix: make admin media uploads reliable 2026-08-16 17:03:36 +03:30
sajjadtalkhabi b2f2f89181 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m26s
2026-08-15 19:42:33 +03:30
Server Migration b64f528c0d fix: complete registration frontend flow
Deploy banu-front / deploy (push) Successful in 1m40s
2026-08-15 17:28:40 +03:30
Server Migration d94a96b25e fix: resolve education dashboard interface issues 2026-08-15 13:06:49 +03:30
sajjadtalkhabi af0149d722 fix: media recorder
Deploy banu-front / deploy (push) Successful in 1m45s
2026-08-15 13:01:18 +03:30
sajjadtalkhabi fec1da6ec8 fix: localize Yup validation messages
Deploy banu-front / deploy (push) Successful in 1m50s
2026-08-14 21:08:52 +03:30
Server Migration fe08fc46fb fix: bind session audio URL correctly
Deploy banu-front / deploy (push) Successful in 1m20s
2026-08-04 18:45:39 +00:00
Server Migration 3d005859ab fix: remove old-server frontend references
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:43:12 +00:00
Server Migration f13eb9bbb4 build: use reliable npm registry
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:38:11 +00:00
Server Migration fb0e4aa7f4 chore: bind frontend deployment to loopback
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:43 +00:00
Server Migration 94ac69d96a chore: migrate deployment to madresebanoo.ir
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:19 +00:00
sajjadtalkhabi 39b295037e fix: bugs
Deploy banu-front / deploy (push) Successful in 2m18s
2026-08-01 10:34:21 +03:30
sajjadtalkhabi 31d100861b fix: bugs
Deploy banu-front / deploy (push) Failing after 23s
2026-07-30 18:56:55 +03:30
92 changed files with 2812 additions and 478 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
run: |
BUILD_DIR="$(mktemp -d -t banu-front-XXXXXX)"
echo "BUILD_DIR=$BUILD_DIR" >> "$GITHUB_ENV"
git clone http://109.122.252.86:3000/front-end/banu-front.git "$BUILD_DIR"
git clone http://127.0.0.1:3000/front-end/banu-front.git "$BUILD_DIR"
git -C "$BUILD_DIR" checkout "${{ github.sha }}"
- name: Build Docker image
@@ -37,7 +37,7 @@ jobs:
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
-p "$PORT:80" \
-p "127.0.0.1:$PORT:80" \
"$IMAGE:latest"
- name: Prune dangling images
+1 -1
View File
@@ -8,7 +8,7 @@ COPY package.json package-lock.json ./
# RUN npm config set registry https://mirror-npm.runflare.com
# RUN npm config set strict-ssl false
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global
RUN npm config set registry https://registry.npmjs.org/ --global
RUN npm config set strict-ssl false
RUN npm ci
+1 -1
View File
@@ -6,7 +6,7 @@ APP_DIR="/root/banu-front"
IMAGE="banu-front"
CONTAINER="banu-front"
PORT="8080"
VITE_API_BASE_URL="http://109.122.252.86/api"
VITE_API_BASE_URL="//api.madresebanoo.ir/api"
echo "==> Pulling latest code..."
if [ -d "$APP_DIR/.git" ]; then
+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>
+16 -6
View File
@@ -7,7 +7,9 @@
</button>
</div>
<div class="header-block__logo">
<img :src="logo" alt="logo" />
<a :href="MAIN_SITE_URL">
<img :src="logo" alt="logo" />
</a>
</div>
<div class="header-block__title">
<slot>
@@ -49,9 +51,10 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUIStore } from '@/store/ui'
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery'
import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
@@ -65,7 +68,7 @@ defineProps({
const ui = useUIStore()
const router = useRouter()
const { clearSession } = useAuth()
const { role, clearSession } = useAuth()
const logoutMutation = useLogoutMutation()
const logo = gallery.authLogo
@@ -98,8 +101,10 @@ const onLogout = async () => {
}
}
const userMenuItems = [
{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile },
const userMenuItems = computed(() => [
...(role.value === 'student'
? [{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile }]
: []),
{
key: 'logout',
label: 'خروج',
@@ -107,7 +112,7 @@ const userMenuItems = [
color: 'var(--color-primary)',
onClick: onLogout,
},
]
])
const iconList = [
// { icon: 'chat', action: () => {} },
@@ -147,7 +152,12 @@ const iconList = [
display: none;
}
a {
display: block;
}
img {
display: block;
margin: 0 auto;
height: 3.5rem;
object-fit: contain;
+18 -2
View File
@@ -3,9 +3,12 @@
<label class="file-uploader__dropzone-wrap">
<div
class="file-uploader__dropzone"
:class="{ 'file-uploader__dropzone--active': isDragging }"
:class="{
'file-uploader__dropzone--active': isDragging,
'file-uploader__dropzone--disabled': disabled,
}"
@dragover.prevent
@dragenter="isDragging = true"
@dragenter="onDragEnter"
@dragleave="isDragging = false"
@drop="handleDrop"
>
@@ -19,6 +22,7 @@
class="file-uploader__input"
:accept="accept"
:multiple="multiple"
:disabled="disabled"
@change="handleFileSelect"
/>
</div>
@@ -75,6 +79,7 @@ const props = defineProps({
multiple: { type: Boolean, default: true },
maxFiles: { type: Number, default: 10 },
maxSize: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
@@ -82,6 +87,7 @@ const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
const isDragging = ref(false)
const handleFileSelect = (event) => {
if (props.disabled) return
const files = [...event.target.files]
emitFiles(files)
event.target.value = ''
@@ -90,10 +96,15 @@ const handleFileSelect = (event) => {
const handleDrop = (event) => {
event.preventDefault()
isDragging.value = false
if (props.disabled) return
const files = [...event.dataTransfer.files]
emitFiles(files)
}
const onDragEnter = () => {
if (!props.disabled) isDragging.value = true
}
const emitFiles = (files) => {
if (props.modelValue.length + files.length > props.maxFiles) {
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
@@ -155,6 +166,11 @@ const formatFileSize = (bytes) => {
&--active {
border-color: var(--color-primary);
}
&--disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
&__upload-icon {
+5 -1
View File
@@ -283,6 +283,7 @@ onBeforeUnmount(() => unmountFloating())
&__value {
flex: 1;
min-width: 0;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
@@ -371,12 +372,15 @@ onBeforeUnmount(() => unmountFloating())
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 1.5rem;
background: transparent;
text-align: right;
font-family: var(--font-family-fa);
font-weight: 400;
line-height: 1.5;
cursor: pointer;
transition: background-color 0.15s;
+20 -1
View File
@@ -19,6 +19,7 @@ export const fields = {
seminaryCode: 'کد حوزوی',
seminaryServiceCenterCode: 'کد مرکز خدمات حوزه',
childrenCount: 'تعداد فرزند',
marriageStatus: 'وضعیت تأهل',
birthDate: 'تاریخ تولد',
birthday: 'تاریخ تولد',
@@ -32,12 +33,17 @@ export const fields = {
address: 'آدرس',
title: 'عنوان',
subject: 'موضوع',
category: 'نوع خدمت',
note: 'یادداشت',
description: 'توضیحات',
bio: 'بیوگرافی',
message: 'پیام',
content: 'محتوا',
rejectionReason: 'دلیل رد',
reason: 'دلیل رد',
adminNote: 'یادداشت مدیر',
reviewerNote: 'یادداشت ارزیاب',
activitySummary: 'خلاصهٔ فعالیت',
avatar: 'تصویر پروفایل',
@@ -53,12 +59,15 @@ export const fields = {
startDate: 'تاریخ شروع',
endDate: 'تاریخ پایان',
deadline: 'مهلت تحویل',
fromDate: 'از تاریخ',
toDate: 'تا تاریخ',
capacity: 'ظرفیت',
minCapacity: 'حداقل ظرفیت',
maxCapacity: 'حداکثر ظرفیت',
score: 'نمره',
minimumScore: 'حد نصاب قبولی',
order: 'ترتیب',
priority: 'اولویت',
@@ -71,22 +80,32 @@ export const fields = {
termId: 'ترم',
courseId: 'دوره',
sessionId: 'جلسه',
missionaryRequestId: 'درخواست مبلغ',
sessionType: 'نوع جلسه',
type: 'نوع جلسه',
link: 'لینک جلسه',
teacherId: 'مدرس',
studentIds: 'دانش‌آموزان',
prerequisiteCourseIds: 'دوره‌های پیش‌نیاز',
contentMediaId: 'فایل محتوای دوره',
coverMediaId: 'تصویر جلد',
imageId: 'تصویر',
educationStatus: 'وضعیت تحصیلی',
fieldOfStudy: 'رشتهٔ تحصیلی',
seminaryLevel: 'مقطع حوزه',
universityLevel: 'مقطع دانشگاهی',
universityName: 'نام حوزه علمیه / دانشگاه',
degree: 'مقطع تحصیلی',
jobTitle: 'عنوان شغل',
organizationName: 'نام سازمان',
hijabApproach: 'رویکرد حجاب',
prerequisites: 'پیش‌نیازها',
specializedTopics: 'موضوعات تخصصی',
propagationPlatforms: 'بسترهای تبلیغ',
propagationMethodDescription: 'شرح روش تبلیغ',
propagationExperienceYears: 'سنوات تجربهٔ تبلیغ',
propagationExperienceYears: 'سابقه فعالیت تبلیغی',
relevantCertificates: 'گواهی‌نامه‌های مرتبط',
onlinePlatformDetails: 'جزئیات بستر آنلاین',
+1 -3
View File
@@ -1,7 +1,5 @@
import { string } from 'yup'
import { fields } from '@/constants/fields'
export const phoneNumberRule = string()
.required()
.matches(/^09\d{9}$/, ({ path }) => `${fields[path] || path} نامعتبر است`)
.matches(/^09\d{9}$/, 'شماره موبایل باید با ۰۹ شروع شود و ۱۱ رقم باشد')
+15 -4
View File
@@ -53,6 +53,8 @@ export const USER_STATUS = Object.freeze({
export const MARITAL_STATUS = Object.freeze({
single: 'مجرد',
married: 'متاهل',
divorced: 'مطلقه',
widowed: 'بیوه',
})
export const GENDER = Object.freeze({
@@ -138,9 +140,15 @@ export const COURSE_CONTENT_TYPE = Object.freeze({
})
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
video: 'video/*',
voice: 'audio/*',
text: '.pdf,.doc,.docx,.txt',
video: '.mp4,.webm,video/mp4,video/webm',
voice: '.mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm',
text: '.pdf,application/pdf',
})
export const COURSE_CONTENT_TYPE_MAX_SIZE = Object.freeze({
video: 300 * 1024 * 1024,
voice: 50 * 1024 * 1024,
text: 50 * 1024 * 1024,
})
export const SESSION_TYPE = Object.freeze({
@@ -212,8 +220,11 @@ export const MISSIONARY_REQUEST_STATUS = Object.freeze({
rejected: 'رد شده',
})
export const MAIN_SITE_URL = 'https://madresebanoo.ir/'
// 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.
// page. Roles not listed here fall back to the student dashboard. Approved
// users of every role except admin bypass this and land on MAIN_SITE_URL.
export const ROLE_HOME_ROUTES = Object.freeze({
admin: 'admin-dashboard',
counselor: 'counselor-dashboard',
@@ -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 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">
<div v-if="!isActive" class="course-item__status">
<span class="course-item__status-badge">
<span class="course-item__status-dot" />
غیرفعال
</span>
</div>
<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">
@@ -33,7 +33,7 @@
<SelectField
v-model="form.teacherId"
name="teacherId"
label="استاد"
label="استاد (اختیاری)"
:options="teacherOptions"
option-label="name"
option-value="id"
@@ -251,12 +251,12 @@ watch(
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation()
const coverUploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const response = await coverUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
@@ -270,15 +270,28 @@ const onImageError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseMutation()
const updateMutation = useUpdateAdminCourseMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
const submitting = computed(
() =>
coverUploadMutation.isPending.value ||
addMutation.isPending.value ||
updateMutation.isPending.value
)
const onSubmit = async () => {
if (coverUploadMutation.isPending.value) {
toast.info('لطفاً تا پایان بارگذاری تصویر دوره منتظر بمانید.')
return
}
const { isValid, payload } = await validate(form.value)
if (!isValid) return
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: courseId.value, payload })
} else {
await addMutation.mutateAsync(payload)
try {
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: courseId.value, payload })
} else {
await addMutation.mutateAsync(payload)
}
} catch {
return
}
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
router.push({ name: 'admin-courses' })
@@ -69,7 +69,12 @@ const courseIcon = gallery.courseIcon
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
const onAdd = () => {
router.push({ name: 'admin-add-course' }).catch(() => {})
router
.push({
name: 'admin-add-course',
query: routeTermId.value ? { termId: routeTermId.value } : undefined,
})
.catch(() => {})
}
const tabs = [
+14 -5
View File
@@ -2,10 +2,15 @@ import { array, boolean, mixed, number, object, string } from 'yup'
export const courseSchema = object().shape({
title: string().required().min(3).max(255),
teacherId: mixed().required(),
capacity: number().typeError('ظرفیت باید عدد باشد').required().min(1),
// The backend intentionally allows courses without an assigned teacher.
teacherId: mixed().nullable().notRequired(),
capacity: number()
.typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
prerequisiteCourseIds: array().nullable().default([]),
contentMediaId: number().nullable().notRequired(),
coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
description: string().nullable().notRequired(),
termId: mixed().required(),
isActive: boolean().nullable().notRequired(),
@@ -14,7 +19,11 @@ export const courseSchema = object().shape({
export const offeredCourseSchema = object().shape({
termId: string().required(),
title: string().required(),
capacity: string().required(),
imageId: string().nullable().notRequired(),
capacity: number()
.typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
isActive: boolean().nullable().notRequired(),
})
@@ -266,7 +266,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
+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
@@ -95,11 +108,23 @@
:accept="contentAccept"
:multiple="false"
:max-files="1"
:disabled="!form.contentType"
:max-size="contentMaxSize"
:disabled="!form.contentType || contentUploadMutation.isPending.value"
@select="onContentSelect"
@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>
@@ -162,7 +187,12 @@ import TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
import {
COURSE_CONTENT_TYPE,
COURSE_CONTENT_TYPE_ACCEPT,
COURSE_CONTENT_TYPE_MAX_SIZE,
SESSION_TYPE,
} from '@/enums'
import {
adminSessionsKeys,
useAddAdminSessionMutation,
@@ -175,6 +205,7 @@ const router = useRouter()
const queryClient = useQueryClient()
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
const presetCourseId = computed(() => (route.query.courseId ? Number(route.query.courseId) : null))
const isEditMode = computed(() => !!sessionId.value)
const sessionTypeOptions = [
@@ -189,10 +220,11 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
const form = ref({
title: '',
courseId: '',
courseId: presetCourseId.value ?? '',
contentType: '',
type: '',
link: '',
embedCode: '',
description: '',
})
@@ -200,8 +232,12 @@ 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(
() => COURSE_CONTENT_TYPE_MAX_SIZE[form.value.contentType] || 50 * 1024 * 1024
)
const { validate, validateAt, errors } = useYup(sessionSchema)
@@ -228,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, {
@@ -257,6 +296,7 @@ watch(
contentType,
type: session.type ?? '',
link: session.link ?? '',
embedCode: session.embedCode ?? '',
description: session.description ?? '',
}
coverMediaId.value = coverMedia?.id ?? null
@@ -276,7 +316,8 @@ watch(
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation()
const contentUploadMutation = useUploadMediaMutation()
const coverUploadMutation = useUploadMediaMutation()
const purposeForContentType = (contentType) => {
if (contentType === 'video') return 'video'
@@ -287,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 uploadMutation.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 }]
@@ -313,7 +361,7 @@ const onContentError = (msg) => toast.error(msg)
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
const response = await uploadMutation.mutateAsync(formData)
const response = await coverUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload }
coverMediaId.value = payload?.id ?? null
@@ -331,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,
}
@@ -346,16 +395,34 @@ const buildPayload = (values) => {
const addMutation = useAddAdminSessionMutation()
const updateMutation = useUpdateAdminSessionMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
const submitting = computed(
() =>
contentUploadMutation.isPending.value ||
coverUploadMutation.isPending.value ||
addMutation.isPending.value ||
updateMutation.isPending.value
)
const onSubmit = async () => {
if (contentUploadMutation.isPending.value || coverUploadMutation.isPending.value) {
toast.info('لطفاً تا پایان بارگذاری محتوای جلسه منتظر بمانید.')
return
}
const { isValid } = await validate(form.value)
if (!isValid) return
if (form.value.contentType && contentMediaId.value == null) {
toast.error('برای نوع محتوای انتخاب‌شده، فایل جلسه را بارگذاری کنید.')
return
}
const payload = buildPayload(form.value)
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: sessionId.value, payload })
} else {
await addMutation.mutateAsync(payload)
try {
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: sessionId.value, payload })
} else {
await addMutation.mutateAsync(payload)
}
} catch {
return
}
// The list query is inactive while we're on the form page, and the global
// `refetchOnMount: false` means navigating back won't refetch a merely-stale
@@ -448,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;
}
@@ -109,7 +109,12 @@ const onFilterApply = () => resetPagination()
const onFilterReset = () => resetPagination()
const onAdd = () => {
router.push({ name: 'admin-add-session' }).catch(() => {})
router
.push({
name: 'admin-add-session',
query: routeCourseId.value ? { courseId: routeCourseId.value } : undefined,
})
.catch(() => {})
}
const onEdit = (session) => {
@@ -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" />
@@ -12,23 +12,18 @@
<div class="term-item__dates">
<div class="term-item__date">
<span class="term-item__date-label">تاریخ شروع:</span>
<span class="term-item__date-value">{{ startDate || '—' }}</span>
<span class="term-item__date-label">تاریخ ایجاد:</span>
<span class="term-item__date-value">{{ createdDate || '—' }}</span>
</div>
<div class="term-item__date">
<span class="term-item__date-label">تاریخ پایان:</span>
<span class="term-item__date-value">{{ endDate || '—' }}</span>
</div>
</div>
<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">
<div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge">
<span class="term-item__status-dot" />
غیرفعال
</span>
</div>
<ToggleSwitch
:model-value="!!term.isActive"
@update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })"
@@ -80,8 +75,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details'])
const startDate = computed(() => formatJalaaliDate(props.term.startsAt) || '')
const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || '')
</script>
<style lang="scss" scoped>
@@ -171,10 +165,6 @@ const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
font-size: 0.75rem;
}
&__status {
flex: 0 0 auto;
}
&__status-badge {
display: inline-flex;
align-items: center;
@@ -196,6 +186,7 @@ const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
@@ -163,8 +163,6 @@ const invalidate = () => {
}
const onAttach = async (template) => {
console.log(template)
if (!termId.value) return
pendingId.value = template.id
try {
@@ -21,12 +21,8 @@
<div class="term-details__overview-grid">
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
<LineInfoBlock
title="تاریخ شروع"
:numeric-desc="formatJalaaliDate(term.startsAt) || '-'"
/>
<LineInfoBlock
title="تاریخ پایان"
:numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
title="تاریخ ایجاد"
:numeric-desc="formatJalaaliDate(term.createdAt) || '-'"
/>
<LineInfoBlock title="تعداد طلبه‌ها" :numeric-desc="term.studentsCount ?? 0" />
<LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" />
@@ -30,24 +30,6 @@
</template>
</TextField>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.startsAt"
name="startsAt"
label="تاریخ شروع"
:min="todayIso"
:error="errors.startsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.endsAt"
name="endsAt"
label="تاریخ پایان"
:min="todayIso"
:error="errors.endsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<TextField
v-model="form.minimumScore"
@@ -136,7 +118,6 @@ import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminTermsKeys,
@@ -152,14 +133,10 @@ const queryClient = useQueryClient()
const termId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEditMode = computed(() => !!termId.value)
const todayIso = new Date().toISOString()
const form = ref({
title: '',
description: '',
isActive: true,
startsAt: '',
endsAt: '',
minimumScore: '',
score: '',
coverMediaId: null,
@@ -181,8 +158,6 @@ watch(
title: term.title || '',
description: term.description || '',
isActive: term.isActive ?? true,
startsAt: term.startsAt || '',
endsAt: term.endsAt || '',
minimumScore: term.minimumScore ?? '',
score: term.score ?? '',
}
+1 -3
View File
@@ -4,11 +4,9 @@ export const termSchema = object().shape({
title: string().required().min(3),
description: string().nullable().notRequired(),
isActive: boolean().default(true),
startsAt: string().required(),
endsAt: string().required(),
minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
score: number().typeError('کل نمره آزمون باید عدد باشد').required(),
coverMediaId: number().nullable().notRequired(),
coverMediaId: number().typeError('شناسه تصویر جلد باید عدد باشد').nullable().notRequired(),
})
export const addTermStudentSchema = object().shape({
@@ -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) => ({
key: `role-${r.value}`,
label: `تغییر به ${r.label}`,
icon: 'arrows-clockwise',
onClick: () => onChangeRole(r.value),
}))
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: 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,
+3 -3
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,
@@ -20,7 +20,7 @@ const baseUserShape = {
provinceId: string().nullable().notRequired(),
cityId: string().nullable().notRequired(),
address: string().nullable().notRequired(),
avatarMediaId: number().nullable().notRequired(),
avatarMediaId: number().typeError('شناسه تصویر پروفایل باید عدد باشد').nullable().notRequired(),
}
export const userAddSchema = object().shape({
@@ -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>
@@ -89,9 +89,9 @@
<script setup>
import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -124,6 +124,8 @@ const universityLevelOptions = Object.entries(UNIVERSITY_LEVEL).map(([value, lab
const form = ref({ ...store.educational })
watch(form, (value) => store.updateSection('educational', value), { deep: true })
const { validate, validateAt, errors } = useYup(educationalInformationSchema)
const saveRegisterMutation = useSaveRegisterDataMutation()
@@ -136,8 +138,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
</script>
<style lang="scss" scoped>
@@ -76,9 +76,9 @@
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useUploadMediaMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
@@ -170,9 +170,6 @@ const onStart = async () => {
try {
const constraints = props.kind === 'video' ? { audio: true, video: true } : { audio: true }
mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
chunks = []
recorderType = pickRecorderType(props.kind)
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
@@ -180,8 +177,16 @@ const onStart = async () => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => handleStop()
mediaRecorder.start()
isRecording.value = true
// The live video is conditionally rendered by isRecording, so wait until
// Vue mounts it before attaching the camera stream.
await nextTick()
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
mediaRecorder.start()
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
@@ -230,16 +230,15 @@ import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { convertToJalali } from '@/utils/date-utils'
import { useQueryClient } from '@tanstack/vue-query'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
import { personalInformationSchema } from '@/features/auth/schema/student-register'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
@@ -269,15 +268,14 @@ const maritalStatusOptions = Object.entries(MARITAL_STATUS).map(([value, label])
}))
const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label }))
const maxBirthDate = computed(() => {
const maxBirthDate = (() => {
const d = new Date()
d.setFullYear(d.getFullYear() - 15)
try {
return convertToJalali(d.toISOString())
} catch {
return d.toISOString()
}
})
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
})()
const initialPhone = ref(user.value?.phone || store.personal.phone || '')
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
@@ -305,6 +303,8 @@ const form = ref({
avatarId: store.personal.avatarId || '',
})
watch(form, (value) => store.updateSection('personal', value), { deep: true })
const avatar = ref(initialAvatar)
const { validate, validateAt, errors } = useYup(personalInformationSchema)
@@ -316,8 +316,8 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value,
})
const onProvinceChange = () => {
provinceIdRef.value = form.value.provinceId
const onProvinceChange = (provinceId) => {
provinceIdRef.value = provinceId
form.value.cityId = null
}
@@ -382,8 +382,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
</script>
<style lang="scss" scoped>
@@ -111,10 +111,10 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { professionalInformationSchema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -150,6 +150,8 @@ const form = ref({
onlinePlatformDetails: seedOnline,
})
watch(form, (value) => store.updateSection('professional', value), { deep: true })
const hasOther = computed(() => form.value.propagationPlatforms.includes('other'))
const hasOnline = computed(() => form.value.propagationPlatforms.includes('online'))
@@ -195,8 +197,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
</script>
<style lang="scss" scoped>
@@ -7,33 +7,57 @@
<div class="sidebar-steps__logo">
<img :src="logoWhite" alt="مدرسه بانو" />
</div>
<ul class="sidebar-steps__list">
<li
v-for="tab in tabs"
:key="tab.key"
class="sidebar-steps__item"
:class="{ 'sidebar-steps__item--active': activeTab === tab.key }"
@click="emit('update:active-tab', tab.key)"
>
<SvgIcon :name="tab.icon" :size="22" color="#fff" />
<ul ref="stepsList" class="sidebar-steps__list scrollbar-thin">
<li v-for="tab in tabs" :key="tab.step" class="sidebar-steps__item">
<button
type="button"
class="sidebar-steps__button"
:class="{
'sidebar-steps__button--active': activeStep === tab.step,
'sidebar-steps__button--completed': tab.completed,
}"
:disabled="tab.disabled"
:aria-label="`${tab.title}، مرحله ${tab.step}`"
:aria-current="activeStep === tab.step ? 'step' : undefined"
@click="emit('select-step', tab.step)"
>
<span class="sidebar-steps__icon">
<SvgIcon :name="tab.completed ? 'check-square' : tab.icon" :size="20" color="#fff" />
</span>
<span class="sidebar-steps__label">{{ tab.title }}</span>
<span class="sidebar-steps__number">{{ tab.step }}</span>
</button>
</li>
</ul>
<SidebarLogout color="#fff" stacked />
<div class="sidebar-steps__logout">
<SidebarLogout color="#fff" />
</div>
</div>
</aside>
</template>
<script setup>
import { gallery } from '@/utils/gallery'
import { nextTick, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({
const props = defineProps({
tabs: { type: Array, required: true },
activeTab: { type: String, default: '' },
activeStep: { type: Number, default: 1 },
})
const emit = defineEmits(['update:active-tab'])
const emit = defineEmits(['select-step'])
const stepsList = ref(null)
const scrollActiveStepIntoView = async () => {
await nextTick()
stepsList.value
?.querySelector('[aria-current="step"]')
?.scrollIntoView({ block: 'nearest', inline: 'center' })
}
watch(() => props.activeStep, scrollActiveStepIntoView, { immediate: true })
const logoWhite = gallery.logoWhite
const logoPinkishRed = gallery.logoPinkishRed
@@ -67,14 +91,11 @@ const logoPinkishRed = gallery.logoPinkishRed
gap: 1rem;
@media (min-width: 1024px) {
// Fill the full-height column only on desktop; on mobile the panel is a
// content-height bar, so height:100% would overflow past the logo above it
// and collide with the page heading.
height: 100%;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 1.25rem 0.75rem;
align-items: stretch;
gap: 1rem;
padding: 1.25rem 0.875rem;
border-radius: 1.5rem;
}
}
@@ -101,30 +122,127 @@ const logoPinkishRed = gallery.logoPinkishRed
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
justify-content: flex-start;
gap: 0.375rem;
flex: 1;
min-width: 0;
overflow-x: auto;
@media (min-width: 1024px) {
flex-direction: column;
justify-content: flex-start;
gap: 1.25rem;
padding-top: 1rem;
gap: 0.25rem;
padding-top: 0.5rem;
overflow: hidden auto;
}
}
&__item {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s ease;
flex: 0 0 auto;
min-width: 0;
@media (min-width: 1024px) {
width: 100%;
}
}
&__button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
gap: 0.5rem;
width: 2.75rem;
min-height: 2.75rem;
padding: 0.375rem;
border: 1px solid transparent;
border-radius: 0.875rem;
background: transparent;
color: #fff;
cursor: pointer;
opacity: 0.62;
transition: opacity 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&--active,
&:hover {
@media (min-width: 1024px) {
justify-content: flex-start;
width: 100%;
padding: 0.5rem 0.625rem;
}
&:hover:not(:disabled),
&--active {
opacity: 1;
background: rgba(255, 255, 255, 14%);
border-color: rgba(255, 255, 255, 22%);
}
&--completed {
&:not(.sidebar-steps__button--active) {
opacity: 0.85;
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.32;
}
}
&__icon {
display: inline-flex;
flex-shrink: 0;
}
&__label {
display: none;
min-width: 0;
overflow: hidden;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
@media (min-width: 1024px) {
display: block;
}
}
&__number {
display: none;
margin-inline-start: auto;
font-family: var(--font-family-number-fa);
font-size: 0.75rem;
opacity: 0.75;
@media (min-width: 1024px) {
display: inline;
}
}
&__logout {
flex: 0 0 auto;
:deep(.sidebar-logout) {
padding: 0.25rem;
}
:deep(.sidebar-logout__labels) {
display: none;
}
@media (min-width: 1024px) {
border-top: 1px solid rgba(255, 255, 255, 20%);
padding-top: 0.5rem;
:deep(.sidebar-logout) {
justify-content: flex-start;
width: 100%;
padding: 0.5rem 0.625rem;
}
:deep(.sidebar-logout__labels) {
display: flex;
}
}
}
}
@@ -43,7 +43,7 @@
</div>
<div class="skill-five__divider" />
<StepActions @back="store.goToPrevStep()" />
<StepActions :loading="saveRegisterMutation.isPending.value" @back="store.goToPrevStep()" />
</form>
</template>
@@ -51,9 +51,10 @@
import useYup from '@/composables/useYup'
import { STUDENT_REGISTRATION } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart5Schema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -69,17 +70,19 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const form = ref({ ...store.skill5 })
watch(form, (value) => store.updateSection('skill5', value), { deep: true })
const { validate, validateAt, errors } = useYup(skillAssessmentPart5Schema)
const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
await saveRegisterMutation.mutateAsync(payload)
store.skill5 = { ...store.skill5, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
</script>
<style lang="scss" scoped>
@@ -10,7 +10,6 @@
purpose="voice"
:max-seconds="180"
:error="errors.faithProductionAudio"
@uploaded="onUploaded"
/>
</div>
<div class="skill-six__content-col">
@@ -23,7 +22,8 @@
<video :src="videoUrl" controls playsinline class="skill-six__video" />
</div>
<div v-else class="skill-six__video-placeholder">
<p>ویدیویی در دسترس نیست</p>
<p>ویدیوی راهنما هنوز بارگذاری نشده است.</p>
<p>تا زمان انتشار ویدیو، ارسال پاسخ در این مرحله اختیاری است.</p>
</div>
</div>
</div>
@@ -34,8 +34,8 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -55,10 +55,12 @@ const form = ref({
const { validate, errors } = useYup(skillAssessmentPart6Schema)
const onUploaded = (value) => {
watch(audio, (value) => {
form.value.faithProductionAudio = value
form.value.faithProductionId = value?.uploadId || ''
}
})
watch(form, (value) => store.updateSection('skill6', value), { deep: true })
const videoUrl = computed(() => store.registrationMedia.faithProductionVideoUrl || '')
@@ -72,8 +74,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
store.goToNextStep()
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
</script>
<style lang="scss" scoped>
@@ -107,8 +107,8 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
&__intro {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
font-size: 0.9375rem;
line-height: 1.7;
margin: 0 0 0.75rem;
text-align: justify;
@@ -130,11 +130,16 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
&__video-placeholder {
border-radius: 1rem;
background: #f3f4f6;
color: var(--color-thd-gray);
color: #666;
padding: 2rem;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 400;
p + p {
margin-top: 0.5rem;
}
}
&__divider {
@@ -9,7 +9,6 @@
context="verification"
:max-seconds="180"
:error="errors.leaderMessageVideo"
@uploaded="onUploaded"
/>
</div>
<div class="skill-seven__content-col">
@@ -22,7 +21,8 @@
<video :src="videoUrl" controls playsinline class="skill-seven__video" />
</div>
<div v-else class="skill-seven__video-placeholder">
<p>ویدیویی در دسترس نیست</p>
<p>ویدیوی راهنما هنوز بارگذاری نشده است.</p>
<p>تا زمان انتشار ویدیو، ارسال پاسخ در این مرحله اختیاری است.</p>
</div>
</div>
</div>
@@ -37,9 +37,9 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -59,10 +59,12 @@ const form = ref({
const { validate, errors } = useYup(skillAssessmentPart7Schema)
const onUploaded = (value) => {
watch(video, (value) => {
form.value.leaderMessageVideo = value
form.value.leaderMessageId = value?.uploadId || ''
}
})
watch(form, (value) => store.updateSection('skill7', value), { deep: true })
const videoUrl = computed(() => store.registrationMedia.leaderMessageVideoUrl || '')
@@ -80,8 +82,6 @@ const onSubmit = async () => {
toast.error(error?.response?.data?.message || 'عملیات با خطا مواجه شد')
}
}
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
</script>
<style lang="scss" scoped>
@@ -115,8 +115,8 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
&__intro {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
font-size: 0.9375rem;
line-height: 1.7;
margin: 0 0 0.75rem;
text-align: justify;
@@ -138,11 +138,16 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
&__video-placeholder {
border-radius: 1rem;
background: #f3f4f6;
color: var(--color-thd-gray);
color: #666;
padding: 2rem;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 400;
p + p {
margin-top: 0.5rem;
}
}
&__divider {
@@ -42,9 +42,9 @@ import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -73,6 +73,8 @@ const form = ref({
[props.descField]: seed[props.descField] || '',
})
watch(form, (value) => store.updateSection(props.storeKey, value), { deep: true })
const hasOther = computed(() => form.value[props.selectField] === 'other')
const onSelect = () => {
@@ -91,8 +93,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(props.step)
store.goToNextStep()
}
store.updateCurrentStep(props.step)
</script>
<style lang="scss" scoped>
@@ -115,8 +115,8 @@ store.updateCurrentStep(props.step)
p {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
font-size: 0.9375rem;
line-height: 1.7;
color: #a7a7a7;
text-align: justify;
@@ -45,21 +45,27 @@ const emit = defineEmits(['back'])
<style lang="scss" scoped>
.step-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
&__back {
flex: 1 1 60%;
flex: 1 1 10rem;
min-width: 0;
display: flex;
justify-content: flex-start;
}
&__next {
flex: 1 1 40%;
flex: 1 1 12rem;
min-width: 0;
max-width: 16rem;
margin-inline-start: auto;
@media (min-width: 768px) {
flex: 0 0 16rem;
@media (max-width: 767px) {
order: -1;
flex-basis: 100%;
max-width: none;
}
}
@@ -67,10 +73,17 @@ const emit = defineEmits(['back'])
color: #969696;
padding: 0 1rem;
width: fit-content;
font-weight: 500;
}
&__next-btn {
width: 100%;
min-height: 2.65rem;
font-weight: 500;
}
:deep(.base-button__text) {
font-size: 0.9375rem;
}
}
</style>
@@ -1,6 +1,9 @@
import { ROLE_HOME_ROUTES } from '@/enums'
import { useAuthStore } from '@/store/auth'
import { useRoute, useRouter } from 'vue-router'
import { MAIN_SITE_URL, ROLE_HOME_ROUTES } from '@/enums'
const shouldLandOnMainSite = (auth) =>
!auth.roles.includes('admin') && auth.user?.status === 'approved'
export const useRedirectAfterLogin = () => {
const router = useRouter()
@@ -13,6 +16,10 @@ export const useRedirectAfterLogin = () => {
router.push(redirect).catch(() => router.push('/'))
return
}
if (shouldLandOnMainSite(auth)) {
window.location.href = MAIN_SITE_URL
return
}
const target = { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' }
router.push(target).catch(() => router.push('/'))
}
+169 -46
View File
@@ -2,16 +2,37 @@
<div class="student-register">
<div class="student-register__container">
<aside class="student-register__sidebar">
<SideBarSteps :tabs="tabs" :active-tab="activeTab" @update:active-tab="goToStep" />
<SideBarSteps :tabs="tabs" :active-step="store.currentStep" @select-step="goToStep" />
</aside>
<main class="student-register__main">
<header class="student-register__heading">
<div class="student-register__step-meta">
<span>مرحله {{ currentStepNumber }} از {{ totalSteps }}</span>
<span>{{ progressPercent }}٪</span>
</div>
<div class="student-register__progress" aria-hidden="true">
<span :style="{ width: `${progressPercent}%` }" />
</div>
<h1 class="student-register__title">{{ stepLabel.title }}</h1>
<p class="student-register__desc">{{ stepLabel.desc }}</p>
</header>
<component :is="currentComponent" />
<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>
<component :is="currentComponent" v-else />
</main>
</div>
@@ -20,13 +41,14 @@
</template>
<script setup>
import { useAuthStore } from '@/store/auth'
import useModal from '@/composables/useModal'
import { computed, markRaw, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums'
import { useGetRegisterDataQuery } from '@/services/query/auth'
import SideBarSteps from '@/features/auth/components/studentRegister/SideBarSteps.vue'
import SuccessModal from '@/features/auth/components/studentRegister/SuccessModal.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useGetRegisterDataQuery, useGetRegistrationVideoQuery } from '@/services/query/auth'
import PersonalInformation from '@/features/auth/components/studentRegister/PersonalInformation.vue'
import SkillAssessmentPart1 from '@/features/auth/components/studentRegister/SkillAssessmentPart1.vue'
import SkillAssessmentPart2 from '@/features/auth/components/studentRegister/SkillAssessmentPart2.vue'
@@ -39,17 +61,24 @@ 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 } = useGetRegisterDataQuery()
const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery()
watch(
registerData,
(value) => {
if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value)
},
{ immediate: true }
{ immediate: true, flush: 'sync' }
)
const { data: registrationMedia } = useGetRegistrationVideoQuery({ retry: false })
watch(registrationMedia, (value) => store.setRegistrationMedia(value), {
immediate: true,
flush: 'sync',
})
const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: markRaw(PersonalInformation),
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: markRaw(EducationalInformation),
@@ -63,26 +92,6 @@ const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: markRaw(SkillAssessmentPart7),
}
const STEP_TO_TAB = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: 'personal',
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: 'educational',
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: 'professional',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: 'skill',
}
const TAB_TO_STEP = {
personal: STUDENT_REGISTRATION.PERSONAL_INFORMATION,
educational: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION,
professional: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION,
skill: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1,
}
const STEP_LABELS = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: {
title: 'اطلاعـــــات شخصـــــی',
@@ -126,14 +135,33 @@ const STEP_LABELS = {
},
}
const tabs = [
{ key: 'personal', icon: 'user' },
{ key: 'educational', icon: 'book' },
{ key: 'professional', icon: 'file' },
{ key: 'skill', icon: 'check-square' },
const STEP_NAVIGATION = [
{ step: STUDENT_REGISTRATION.PERSONAL_INFORMATION, title: 'مشخصات فردی', icon: 'user' },
{ step: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION, title: 'اطلاعات تحصیلی', icon: 'book' },
{ step: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION, title: 'فعالیت‌های تبلیغی', icon: 'file' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1, title: 'مهارت ۱', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2, title: 'مهارت ۲', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3, title: 'مهارت ۳', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4, title: 'مهارت ۴', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5, title: 'مهارت ۵', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6, title: 'مهارت ۶', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7, title: 'مهارت ۷', icon: 'check-square' },
]
const activeTab = computed(() => STEP_TO_TAB[store.currentStep] || 'personal')
const tabs = computed(() =>
STEP_NAVIGATION.map((tab) => ({
...tab,
completed: store.completedSteps.includes(tab.step),
disabled: !store.canGoToStep(tab.step),
}))
)
const totalSteps = STEP_NAVIGATION.length
const currentStepNumber = computed(() => {
const index = STEP_NAVIGATION.findIndex((item) => item.step === store.currentStep)
return index >= 0 ? index + 1 : 1
})
const progressPercent = computed(() => Math.round((currentStepNumber.value / totalSteps) * 100))
const currentComponent = computed(
() =>
@@ -145,12 +173,8 @@ const stepLabel = computed(
() => STEP_LABELS[store.currentStep] || STEP_LABELS[STUDENT_REGISTRATION.PERSONAL_INFORMATION]
)
const goToStep = (tabKey) => {
const targetStep = TAB_TO_STEP[tabKey]
if (!targetStep) return
if (store.canGoToStep(targetStep)) {
store.updateCurrentStep(targetStep)
}
const goToStep = (step) => {
store.updateCurrentStep(step)
}
watch(
@@ -188,9 +212,8 @@ watch(
width: 100%;
@media (min-width: 1024px) {
width: 8.333%;
min-width: 5rem;
max-width: 6.5rem;
width: 13.5rem;
min-width: 13.5rem;
}
}
@@ -206,23 +229,123 @@ watch(
}
&__heading {
margin-bottom: 1.25rem;
}
&__step-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
color: #6f6f6f;
font-family: var(--font-family-number-fa);
font-size: 0.875rem;
font-weight: 500;
}
&__progress {
width: 100%;
height: 0.35rem;
margin-bottom: 1rem;
overflow: hidden;
border-radius: 999px;
background: #f2f2f2;
span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width 0.2s ease;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4c4c4c;
margin: 0 0 0.25rem;
font-weight: 600;
font-size: 1.375rem;
color: #3f3f3f;
margin: 0 0 0.375rem;
}
&__desc {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #adadad;
font-size: 0.9375rem;
line-height: 1.8;
color: #747474;
margin: 0;
}
&__loading {
padding: 3rem 1rem;
color: #747474;
font-family: var(--font-family-fa);
font-size: 1rem;
font-weight: 500;
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),
&__main :deep(.datepicker-field__label) {
color: #626262;
font-size: 0.9375rem;
font-weight: 400;
}
&__main :deep(.text-field__input),
&__main :deep(.textarea-field__input),
&__main :deep(.select-field__trigger),
&__main :deep(.datepicker-field__trigger) {
min-height: 2.6rem;
font-size: 1rem;
font-weight: 400;
}
&__main :deep(.line-title__fa) {
font-size: 1rem;
font-weight: 600;
}
&__main :deep(.line-title__en) {
font-size: 0.8125rem;
font-weight: 400;
}
&__main :deep(.text-field__error),
&__main :deep(.textarea-field__error),
&__main :deep(.select-field__error),
&__main :deep(.datepicker-field__error) {
font-size: 0.8125rem;
font-weight: 400;
}
}
</style>
+16 -9
View File
@@ -1,6 +1,5 @@
import { array, mixed, number, object, string } from 'yup'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
const requiredString = (min = 0) => {
@@ -16,7 +15,9 @@ export const personalInformationSchema = object().shape({
nationalCode: nationalCodeRule,
maritalStatus: string().required(),
gender: string().required(),
phone: phoneNumberRule,
// The verified phone is read-only here and may be returned in international
// format (for example +98912...). Do not re-validate it as a local 09 number.
phone: string().required(),
provinceId: mixed().required(),
cityId: mixed().required(),
address: requiredString(10),
@@ -25,10 +26,15 @@ export const personalInformationSchema = object().shape({
.matches(/^\d{10}$/, 'کد پستی باید ۱۰ رقم باشد'),
virtualPhoneNumber: string()
.required()
.matches(/^\d{10,15}$/, 'شماره تلفن مجازی نامعتبر است'),
.matches(/^\d{10,15}$/, 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد'),
seminaryCode: requiredString(),
seminaryServiceCenterCode: requiredString(),
childrenCount: number().typeError('تعداد فرزند باید عدد باشد').required().min(0).max(20),
childrenCount: number()
.typeError('تعداد فرزند باید به‌صورت عدد وارد شود')
.required()
.integer('تعداد فرزند باید عدد صحیح باشد')
.min(0, 'تعداد فرزند نمی‌تواند کمتر از صفر باشد')
.max(20, 'تعداد فرزند نمی‌تواند بیشتر از ۲۰ باشد'),
})
export const educationalInformationSchema = object().shape({
@@ -42,10 +48,11 @@ export const educationalInformationSchema = object().shape({
export const professionalInformationSchema = object().shape({
propagationExperienceYears: number()
.typeError('سنوات تجربه تبلیغ باید عدد باشد')
.typeError('سابقه فعالیت تبلیغی باید به‌صورت عدد وارد شود')
.required()
.min(0)
.max(70),
.integer('سابقه فعالیت تبلیغی باید عدد صحیح باشد')
.min(0, 'سابقه فعالیت تبلیغی نمی‌تواند کمتر از صفر باشد')
.max(70, 'سابقه فعالیت تبلیغی نمی‌تواند بیشتر از ۷۰ سال باشد'),
propagationMethodDescription: requiredString(10),
specializedTopics: requiredString(10),
propagationPlatforms: array()
@@ -112,11 +119,11 @@ export const skillAssessmentPart5Schema = object().shape({
})
export const skillAssessmentPart6Schema = object().shape({
faithProductionAudio: mixed().required(),
faithProductionAudio: mixed().nullable().notRequired(),
faithProductionId: mixed().notRequired(),
})
export const skillAssessmentPart7Schema = object().shape({
leaderMessageVideo: mixed().required(),
leaderMessageVideo: mixed().nullable().notRequired(),
leaderMessageId: mixed().notRequired(),
})
@@ -145,6 +145,21 @@ const readPersisted = () => {
}
}
const normalizeCompletedSteps = (steps) =>
Array.isArray(steps) ? STUDENT_REGISTRATION_ORDER.filter((step) => steps.includes(step)) : []
const firstIncompleteIndex = (steps) => {
const index = STUDENT_REGISTRATION_ORDER.findIndex((step) => !steps.includes(step))
return index === -1 ? STUDENT_REGISTRATION_ORDER.length - 1 : index
}
const resolveCurrentStep = (candidate, completed) => {
const candidateIndex = STUDENT_REGISTRATION_ORDER.indexOf(candidate)
const reachableIndex = firstIncompleteIndex(completed)
if (candidateIndex >= 0 && candidateIndex <= reachableIndex) return candidate
return STUDENT_REGISTRATION_ORDER[reachableIndex]
}
export const useStudentRegistrationStore = defineStore('studentRegistration', () => {
const persisted = readPersisted() || {}
const initial = { ...emptyState(), ...persisted }
@@ -160,8 +175,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
const skill5 = ref({ ...emptyState().skill5, ...persisted.skill5 })
const skill6 = ref({ ...emptyState().skill6, ...persisted.skill6 })
const skill7 = ref({ ...emptyState().skill7, ...persisted.skill7 })
const currentStep = ref(initial.currentStep)
const completedSteps = ref(initial.completedSteps || [])
const completedSteps = ref(normalizeCompletedSteps(initial.completedSteps))
const currentStep = ref(resolveCurrentStep(initial.currentStep, completedSteps.value))
const registrationMedia = ref(initial.registrationMedia || {})
const sectionRefs = {
@@ -207,18 +222,33 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
)
const markStepAsCompleted = (stepKey) => {
if (!completedSteps.value.includes(stepKey)) {
if (STUDENT_REGISTRATION_ORDER.includes(stepKey) && !completedSteps.value.includes(stepKey)) {
completedSteps.value = [...completedSteps.value, stepKey]
}
}
const updateCurrentStep = (stepKey) => {
if (stepKey) currentStep.value = stepKey
if (canGoToStep(stepKey)) currentStep.value = stepKey
}
const updateSection = (section, value) => {
const target = sectionRefs[section]
if (!target || !value || typeof value !== 'object') return
target.value = { ...target.value, ...value }
}
const setRegistrationMedia = (value) => {
if (!value || typeof value !== 'object') return
registrationMedia.value = { ...registrationMedia.value, ...value }
}
const goToNextStep = () => {
const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
if (idx >= 0 && idx < STUDENT_REGISTRATION_ORDER.length - 1) {
if (
idx >= 0 &&
idx < STUDENT_REGISTRATION_ORDER.length - 1 &&
completedSteps.value.includes(currentStep.value)
) {
currentStep.value = STUDENT_REGISTRATION_ORDER[idx + 1]
}
}
@@ -229,9 +259,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
}
const canGoToStep = (stepKey) => {
const currentIndex = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
const targetIndex = STUDENT_REGISTRATION_ORDER.indexOf(stepKey)
return targetIndex <= currentIndex + 1
return targetIndex >= 0 && targetIndex <= firstIncompleteIndex(completedSteps.value)
}
const buildCompletePayload = () => ({
@@ -307,6 +336,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
registrationMedia,
markStepAsCompleted,
updateCurrentStep,
updateSection,
setRegistrationMedia,
goToNextStep,
goToPrevStep,
canGoToStep,
@@ -206,7 +206,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
@@ -225,7 +225,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
@@ -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,
@@ -91,11 +91,11 @@ const performanceStats = computed(() => {
const stats = dashboard.value.stats ?? {}
return [
{
icon: 'user-sound',
label: 'تعداد جلسات اعزام شده',
icon: 'video',
label: 'تعداد جلسات آموزشی گذرانده‌شده',
value: toFaDigits(stats.attendedSessions ?? 0),
unit: 'جلسه',
subtitle: 'اعزام شده است',
subtitle: 'با موفقیت گذرانده شده',
to: { name: 'student-terms' },
},
{
@@ -292,7 +292,7 @@ const calendar = buildTehranCalendar()
gap: 1.5rem;
@media (min-width: 1024px) {
width: 19.625rem;
width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem;
+4 -1
View File
@@ -22,7 +22,10 @@ export const studentProfileSchema = object().shape({
virtualPhoneNumber: string()
.nullable()
.notRequired()
.matches(/^\d{10,15}$/, { message: 'شماره تلفن مجازی نامعتبر است', excludeEmptyString: true }),
.matches(/^\d{10,15}$/, {
message: 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد',
excludeEmptyString: true,
}),
seminaryCode: string().nullable().notRequired(),
seminaryServiceCenterCode: string().nullable().notRequired(),
childrenCount: string()
@@ -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: '',
@@ -31,21 +31,29 @@
/>
</div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio">
<VoiceRecorder
model-value="http://api.tripwisedaily.ir/storage/pending/3/test2-AmQiegjc.mp3?expires=1783677165&signature=38104395400100f1bd74594bfc64ac462c14634a8c2bc4541c988766a08ea2bd"
disabled
/>
<VoiceRecorder :model-value="session?.audioUrl" disabled />
</div>
<div v-else-if="contentKind === 'text'" class="ssd__text">
<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>
@@ -132,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: '' })
@@ -263,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;
+19 -2
View File
@@ -147,8 +147,19 @@ const menuItems = computed(() => [
{
title: 'مدیریت خدمات',
icon: 'users-three',
to: { name: 'admin-services' },
active: route.name === 'admin-services',
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: 'مدیریت مشاوره',
@@ -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
}
+98 -17
View File
@@ -1,31 +1,53 @@
<template>
<aside class="admin-sidebar">
<div class="admin-sidebar__logo">
<img :src="logo" alt="مدرسه بانو" />
<a :href="MAIN_SITE_URL">
<img :src="logo" alt="مدرسه بانو" />
</a>
</div>
<nav class="admin-sidebar__nav">
<ul>
<li v-for="item in menuItems" :key="item.title" class="admin-sidebar__item">
<component
:is="item.to ? 'RouterLink' : 'div'"
<li v-for="(item, index) in menuItems" :key="item.title" class="admin-sidebar__item">
<button
v-if="item.children?.length"
type="button"
class="admin-sidebar__link"
:class="{ 'admin-sidebar__link--active': item.active }"
:aria-expanded="isExpanded(item)"
:aria-controls="submenuId(index)"
@click="toggle(item)"
>
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
<span>{{ item.title }}</span>
<SvgIcon
name="caret-down"
:size="16"
class="admin-sidebar__chevron"
:class="{ 'admin-sidebar__chevron--expanded': isExpanded(item) }"
/>
</button>
<RouterLink
v-else
:to="item.to"
class="admin-sidebar__link"
:class="{ 'admin-sidebar__link--active': item.active }"
>
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
<span>{{ item.title }}</span>
</component>
<ul v-if="item.children?.length" class="admin-sidebar__submenu">
<li v-for="child in item.children" :key="child.title">
<RouterLink
:to="child.to"
class="admin-sidebar__sublink"
:class="{ 'admin-sidebar__sublink--active': child.active }"
>
{{ child.title }}
</RouterLink>
</li>
</ul>
</RouterLink>
<Transition v-if="item.children?.length" name="admin-sidebar__accordion">
<ul v-show="isExpanded(item)" :id="submenuId(index)" class="admin-sidebar__submenu">
<li v-for="child in item.children" :key="child.title">
<RouterLink
:to="child.to"
class="admin-sidebar__sublink"
:class="{ 'admin-sidebar__sublink--active': child.active }"
>
{{ child.title }}
</RouterLink>
</li>
</ul>
</Transition>
</li>
</ul>
</nav>
@@ -34,16 +56,44 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({
const props = defineProps({
menuItems: { type: Array, default: () => [] },
})
const logo = gallery.logoPinkishRed
const expandedItems = ref({})
watch(
() => props.menuItems,
(items) => {
const next = { ...expandedItems.value }
items.forEach((item) => {
if (!item.children?.length) return
if (!(item.title in next)) next[item.title] = false
if (item.active) next[item.title] = true
})
expandedItems.value = next
},
{ immediate: true }
)
const isExpanded = (item) => Boolean(expandedItems.value[item.title])
const toggle = (item) => {
expandedItems.value = {
...expandedItems.value,
[item.title]: !isExpanded(item),
}
}
const submenuId = (index) => `admin-sidebar-submenu-${index}`
</script>
<style lang="scss" scoped>
@@ -82,6 +132,7 @@ const logo = gallery.logoPinkishRed
}
&__link {
width: 100%;
display: flex;
align-items: center;
gap: 0.625rem;
@@ -91,6 +142,9 @@ const logo = gallery.logoPinkishRed
font-size: 0.875rem;
color: #4b5563;
text-decoration: none;
text-align: start;
border: 0;
background: transparent;
transition: background-color 0.15s, color 0.15s;
cursor: pointer;
@@ -105,6 +159,15 @@ const logo = gallery.logoPinkishRed
}
}
&__chevron {
margin-inline-start: auto;
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__submenu {
margin-top: 0.25rem !important;
padding-inline-start: 1.5rem !important;
@@ -130,5 +193,23 @@ const logo = gallery.logoPinkishRed
font-weight: 700;
}
}
&__accordion-enter-active,
&__accordion-leave-active {
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease;
}
&__accordion-enter-from,
&__accordion-leave-to {
max-height: 0;
opacity: 0;
}
&__accordion-enter-to,
&__accordion-leave-from {
max-height: 16rem;
opacity: 1;
}
}
</style>
+4 -1
View File
@@ -1,7 +1,9 @@
<template>
<aside class="counselor-sidebar">
<div class="counselor-sidebar__logo">
<img :src="logo" alt="مدرسه بانو" />
<a :href="MAIN_SITE_URL">
<img :src="logo" alt="مدرسه بانو" />
</a>
</div>
<nav class="counselor-sidebar__nav">
<ul>
@@ -23,6 +25,7 @@
</template>
<script setup>
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
+4 -1
View File
@@ -1,7 +1,9 @@
<template>
<aside class="student-sidebar" :style="{ backgroundImage: `url(${bgPattern})` }">
<div class="student-sidebar__logo">
<img :src="logo" alt="مدرسه بانو" />
<a :href="MAIN_SITE_URL">
<img :src="logo" alt="مدرسه بانو" />
</a>
</div>
<nav class="student-sidebar__nav">
<ul>
@@ -23,6 +25,7 @@
</template>
<script setup>
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
+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) =>
+10 -1
View File
@@ -43,4 +43,13 @@ export const apiSaveRegisterData = (data) => http.post(endpoints.registerData, {
export const apiGetRegistrationQuestionVideo = () =>
http.get(endpoints.getRegistrationQuestionVideo)
export const apiUploadMedia = (formData) => http.post(endpoints.uploadMedia, formData)
// 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.
// `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 = {}) =>
+17 -4
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) {
tokenService.clearAll()
// 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 }))
+1 -1
View File
@@ -18,7 +18,7 @@ export const currentMe = {
lastName: 'محمدی',
fullName: 'سجاد محمدی',
phoneNumber: '09123456789',
nationalCode: '0079827498',
nationalCode: '0079827497',
status: 'approved',
address: {
address: 'خیابان آزادی، پلاک ۱۲',
+8
View File
@@ -4,6 +4,7 @@ import { endpoints } from '@/services/api/endpoints'
import { currentMe } from '@/services/mock/fixtures/me'
const fakeToken = 'mock-token-1234567890'
let registerData = {}
register('POST', endpoints.login, () => ({
data: { user: currentMe, token: fakeToken },
@@ -86,6 +87,13 @@ register('POST', endpoints.completeProfile, ({ data }) => ({
data: { user: { ...currentMe, ...data } },
}))
register('GET', endpoints.registerData, () => ({ data: registerData }))
register('POST', endpoints.registerData, ({ data }) => {
registerData = { ...registerData, ...data?.data }
return { data: registerData }
})
register('GET', endpoints.getRegistrationQuestionVideo, () => ({
data: { url: '', id: 0 },
}))
+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) })
+22 -2
View File
@@ -60,15 +60,35 @@ export const useCompleteProfileMutation = () => useMutation({ mutationFn: apiCom
export const useUpdateProfileMutation = () => useMutation({ mutationFn: apiUpdateProfile })
const getRegistrationVideoIfAvailable = async () => {
try {
return await apiGetRegistrationQuestionVideo()
} catch (error) {
// The migrated backend does not expose this optional content endpoint yet.
// Treat a missing route as "no tutorial uploaded" so registration remains usable.
if (error?.response?.status === 404) return {}
throw error
}
}
export const useGetRegistrationVideoQuery = (options = {}) =>
useQuery({
queryKey: authKeys.registrationVideo(),
queryFn: () => apiGetRegistrationQuestionVideo(),
queryFn: getRegistrationVideoIfAvailable,
select: (response) => response?.data ?? response,
...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,
+4 -8
View File
@@ -35,8 +35,7 @@ export function gregorianToJalaliString(isoString, separator = '/', persianDigit
jd = jd.replace(/\d/g, (d) => enToFa[d])
}
return `${jy}${separator}${jm}${separator}${jd}`
} catch (error) {
console.error('Date conversion error:', error)
} catch {
return ''
}
}
@@ -83,8 +82,7 @@ export function gregorianToJalaliStringLong(isoString, separator = '/', persianD
}
return ` ${jy}${separator}${jm}${separator}${jd}، ${hh}:${mm}`
} catch (error) {
console.error('Date conversion error:', error)
} catch {
return ''
}
}
@@ -98,8 +96,7 @@ export function JalaliToGregorianString(jalaliStr) {
2,
'0'
)}T00:00:00.000Z`
} catch (error) {
console.log('Jalali to Gregorian conversion error:', error)
} catch {
return ''
}
}
@@ -119,8 +116,7 @@ export function JalaliToGregorianStringWithTime(jalaliDateTimeStr) {
2,
'0'
)}T${timePart}:00.000Z`
} catch (error) {
console.log('Jalali to Gregorian with time conversion error:', error)
} catch {
return ''
}
}
+12
View File
@@ -24,6 +24,18 @@ export function handleApiError(error_) {
const data = error_?.response?.data
const status = error_?.response?.status
if (error_?.code === 'ECONNABORTED') {
toast.error(
'زمان بارگذاری فایل به پایان رسید. لطفاً اتصال اینترنت را بررسی و دوباره تلاش کنید.'
)
return
}
if (status === 413) {
toast.error('حجم فایل از حداکثر مجاز بیشتر است.')
return
}
if (status === 422 && data?.errors) {
Object.values(data.errors).forEach((messages) => {
const list = Array.isArray(messages) ? messages : [messages]
+68 -7
View File
@@ -2,21 +2,82 @@ import { setLocale } from 'yup'
import { fields } from '@/constants/fields'
const label = (path) => fields[path] || path
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
const toFaDigits = (value) => String(value ?? '').replace(/\d/g, (digit) => FA_DIGITS[digit])
const label = (path) => {
const normalizedPath = String(path ?? '').replace(/\[\d+]/g, '')
const fieldName = normalizedPath.split('.').pop()
return fields[path] || fields[normalizedPath] || fields[fieldName] || path || 'مقدار'
}
const typeError = ({ path, type }) => {
const field = label(path)
const messages = {
array: `فیلد ${field} باید به‌صورت فهرست باشد`,
boolean: `فیلد ${field} باید مقدار بله یا خیر داشته باشد`,
date: `فیلد ${field} باید یک تاریخ معتبر باشد`,
number: `فیلد ${field} باید عدد باشد`,
object: `ساختار فیلد ${field} معتبر نیست`,
string: `فیلد ${field} باید متن باشد`,
}
return messages[type] || `نوع مقدار فیلد ${field} معتبر نیست`
}
setLocale({
mixed: {
default: ({ path }) => `مقدار فیلد ${label(path)} معتبر نیست`,
required: ({ path }) => `فیلد ${label(path)} ضروری است`,
defined: ({ path }) => `فیلد ${label(path)} باید مقدار داشته باشد`,
notNull: ({ path }) => `فیلد ${label(path)} نمی‌تواند خالی باشد`,
oneOf: ({ path }) => `مقدار انتخاب‌شده برای فیلد ${label(path)} معتبر نیست`,
notOneOf: ({ path }) => `مقدار انتخاب‌شده برای فیلد ${label(path)} مجاز نیست`,
notType: typeError,
},
string: {
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${min} کاراکتر باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${max} کاراکتر باشد`,
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${toFaDigits(min)} کاراکتر باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${toFaDigits(max)} کاراکتر باشد`,
email: ({ path }) => `فیلد ${label(path)} باید یک ایمیل معتبر باشد`,
url: ({ path }) => `فیلد ${label(path)} باید یک URL معتبر باشد`,
length: ({ path, length }) => `فیلد ${label(path)} باید دقیقا ${length} کاراکتر باشد`,
url: ({ path }) => `فیلد ${label(path)} باید یک نشانی اینترنتی معتبر باشد`,
length: ({ path, length }) =>
`فیلد ${label(path)} باید دقیقاً ${toFaDigits(length)} کاراکتر باشد`,
matches: ({ path }) => `فرمت فیلد ${label(path)} صحیح نیست`,
uuid: ({ path }) => `شناسه فیلد ${label(path)} معتبر نیست`,
datetime: ({ path }) => `فیلد ${label(path)} باید یک تاریخ و زمان معتبر باشد`,
datetime_precision: ({ path }) => `دقت زمانی فیلد ${label(path)} معتبر نیست`,
datetime_offset: ({ path }) => `منطقه زمانی فیلد ${label(path)} معتبر نیست`,
trim: ({ path }) => `ابتدا یا انتهای فیلد ${label(path)} نباید فاصله داشته باشد`,
lowercase: ({ path }) => `فیلد ${label(path)} باید با حروف کوچک نوشته شود`,
uppercase: ({ path }) => `فیلد ${label(path)} باید با حروف بزرگ نوشته شود`,
},
number: {
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${toFaDigits(min)} باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${toFaDigits(max)} باشد`,
lessThan: ({ path, less }) => `فیلد ${label(path)} باید کمتر از ${toFaDigits(less)} باشد`,
moreThan: ({ path, more }) => `فیلد ${label(path)} باید بیشتر از ${toFaDigits(more)} باشد`,
positive: ({ path }) => `فیلد ${label(path)} باید عددی بزرگ‌تر از صفر باشد`,
negative: ({ path }) => `فیلد ${label(path)} باید عددی کوچک‌تر از صفر باشد`,
integer: ({ path }) => `فیلد ${label(path)} باید عدد صحیح باشد`,
},
date: {
min: ({ path }) => `تاریخ فیلد ${label(path)} زودتر از محدوده مجاز است`,
max: ({ path }) => `تاریخ فیلد ${label(path)} دیرتر از محدوده مجاز است`,
},
boolean: {
isValue: ({ path }) => `مقدار فیلد ${label(path)} معتبر نیست`,
},
object: {
noUnknown: ({ path }) => `فیلد ${label(path)} شامل مقادیر ناشناخته است`,
exact: ({ path }) => `فیلد ${label(path)} شامل مقادیر اضافی است`,
},
array: {
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${min} آیتم داشته باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${max} آیتم داشته باشد`,
min: ({ path, min }) => `فیلد ${label(path)} باید حداقل ${toFaDigits(min)} مورد داشته باشد`,
max: ({ path, max }) => `فیلد ${label(path)} باید حداکثر ${toFaDigits(max)} مورد داشته باشد`,
length: ({ path, length }) =>
`فیلد ${label(path)} باید دقیقاً ${toFaDigits(length)} مورد داشته باشد`,
},
tuple: {
notType: typeError,
},
})