12 Commits

Author SHA1 Message Date
madresebanoo 216efa7c1f Merge pull request 'Deploy/batch 2 medium risk' (#2) from deploy/batch-2-medium-risk into master
Deploy banu-front / deploy (push) Successful in 3m38s
Reviewed-on: #2
2026-09-02 11:50:31 +00:00
Saghar Mohammadi 274284dc05 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
Saghar Mohammadi 0caea18eb8 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
Saghar Mohammadi 9b61fff128 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
Saghar Mohammadi 55ed80e652 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
Saghar Mohammadi bdb77786f2 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
Saghar Mohammadi d227b1c8f4 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
Saghar Mohammadi fdc52381db 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
Saghar Mohammadi c0fe9e9918 Merge branch 'master' of https://git.madresebanoo.ir/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 1m38s
# 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
Saghar Mohammadi 8743d37a7d 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 fc1c249033 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
Deploy banu-front / deploy (push) Successful in 1m26s
Reviewed-on: #1
2026-09-02 08:06:21 +00:00
Saghar Mohammadi 9f86acacb0 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
8 changed files with 23 additions and 105 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="fade-scale" @after-enter="onOpened" @after-leave="onClosed"> <Transition name="fade-scale" @after-enter="onOpened" @after-leave="onClosed">
<div v-if="isOpen" class="basic-modal" :style="{ zIndex }" @click="onBackdropClick"> <div v-if="isOpen" class="basic-modal" @click="onBackdropClick">
<div class="basic-modal__container" :style="containerStyle" @click.stop> <div class="basic-modal__container" :style="containerStyle" @click.stop>
<div class="basic-modal__card scrollbar-thin"> <div class="basic-modal__card scrollbar-thin">
<header v-if="hasHeader" class="basic-modal__header"> <header v-if="hasHeader" class="basic-modal__header">
@@ -43,7 +43,6 @@ const props = defineProps({
width: { type: String, default: '' }, width: { type: String, default: '' },
maxWidth: { type: String, default: '98%' }, maxWidth: { type: String, default: '98%' },
minWidth: { type: String, default: '85%' }, minWidth: { type: String, default: '85%' },
zIndex: { type: [Number, String], default: 50 },
}) })
const emit = defineEmits(['open', 'close']) const emit = defineEmits(['open', 'close'])
@@ -102,6 +101,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
.basic-modal { .basic-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<BasicModal min-width="0" max-width="640px" width="95%" :show-close-button="false" :z-index="60"> <BasicModal min-width="0" max-width="640px" width="95%" :show-close-button="false">
<template #default="{ data }"> <template #default="{ data }">
<div class="confirm-modal"> <div class="confirm-modal">
<h3 class="confirm-modal__title">{{ data?.title || 'تأیید حذف' }}</h3> <h3 class="confirm-modal__title">{{ data?.title || 'تأیید حذف' }}</h3>
@@ -283,11 +283,8 @@ const addExamMutation = useAddAdminExamMutation()
const updateExamMutation = useUpdateAdminExamMutation() const updateExamMutation = useUpdateAdminExamMutation()
const addQuestionMutation = useAddAdminExamQuestionMutation() const addQuestionMutation = useAddAdminExamQuestionMutation()
const isSubmitting = ref(false)
const submitting = computed( const submitting = computed(
() => () =>
isSubmitting.value ||
addExamMutation.isPending.value || addExamMutation.isPending.value ||
updateExamMutation.isPending.value || updateExamMutation.isPending.value ||
addQuestionMutation.isPending.value addQuestionMutation.isPending.value
@@ -301,9 +298,6 @@ const postQuestionsSequentially = async (id, list) => {
} }
const onSubmit = async () => { const onSubmit = async () => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
const { isValid } = await validate(form.value) const { isValid } = await validate(form.value)
const newQuestions = validateLocalQuestions() const newQuestions = validateLocalQuestions()
if (!isValid || !newQuestions) return if (!isValid || !newQuestions) return
@@ -321,9 +315,6 @@ const onSubmit = async () => {
} }
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all, refetchType: 'all' }) await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all, refetchType: 'all' })
router.push({ name: 'admin-exams' }) router.push({ name: 'admin-exams' })
} finally {
isSubmitting.value = false
}
} }
const onCancel = () => router.push({ name: 'admin-exams' }) const onCancel = () => router.push({ name: 'admin-exams' })
-11
View File
@@ -274,15 +274,4 @@ export default [
subtitle: 'درخواست‌های اعزام مبلغین را مشاهده و مدیریت نمایید.', subtitle: 'درخواست‌های اعزام مبلغین را مشاهده و مدیریت نمایید.',
}, },
}, },
{
path: '/certificates',
name: 'admin-certificates',
component: () => import('@/features/admin/certificates/pages/AdminCertificatesPage.vue'),
meta: {
layout: 'admin',
role: 'admin',
title: 'گواهینامه‌ها',
subtitle: 'گواهینامه‌های دوره‌ها را صادر و مدیریت نمایید.',
},
},
] ]
@@ -70,18 +70,6 @@
</p> </p>
</div> </div>
</div> </div>
<div class="term-details__student-actions">
<CircleButton
tooltip="حذف از ترم"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="onAskRemoveStudent(student)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div> </div>
</div> </div>
<NoItems v-else title="متاسفیم" desc="طلبه‌ای برای نمایش وجود ندارد." /> <NoItems v-else title="متاسفیم" desc="طلبه‌ای برای نمایش وجود ندارد." />
@@ -142,24 +130,18 @@ import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue' import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination' import { usePagination } from '@/composables/usePagination'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue' import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useAdminTermEnrolleesQuery, useAdminTermQuery } from '@/services/query/admin-terms'
import { import {
adminCoursesKeys, adminCoursesKeys,
useAdminCoursesListQuery, useAdminCoursesListQuery,
useUpdateAdminCourseMutation, useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses' } from '@/services/query/admin-courses'
import {
adminTermsKeys,
useAdminTermEnrolleesQuery,
useAdminTermQuery,
useRemoveAdminTermStudentMutation,
} from '@/services/query/admin-terms'
defineOptions({ name: 'TermDetailsModal' }) defineOptions({ name: 'TermDetailsModal' })
@@ -205,24 +187,6 @@ const studentPaginationMeta = computed(() => ({
const studentAvatar = (student) => student.avatarUrl || student.avatarDownloadUrl || '' const studentAvatar = (student) => student.avatarUrl || student.avatarDownloadUrl || ''
const invalidateStudents = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
const removeStudentMutation = useRemoveAdminTermStudentMutation()
const onAskRemoveStudent = (student) => {
openModal('ConfirmModal', {
title: `حذف ${student?.user?.name || 'طلبه'}`,
message: `بعد از حذف، دسترسی <strong>${
student?.user?.name || 'این طلبه'
}</strong> به این ترم و محتوای آن قطع می‌شود. آیا مطمئن هستید؟`,
onConfirm: () =>
removeStudentMutation.mutate(
{ termId: termId.value, userId: student.user.id },
{ onSuccess: invalidateStudents }
),
})
}
const courseFilters = computed(() => ({ termId: termId.value })) const courseFilters = computed(() => ({ termId: termId.value }))
const { pagination: coursePagination, setPage: setCoursePage } = usePagination({ const { pagination: coursePagination, setPage: setCoursePage } = usePagination({
page: 1, page: 1,
-6
View File
@@ -173,12 +173,6 @@ const menuItems = computed(() => [
to: { name: 'admin-missionary-requests' }, to: { name: 'admin-missionary-requests' },
active: route.name === 'admin-missionary-requests', active: route.name === 'admin-missionary-requests',
}, },
{
title: 'گواهینامه‌ها',
icon: 'certificate',
to: { name: 'admin-certificates' },
active: route.name === 'admin-certificates',
},
]) ])
</script> </script>
+4 -19
View File
@@ -11,7 +11,6 @@ export const buildStudentMenuItems = (route) => [
icon: 'monitor', icon: 'monitor',
to: { name: 'student-dashboard' }, to: { name: 'student-dashboard' },
active: route.name === 'student-dashboard', active: route.name === 'student-dashboard',
dedupeKey: 'dashboard',
}, },
{ {
title: 'آموزش', title: 'آموزش',
@@ -26,7 +25,6 @@ export const buildStudentMenuItems = (route) => [
'student-courses', 'student-courses',
'student-course-details', 'student-course-details',
].includes(route.name), ].includes(route.name),
dedupeKey: 'education',
}, },
{ {
title: 'خدمات', title: 'خدمات',
@@ -57,7 +55,6 @@ export const buildStudentMenuItems = (route) => [
icon: 'envelope-open', icon: 'envelope-open',
to: { name: 'student-tickets' }, to: { name: 'student-tickets' },
active: route.name === 'student-tickets', active: route.name === 'student-tickets',
dedupeKey: 'ticket',
}, },
] ]
@@ -67,7 +64,6 @@ export const buildMissionaryMenuItems = (route) => [
icon: 'menu', icon: 'menu',
to: { name: 'missionary-dashboard' }, to: { name: 'missionary-dashboard' },
active: route.name === 'missionary-dashboard', active: route.name === 'missionary-dashboard',
dedupeKey: 'dashboard',
}, },
{ {
title: 'سامانه اعزام', title: 'سامانه اعزام',
@@ -80,14 +76,12 @@ export const buildMissionaryMenuItems = (route) => [
icon: 'graduation-cap', icon: 'graduation-cap',
to: { name: 'missionary-education' }, to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'), active: route.name?.startsWith('missionary-education'),
dedupeKey: 'education',
}, },
{ {
title: 'تیکت', title: 'تیکت',
icon: 'envelope-open', icon: 'envelope-open',
to: { name: 'missionary-tickets' }, to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets', active: route.name === 'missionary-tickets',
dedupeKey: 'ticket',
}, },
] ]
@@ -97,14 +91,12 @@ export const buildCounselorMenuItems = (route) => [
icon: 'menu', icon: 'menu',
to: { name: 'counselor-dashboard' }, to: { name: 'counselor-dashboard' },
active: route.name === 'counselor-dashboard', active: route.name === 'counselor-dashboard',
dedupeKey: 'dashboard',
}, },
{ {
title: 'مشاوره', title: 'مشاوره',
icon: 'chat', icon: 'chat',
to: { name: 'counselor-consultations' }, to: { name: 'counselor-consultations' },
active: route.name === 'counselor-consultations', active: route.name === 'counselor-consultations',
dedupeKey: 'consultation',
}, },
] ]
@@ -118,22 +110,15 @@ const MENU_BUILDERS = {
* Union of every panel section unlocked by `roles`, in role order (roles * 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 * 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). * sections lead and each additional role's sections are appended after).
* Items sharing a dedupeKey across roles are merged — only the first
* occurrence is kept (the primary role's version wins).
*/ */
export const buildMergedMenuItems = (roles, route) => { export const buildMergedMenuItems = (roles, route) => {
const seenRoles = new Set() const seen = new Set()
const seenKeys = new Set()
const items = [] const items = []
for (const roleName of roles ?? []) { for (const roleName of roles ?? []) {
const builder = MENU_BUILDERS[roleName] const builder = MENU_BUILDERS[roleName]
if (!builder || seenRoles.has(roleName)) continue if (!builder || seen.has(roleName)) continue
seenRoles.add(roleName) seen.add(roleName)
for (const item of builder(route)) { items.push(...builder(route))
if (item.dedupeKey && seenKeys.has(item.dedupeKey)) continue
if (item.dedupeKey) seenKeys.add(item.dedupeKey)
items.push(item)
}
} }
return items return items
} }
-5
View File
@@ -196,11 +196,6 @@ export const endpoints = {
listAdminMissionaryRequests: '/admin/missionary-requests', listAdminMissionaryRequests: '/admin/missionary-requests',
showAdminMissionaryRequest: '/admin/missionary-requests/:id', showAdminMissionaryRequest: '/admin/missionary-requests/:id',
changeAdminMissionaryRequestStatus: '/admin/missionary-requests/:id/status', changeAdminMissionaryRequestStatus: '/admin/missionary-requests/:id/status',
// ─── Admin: certificates ───────────────────────────────────────────────────
getAdminCertificates: '/admin/certificates',
addAdminCertificate: '/admin/certificates',
deleteAdminCertificate: '/admin/certificates/:id',
} }
export const buildUrl = (template, params = {}) => export const buildUrl = (template, params = {}) =>