4 Commits

Author SHA1 Message Date
madresebanoo c99df794b9 fix(modal): keep confirm dialog above other modals
Deploy banu-front / deploy (push) Successful in 1m47s
ConfirmModal mounts once at the app root, so its Teleport anchor in
<body> is created before page-level modals like TermDetailsModal.
With an identical z-index, later-mounted modals ended up stacking on
top of it, hiding the confirmation prompt (e.g. confirming the new
remove-student-from-term action). Adds a configurable zIndex prop to
BasicModal and gives ConfirmModal a higher value so it always shows
on top regardless of mount order.
2026-09-21 11:39:41 +04:30
madresebanoo af9d55e651 feat(admin): wire up remove-student-from-term button
Deploy banu-front / deploy (push) Successful in 1m46s
The mutation/API plumbing already existed (useRemoveAdminTermStudentMutation)
but no UI ever called it. Adds a delete action to each row in the term
details modal's seminarians list, with a confirmation prompt.
2026-09-20 16:55:13 +04:30
madresebanoo 6c4bc4e575 fix(exams): prevent double-submit from creating duplicate exams
Deploy banu-front / deploy (push) Successful in 2m3s
The submit button only disabled after Vue re-rendered on mutation
pending state, leaving a window where a fast double-click fired
onSubmit twice — creating two exam rows, with the losing request
usually failing to attach its questions (an "empty" duplicate exam).
2026-09-20 16:35:36 +04:30
madresebanoo a967479c9f fix(sidebar): deduplicate menu items for multi-role users
Deploy banu-front / deploy (push) Successful in 1m50s
When a user has both student and missionary roles, common items
(dashboard, education, ticket) appeared twice. Added dedupeKey to
each item; buildMergedMenuItems now skips items whose key was already
added by an earlier role — the primary role's version always wins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-05 16:08:59 +04:30
5 changed files with 83 additions and 23 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<Teleport to="body">
<Transition name="fade-scale" @after-enter="onOpened" @after-leave="onClosed">
<div v-if="isOpen" class="basic-modal" @click="onBackdropClick">
<div v-if="isOpen" class="basic-modal" :style="{ zIndex }" @click="onBackdropClick">
<div class="basic-modal__container" :style="containerStyle" @click.stop>
<div class="basic-modal__card scrollbar-thin">
<header v-if="hasHeader" class="basic-modal__header">
@@ -43,6 +43,7 @@ const props = defineProps({
width: { type: String, default: '' },
maxWidth: { type: String, default: '98%' },
minWidth: { type: String, default: '85%' },
zIndex: { type: [Number, String], default: 50 },
})
const emit = defineEmits(['open', 'close'])
@@ -101,7 +102,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
.basic-modal {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<BasicModal min-width="0" max-width="640px" width="95%" :show-close-button="false">
<BasicModal min-width="0" max-width="640px" width="95%" :show-close-button="false" :z-index="60">
<template #default="{ data }">
<div class="confirm-modal">
<h3 class="confirm-modal__title">{{ data?.title || 'تأیید حذف' }}</h3>
+24 -15
View File
@@ -283,8 +283,11 @@ const addExamMutation = useAddAdminExamMutation()
const updateExamMutation = useUpdateAdminExamMutation()
const addQuestionMutation = useAddAdminExamQuestionMutation()
const isSubmitting = ref(false)
const submitting = computed(
() =>
isSubmitting.value ||
addExamMutation.isPending.value ||
updateExamMutation.isPending.value ||
addQuestionMutation.isPending.value
@@ -298,23 +301,29 @@ const postQuestionsSequentially = async (id, list) => {
}
const onSubmit = async () => {
const { isValid } = await validate(form.value)
const newQuestions = validateLocalQuestions()
if (!isValid || !newQuestions) return
if (isSubmitting.value) return
isSubmitting.value = true
try {
const { isValid } = await validate(form.value)
const newQuestions = validateLocalQuestions()
if (!isValid || !newQuestions) return
const examPayload = buildExamPayload(form.value)
let targetExamId = examId.value
if (isEditMode.value) {
await updateExamMutation.mutateAsync({ id: targetExamId, payload: examPayload })
} else {
const created = await addExamMutation.mutateAsync(examPayload)
targetExamId = created?.data?.id ?? created?.id ?? targetExamId
const examPayload = buildExamPayload(form.value)
let targetExamId = examId.value
if (isEditMode.value) {
await updateExamMutation.mutateAsync({ id: targetExamId, payload: examPayload })
} else {
const created = await addExamMutation.mutateAsync(examPayload)
targetExamId = created?.data?.id ?? created?.id ?? targetExamId
}
if (targetExamId && newQuestions.length > 0) {
await postQuestionsSequentially(targetExamId, newQuestions)
}
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all, refetchType: 'all' })
router.push({ name: 'admin-exams' })
} finally {
isSubmitting.value = false
}
if (targetExamId && newQuestions.length > 0) {
await postQuestionsSequentially(targetExamId, newQuestions)
}
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all, refetchType: 'all' })
router.push({ name: 'admin-exams' })
}
const onCancel = () => router.push({ name: 'admin-exams' })
@@ -70,6 +70,18 @@
</p>
</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>
<NoItems v-else title="متاسفیم" desc="طلبه‌ای برای نمایش وجود ندارد." />
@@ -130,18 +142,24 @@ 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 CircleButton from '@/components/CircleButton.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useAdminTermEnrolleesQuery, useAdminTermQuery } from '@/services/query/admin-terms'
import {
adminCoursesKeys,
useAdminCoursesListQuery,
useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses'
import {
adminTermsKeys,
useAdminTermEnrolleesQuery,
useAdminTermQuery,
useRemoveAdminTermStudentMutation,
} from '@/services/query/admin-terms'
defineOptions({ name: 'TermDetailsModal' })
@@ -187,6 +205,24 @@ const studentPaginationMeta = computed(() => ({
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 { pagination: coursePagination, setPage: setCoursePage } = usePagination({
page: 1,
+19 -4
View File
@@ -11,6 +11,7 @@ export const buildStudentMenuItems = (route) => [
icon: 'monitor',
to: { name: 'student-dashboard' },
active: route.name === 'student-dashboard',
dedupeKey: 'dashboard',
},
{
title: 'آموزش',
@@ -25,6 +26,7 @@ export const buildStudentMenuItems = (route) => [
'student-courses',
'student-course-details',
].includes(route.name),
dedupeKey: 'education',
},
{
title: 'خدمات',
@@ -55,6 +57,7 @@ export const buildStudentMenuItems = (route) => [
icon: 'envelope-open',
to: { name: 'student-tickets' },
active: route.name === 'student-tickets',
dedupeKey: 'ticket',
},
]
@@ -64,6 +67,7 @@ export const buildMissionaryMenuItems = (route) => [
icon: 'menu',
to: { name: 'missionary-dashboard' },
active: route.name === 'missionary-dashboard',
dedupeKey: 'dashboard',
},
{
title: 'سامانه اعزام',
@@ -76,12 +80,14 @@ export const buildMissionaryMenuItems = (route) => [
icon: 'graduation-cap',
to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'),
dedupeKey: 'education',
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets',
dedupeKey: 'ticket',
},
]
@@ -91,12 +97,14 @@ export const buildCounselorMenuItems = (route) => [
icon: 'menu',
to: { name: 'counselor-dashboard' },
active: route.name === 'counselor-dashboard',
dedupeKey: 'dashboard',
},
{
title: 'مشاوره',
icon: 'chat',
to: { name: 'counselor-consultations' },
active: route.name === 'counselor-consultations',
dedupeKey: 'consultation',
},
]
@@ -110,15 +118,22 @@ const MENU_BUILDERS = {
* 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).
* 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) => {
const seen = new Set()
const seenRoles = new Set()
const seenKeys = 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))
if (!builder || seenRoles.has(roleName)) continue
seenRoles.add(roleName)
for (const item of builder(route)) {
if (item.dedupeKey && seenKeys.has(item.dedupeKey)) continue
if (item.dedupeKey) seenKeys.add(item.dedupeKey)
items.push(item)
}
}
return items
}