Compare commits
3
Commits
a2a7e756ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a4ac1396d | ||
|
|
0201089aee | ||
|
|
e9f1f534d5 |
+2
-2
@@ -2,9 +2,9 @@ FROM node:24-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json .npmrc* ./
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<!-- /src/components/common/LoadingOverlay.vue -->
|
||||||
|
<template>
|
||||||
|
<Transition name="fade-overlay">
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="loading-overlay-container flex flex-column align-items-center justify-content-center"
|
||||||
|
:class="{ 'fixed-overlay': fullscreen }"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="loading-card surface-card p-4 border-round-xl border-1 border-color shadow-3 flex flex-column align-items-center gap-3">
|
||||||
|
<ProgressSpinner
|
||||||
|
style="width: 44px; height: 44px"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="transparent"
|
||||||
|
animationDuration=".8s"
|
||||||
|
aria-label="در حال بارگذاری"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-column align-items-center gap-1 text-center">
|
||||||
|
<span class="font-bold text-sm text-color">{{ message }}</span>
|
||||||
|
<span v-if="subMessage" class="text-xs text-muted">{{ subMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: 'در حال دریافت اطلاعات...'
|
||||||
|
},
|
||||||
|
subMessage: {
|
||||||
|
type: String,
|
||||||
|
default: 'لطفاً چند لحظه صبر کنید'
|
||||||
|
},
|
||||||
|
fullscreen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-overlay-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
z-index: 50;
|
||||||
|
border-radius: inherit;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[class*='dark'] .loading-overlay-container,
|
||||||
|
.dark-theme .loading-overlay-container {
|
||||||
|
background: rgba(18, 24, 38, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-overlay {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
animation: pulse-card 1.8s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-card {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-overlay-enter-active,
|
||||||
|
.fade-overlay-leave-active {
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-overlay-enter-from,
|
||||||
|
.fade-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- /src/views/classes/ClassDetailView.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="class-detail-view" v-if="classData">
|
<div class="class-detail-view relative">
|
||||||
|
<LoadingOverlay :loading="isFetching || !classData" message="در حال دریافت مشخصات کلاس..." />
|
||||||
|
<div v-if="classData">
|
||||||
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
|
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
|
||||||
<PermissionGate permission="financial_reports:read">
|
<PermissionGate permission="financial_reports:read">
|
||||||
<Button
|
<Button
|
||||||
@@ -241,6 +242,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -256,6 +258,7 @@ import { useToast } from '@/composables/useToast';
|
|||||||
import { usePermission } from '@/composables/usePermission';
|
import { usePermission } from '@/composables/usePermission';
|
||||||
import { getPayableAmount } from '@/utils/paymentAmount';
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
@@ -402,12 +405,17 @@ const removeDialogMessage = computed(() => {
|
|||||||
: 'آیا این دانشجو از کلاس حذف شود؟';
|
: 'آیا این دانشجو از کلاس حذف شود؟';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFetching = ref(true);
|
||||||
|
|
||||||
const fetchClass = async () => {
|
const fetchClass = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await classApi.getOne(classId);
|
const res = await classApi.getOne(classId);
|
||||||
classData.value = res.data || res;
|
classData.value = res.data || res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4 relative overflow-hidden">
|
||||||
|
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات کلاس..." />
|
||||||
<form @submit.prevent="handleSubmit" class="grid">
|
<form @submit.prevent="handleSubmit" class="grid">
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">نام کلاس *</label>
|
<label class="font-semibold text-sm">نام کلاس *</label>
|
||||||
@@ -347,6 +348,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { usePermission } from '@/composables/usePermission';
|
import { usePermission } from '@/composables/usePermission';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
||||||
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
@@ -489,6 +491,7 @@ const applyCourseDefaults = (course) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const [cResult, pResult, uResult] = await Promise.allSettled([
|
const [cResult, pResult, uResult] = await Promise.allSettled([
|
||||||
courseApi.getAll({ limit: 100 }),
|
courseApi.getAll({ limit: 100 }),
|
||||||
@@ -566,6 +569,8 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- /src/views/contactInquiries/ContactInquiryDetailView.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="contact-inquiry-detail" v-if="inquiry">
|
<div class="contact-inquiry-detail relative">
|
||||||
|
<LoadingOverlay :loading="isFetching || !inquiry" message="در حال بارگذاری جزئیات درخواست تماس..." />
|
||||||
|
<div v-if="inquiry">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
:title="`${inquiry.name} ${inquiry.surname}`"
|
:title="`${inquiry.name} ${inquiry.surname}`"
|
||||||
subtitle="جزئیات و پیگیری درخواست تماس"
|
subtitle="جزئیات و پیگیری درخواست تماس"
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ import { contactInquiryApi } from '@/api/contactInquiryApi';
|
|||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
@@ -111,6 +114,7 @@ const route = useRoute();
|
|||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const isFetching = ref(true);
|
||||||
const inquiry = ref(null);
|
const inquiry = ref(null);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const form = reactive({ status: 'new', notes: '' });
|
const form = reactive({ status: 'new', notes: '' });
|
||||||
@@ -136,6 +140,7 @@ const METHOD_LABELS = {
|
|||||||
const methodLabel = (value) => METHOD_LABELS[value] || value;
|
const methodLabel = (value) => METHOD_LABELS[value] || value;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await contactInquiryApi.getOne(route.params.id);
|
const res = await contactInquiryApi.getOne(route.params.id);
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
@@ -150,6 +155,8 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- /src/views/courses/CourseDetailView.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="course-detail-view" v-if="course">
|
<div class="course-detail-view relative">
|
||||||
|
<LoadingOverlay :loading="isFetching || !course" message="در حال دریافت مشخصات دوره..." />
|
||||||
|
<div v-if="course">
|
||||||
<PageHeader :title="course.title" :subtitle="course.type === 'Private' ? 'دوره خصوصی' : 'دوره عمومی'">
|
<PageHeader :title="course.title" :subtitle="course.type === 'Private' ? 'دوره خصوصی' : 'دوره عمومی'">
|
||||||
<PermissionGate permission="courses:update">
|
<PermissionGate permission="courses:update">
|
||||||
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
|
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ import { courseApi } from '@/api/courseApi';
|
|||||||
import { sessionApi } from '@/api/sessionApi';
|
import { sessionApi } from '@/api/sessionApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
@@ -78,10 +81,12 @@ const route = useRoute();
|
|||||||
const courseId = route.params.id;
|
const courseId = route.params.id;
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
|
|
||||||
|
const isFetching = ref(true);
|
||||||
const course = ref(null);
|
const course = ref(null);
|
||||||
const courseSessions = ref([]);
|
const courseSessions = ref([]);
|
||||||
|
|
||||||
const fetchDetail = async () => {
|
const fetchDetail = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await courseApi.getOne(courseId);
|
const res = await courseApi.getOne(courseId);
|
||||||
course.value = res.data || res;
|
course.value = res.data || res;
|
||||||
@@ -99,6 +104,8 @@ const fetchDetail = async () => {
|
|||||||
courseSessions.value = Array.isArray(list) ? list : [];
|
courseSessions.value = Array.isArray(list) ? list : [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Fetch course detail error:', e);
|
console.warn('Fetch course detail error:', e);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Course Details -->
|
<!-- Course Details -->
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4 relative overflow-hidden">
|
||||||
|
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات دوره..." />
|
||||||
<h2 class="text-lg font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات اصلی دوره</h2>
|
<h2 class="text-lg font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات اصلی دوره</h2>
|
||||||
<form @submit.prevent="handleSubmitCourse" class="grid">
|
<form @submit.prevent="handleSubmitCourse" class="grid">
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
@@ -157,6 +158,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
@@ -178,6 +180,7 @@ const { showSuccess, showError } = useToast();
|
|||||||
const courseId = route.params.id;
|
const courseId = route.params.id;
|
||||||
const isEditMode = computed(() => !!courseId);
|
const isEditMode = computed(() => !!courseId);
|
||||||
|
|
||||||
|
const isFetching = ref(false);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const loadingClasses = ref(false);
|
const loadingClasses = ref(false);
|
||||||
const courseClasses = ref([]);
|
const courseClasses = ref([]);
|
||||||
@@ -219,6 +222,7 @@ const fetchClassesForCourse = async () => {
|
|||||||
|
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
if (!courseId) return;
|
if (!courseId) return;
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await courseApi.getOne(courseId);
|
const res = await courseApi.getOne(courseId);
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
@@ -233,6 +237,8 @@ const fetchCourse = async () => {
|
|||||||
highlightsText.value = Array.isArray(data.highlights) ? data.highlights.join('\n') : '';
|
highlightsText.value = Array.isArray(data.highlights) ? data.highlights.join('\n') : '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- /src/views/payments/PaymentDetailView.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="payment-detail-view" v-if="payment">
|
<div class="payment-detail-view relative">
|
||||||
|
<LoadingOverlay :loading="isFetching || !payment" message="در حال دریافت اطلاعات صورتحساب..." />
|
||||||
|
<div v-if="payment">
|
||||||
<PageHeader :title="$t('payments.paymentDetail')" :subtitle="`کد صورتحساب: ${payment._id || payment.id}`">
|
<PageHeader :title="$t('payments.paymentDetail')" :subtitle="`کد صورتحساب: ${payment._id || payment.id}`">
|
||||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
|
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -258,6 +259,7 @@
|
|||||||
<Button label="لغو تراکنش" icon="pi pi-ban" severity="danger" :loading="isSubmittingCancel" @click="handleCancelTransaction" />
|
<Button label="لغو تراکنش" icon="pi pi-ban" severity="danger" :loading="isSubmittingCancel" @click="handleCancelTransaction" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -269,6 +271,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { getPayableAmount } from '@/utils/paymentAmount';
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
@@ -352,14 +355,17 @@ const transactionStatus = (trx) => {
|
|||||||
return trx.status || (trx.date ? 'paid' : 'pending');
|
return trx.status || (trx.date ? 'paid' : 'pending');
|
||||||
};
|
};
|
||||||
|
|
||||||
const transactionRowClass = (trx) => (isTransactionCancelled(trx) ? 'transaction-row-cancelled' : '');
|
const isFetching = ref(true);
|
||||||
|
|
||||||
const fetchPayment = async () => {
|
const fetchPayment = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await paymentApi.getOne(paymentId);
|
const res = await paymentApi.getOne(paymentId);
|
||||||
payment.value = res.data || res;
|
payment.value = res.data || res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- /src/views/pendingStudents/PendingStudentDetailView.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pending-student-detail" v-if="pending">
|
<div class="pending-student-detail relative">
|
||||||
|
<LoadingOverlay :loading="isFetching || !pending" message="در حال دریافت اطلاعات ثبتنام در انتظار..." />
|
||||||
|
<div v-if="pending">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
:title="pending.user?.name || 'ثبتنام در انتظار'"
|
:title="pending.user?.name || 'ثبتنام در انتظار'"
|
||||||
subtitle="بررسی پرداخت، تأیید ثبتنام و افزودن به کلاس"
|
subtitle="بررسی پرداخت، تأیید ثبتنام و افزودن به کلاس"
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -127,6 +129,7 @@ import { pendingStudentApi } from '@/api/pendingStudentApi';
|
|||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
@@ -136,6 +139,7 @@ const router = useRouter();
|
|||||||
const { formatJalali } = usePersianDate();
|
const { formatJalali } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const isFetching = ref(true);
|
||||||
const pending = ref(null);
|
const pending = ref(null);
|
||||||
const adminNotes = ref('');
|
const adminNotes = ref('');
|
||||||
const approving = ref(false);
|
const approving = ref(false);
|
||||||
@@ -153,6 +157,7 @@ const formatPrice = (amount) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await pendingStudentApi.getOne(route.params.id);
|
const res = await pendingStudentApi.getOne(route.params.id);
|
||||||
pending.value = res.data?.data || res.data;
|
pending.value = res.data?.data || res.data;
|
||||||
@@ -160,6 +165,8 @@ async function load() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error?.response?.data?.error?.message || 'بارگذاری ناموفق بود');
|
showError(error?.response?.data?.error?.message || 'بارگذاری ناموفق بود');
|
||||||
router.push('/pending-students');
|
router.push('/pending-students');
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,54 @@
|
|||||||
<div class="professor-form-view w-full max-w-4xl mx-auto">
|
<div class="professor-form-view w-full max-w-4xl mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
:title="isEditMode ? $t('professors.editProfessor') : $t('professors.addProfessor')"
|
:title="isEditMode ? $t('professors.editProfessor') : $t('professors.addProfessor')"
|
||||||
:subtitle="isEditMode ? 'ویرایش رزومه و تخصصهای استاد' : 'ثبت نام و اطلاعات استاد جدید'"
|
:subtitle="isEditMode ? 'ویرایش رزومه و مشخصات استاد' : 'ثبت مشخصات استاد جدید یا ارتقای کاربر به استاد'"
|
||||||
>
|
>
|
||||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
|
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm relative overflow-hidden">
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات استاد..." />
|
||||||
|
|
||||||
|
<!-- Mode selection when creating -->
|
||||||
|
<div v-if="!isEditMode" class="mb-4 pb-3 border-bottom-1 border-color">
|
||||||
|
<label class="font-bold text-sm text-color block mb-2">روش افزودن استاد</label>
|
||||||
|
<SelectButton
|
||||||
|
v-model="creationMode"
|
||||||
|
:options="creationModeOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
:allowEmpty="false"
|
||||||
|
class="w-full sm:w-auto text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="creationMode === 'existing'" class="surface-ground p-3 border-round-lg border-1 border-color mt-3">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب کاربر از لیست کاربران سامانه *</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="userOptions"
|
||||||
|
optionLabel="displayName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="جستجو و انتخاب کاربر (نام، کدملی یا شماره همراه)..."
|
||||||
|
filter
|
||||||
|
filterPlaceholder="جستجو..."
|
||||||
|
class="w-full text-sm"
|
||||||
|
:loading="isLoadingUsers"
|
||||||
|
@change="onUserSelected"
|
||||||
|
/>
|
||||||
|
<small class="text-xs text-primary flex align-items-center gap-1 mt-1">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
با انتخاب کاربر، اطلاعات وی به صورت خودکار بارگذاری شده و نقش او به «استاد» ارتقا مییابد.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="grid">
|
<form @submit.prevent="handleSubmit" class="grid">
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
|
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
|
||||||
<InputText v-model.trim="form.name" class="w-full text-sm" />
|
<InputText v-model.trim="form.name" class="w-full text-sm" :disabled="creationMode === 'existing' && !form.name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
@@ -47,12 +85,17 @@
|
|||||||
|
|
||||||
<div class="col-12 flex flex-column gap-2">
|
<div class="col-12 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">{{ $t('professors.bio') }}</label>
|
<label class="font-semibold text-sm">{{ $t('professors.bio') }}</label>
|
||||||
<Textarea v-model="form.bio" rows="3" class="w-full text-sm" />
|
<Textarea v-model="form.bio" rows="3" class="w-full text-sm" placeholder="سوابق، رزومه و یادداشتهای مربوط به استاد..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
||||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
|
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
|
||||||
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:label="isEditMode ? $t('app.save') : (creationMode === 'existing' ? 'ارتقا به استاد و ذخیره' : 'ایجاد استاد')"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,11 +106,15 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { professorApi } from '@/api/professorApi';
|
import { professorApi } from '@/api/professorApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -75,8 +122,19 @@ const { showSuccess, showError } = useToast();
|
|||||||
|
|
||||||
const professorId = route.params.id;
|
const professorId = route.params.id;
|
||||||
const isEditMode = computed(() => !!professorId);
|
const isEditMode = computed(() => !!professorId);
|
||||||
|
const isFetching = ref(false);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const creationMode = ref('existing');
|
||||||
|
const creationModeOptions = [
|
||||||
|
{ label: 'انتخاب از کاربران موجود سامانه', value: 'existing' },
|
||||||
|
{ label: 'ثبت کاربر و استاد جدید', value: 'new' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedUserId = ref(null);
|
||||||
|
const userOptions = ref([]);
|
||||||
|
const isLoadingUsers = ref(false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
surname: '',
|
surname: '',
|
||||||
@@ -88,8 +146,42 @@ const form = reactive({
|
|||||||
bio: ''
|
bio: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
if (isEditMode.value) return;
|
||||||
|
isLoadingUsers.value = true;
|
||||||
|
try {
|
||||||
|
const res = await userApi.getAll({ limit: 300 });
|
||||||
|
const items = res.data?.items || res.data || [];
|
||||||
|
userOptions.value = items.map((u) => ({
|
||||||
|
_id: u._id,
|
||||||
|
displayName: `${u.name || 'بدون نام'} | ${u.phoneNumber || u.phone || '-'} | کد: ${u.uniqueCode || u.nationalIdCode || '-'}`,
|
||||||
|
raw: u
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load users list:', err);
|
||||||
|
} finally {
|
||||||
|
isLoadingUsers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserSelected = () => {
|
||||||
|
const found = userOptions.value.find((u) => u._id === selectedUserId.value);
|
||||||
|
if (found && found.raw) {
|
||||||
|
const u = found.raw;
|
||||||
|
const parts = String(u.name || '').trim().split(/\s+/);
|
||||||
|
form.name = parts[0] || u.name || '';
|
||||||
|
form.surname = parts.length > 1 ? parts.slice(1).join(' ') : (parts[0] || '');
|
||||||
|
form.nationalIdCode = u.nationalIdCode || u.nationalId || '';
|
||||||
|
form.phoneNumber = u.phoneNumber || u.phone || '';
|
||||||
|
form.email = u.email || '';
|
||||||
|
form.cardNumber = u.cardNumber || '';
|
||||||
|
form.shabaNumber = u.shabaNumber || u.iban || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchProfessor = async () => {
|
const fetchProfessor = async () => {
|
||||||
if (!professorId) return;
|
if (!professorId) return;
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await professorApi.getOne(professorId);
|
const res = await professorApi.getOne(professorId);
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
@@ -105,6 +197,8 @@ const fetchProfessor = async () => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,9 +223,16 @@ const handleSubmit = async () => {
|
|||||||
shabaNumber: form.shabaNumber || undefined,
|
shabaNumber: form.shabaNumber || undefined,
|
||||||
bio: form.bio || undefined
|
bio: form.bio || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
await professorApi.update(professorId, payload);
|
await professorApi.update(professorId, payload);
|
||||||
showSuccess('اطلاعات استاد با موفقیت ویرایش شد');
|
showSuccess('اطلاعات استاد با موفقیت ویرایش شد');
|
||||||
|
} else if (creationMode.value === 'existing' && selectedUserId.value) {
|
||||||
|
await professorApi.createFromUser({
|
||||||
|
userId: selectedUserId.value,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
showSuccess('کاربر با موفقیت به نقش استاد ارتقا یافت و پروفایل استادی ایجاد شد');
|
||||||
} else {
|
} else {
|
||||||
await professorApi.create(payload);
|
await professorApi.create(payload);
|
||||||
showSuccess('استاد جدید با موفقیت ایجاد شد');
|
showSuccess('استاد جدید با موفقیت ایجاد شد');
|
||||||
@@ -145,6 +246,10 @@ const handleSubmit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProfessor();
|
if (isEditMode.value) {
|
||||||
|
fetchProfessor();
|
||||||
|
} else {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
|
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid relative">
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات نقش..." />
|
||||||
<!-- Basic Info -->
|
<!-- Basic Info -->
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||||
@@ -149,6 +151,7 @@ import { roleApi } from '@/api/roleApi';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/constants/permissions';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import Checkbox from 'primevue/checkbox';
|
import Checkbox from 'primevue/checkbox';
|
||||||
import Badge from 'primevue/badge';
|
import Badge from 'primevue/badge';
|
||||||
@@ -160,6 +163,7 @@ const { showSuccess, showError } = useToast();
|
|||||||
|
|
||||||
const roleId = route.params.id;
|
const roleId = route.params.id;
|
||||||
const isEditMode = computed(() => !!roleId);
|
const isEditMode = computed(() => !!roleId);
|
||||||
|
const isFetching = ref(false);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const isSystemRole = ref(false);
|
const isSystemRole = ref(false);
|
||||||
|
|
||||||
@@ -227,6 +231,7 @@ const togglePermission = (key) => {
|
|||||||
// ── API ───────────────────────────────────────────────────────────────────────
|
// ── API ───────────────────────────────────────────────────────────────────────
|
||||||
const fetchRole = async () => {
|
const fetchRole = async () => {
|
||||||
if (!roleId) return;
|
if (!roleId) return;
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await roleApi.getOne(roleId);
|
const res = await roleApi.getOne(roleId);
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
@@ -238,6 +243,8 @@ const fetchRole = async () => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
|
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm relative overflow-hidden">
|
||||||
|
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات جلسه..." />
|
||||||
<form @submit.prevent="handleSubmit" class="grid">
|
<form @submit.prevent="handleSubmit" class="grid">
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">انتخاب دوره *</label>
|
<label class="font-semibold text-sm">انتخاب دوره *</label>
|
||||||
@@ -102,6 +103,7 @@ import { professorApi } from '@/api/professorApi';
|
|||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
@@ -117,6 +119,7 @@ const { toLatinDigits, toJalaliPickerValue, toGregorianIso, getTodayJalali } = u
|
|||||||
const sessionId = route.params.id;
|
const sessionId = route.params.id;
|
||||||
const isEditMode = computed(() => !!sessionId);
|
const isEditMode = computed(() => !!sessionId);
|
||||||
|
|
||||||
|
const isFetching = ref(false);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const courses = ref([]);
|
const courses = ref([]);
|
||||||
const classes = ref([]);
|
const classes = ref([]);
|
||||||
@@ -163,6 +166,7 @@ const onCourseChange = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const [cResult, pResult] = await Promise.allSettled([
|
const [cResult, pResult] = await Promise.allSettled([
|
||||||
courseApi.getAll({ limit: 100 }),
|
courseApi.getAll({ limit: 100 }),
|
||||||
@@ -205,6 +209,8 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,88 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SMS Bypass Numbers Section ──────────────────────────────── -->
|
||||||
|
<div class="mt-5 pt-4 border-top-1 border-color">
|
||||||
|
<div class="flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-3 mb-3">
|
||||||
|
<div class="flex align-items-start gap-3">
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-orange-100 text-orange-600">
|
||||||
|
<i class="pi pi-shield text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<h2 class="text-lg font-bold text-color m-0">شمارههای مجاز (Bypass سوییچ خاموش پیامک)</h2>
|
||||||
|
<Tag :value="`${toPersianDigits(smsBypassNumbers.length)} شماره`" severity="info" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted m-0 mt-1 line-height-3">
|
||||||
|
پیامکها به این شمارهها حتی در صورت خاموش بودن ارسال پیامک در داشبورد یا غیرفعال بودن سوییچهای اطلاعرسانی ارسال میشوند (مناسب تست، مدیریت و مانیتورینگ).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label="افزودن شماره مجاز"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
@click="openAddBypassDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!smsBypassNumbers.length" class="p-4 border-round-lg surface-ground border-1 border-color text-center text-muted text-sm">
|
||||||
|
<i class="pi pi-info-circle ml-1"></i>
|
||||||
|
هیچ شماره مجازی تعریف نشده است. برای افزودن شماره دکمه «افزودن شماره مجاز» را بزنید.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-column gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in smsBypassNumbers"
|
||||||
|
:key="item._id || index"
|
||||||
|
class="p-3 border-round-lg surface-ground border-1 border-color flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex align-items-center gap-3">
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg flex align-items-center justify-content-center flex-shrink-0" :class="item.isActive !== false ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'">
|
||||||
|
<i class="pi pi-phone"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-bold text-sm text-color" dir="ltr">{{ toPersianDigits(item.phoneNumber) }}</span>
|
||||||
|
<Tag v-if="item.label" :value="item.label" severity="secondary" class="text-xs" />
|
||||||
|
<Tag :value="item.isActive !== false ? 'فعال' : 'غیرفعال'" :severity="item.isActive !== false ? 'success' : 'secondary'" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<span v-if="item.createdAt" class="text-xs text-muted block mt-1">
|
||||||
|
تاریخ ثبت: {{ formatJalali(item.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<InputSwitch
|
||||||
|
:modelValue="item.isActive !== false"
|
||||||
|
@update:modelValue="toggleBypassStatus(item, index)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
tooltip="ویرایش"
|
||||||
|
@click="openEditBypassDialog(item, index)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
tooltip="حذف"
|
||||||
|
@click="deleteBypassItem(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -274,6 +356,54 @@
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- ── SMS Bypass Number Add / Edit Dialog ────────────────────────── -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showBypassDialog"
|
||||||
|
:header="isEditingBypass ? 'ویرایش شماره مجاز' : 'افزودن شماره مجاز (Bypass)'"
|
||||||
|
modal
|
||||||
|
:style="{ width: '440px' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شماره همراه *</label>
|
||||||
|
<InputText
|
||||||
|
v-model.trim="bypassForm.phoneNumber"
|
||||||
|
placeholder="مثلا: 09123456789"
|
||||||
|
dir="ltr"
|
||||||
|
class="text-sm w-full"
|
||||||
|
/>
|
||||||
|
<small class="text-xs text-muted">شماره همراه با فرمت ۰۹... وارد شود.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">عنوان یا برچسب شماره</label>
|
||||||
|
<InputText
|
||||||
|
v-model.trim="bypassForm.label"
|
||||||
|
placeholder="مثلا: مدیر سامانه، تیم پشتیبانی، تست سرور"
|
||||||
|
class="text-sm w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center justify-content-between p-3 border-round surface-ground border-1 border-color mt-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-sm text-color block">فعال بودن وضعیت Bypass</span>
|
||||||
|
<span class="text-xs text-muted">در صورت فعال بودن، پیامکها بدون توجه به سوییچها ارسال میشوند.</span>
|
||||||
|
</div>
|
||||||
|
<InputSwitch v-model="bypassForm.isActive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showBypassDialog = false" />
|
||||||
|
<Button
|
||||||
|
:label="isEditingBypass ? 'ذخیره تغییرات' : 'افزودن شماره'"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="isSavingBypass"
|
||||||
|
@click="saveBypassItem"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -290,13 +420,16 @@ import TabList from 'primevue/tablist';
|
|||||||
import Tab from 'primevue/tab';
|
import Tab from 'primevue/tab';
|
||||||
import TabPanels from 'primevue/tabpanels';
|
import TabPanels from 'primevue/tabpanels';
|
||||||
import TabPanel from 'primevue/tabpanel';
|
import TabPanel from 'primevue/tabpanel';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import { seedApi } from '@/api/seedApi';
|
import { seedApi } from '@/api/seedApi';
|
||||||
import { settingsApi } from '@/api/settingsApi';
|
import { settingsApi } from '@/api/settingsApi';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
|
|
||||||
const isSettingsLoading = ref(true);
|
const isSettingsLoading = ref(true);
|
||||||
const isSavingSettings = ref(false);
|
const isSavingSettings = ref(false);
|
||||||
@@ -306,6 +439,16 @@ const smsTemplates = ref([]);
|
|||||||
const templateForm = ref({});
|
const templateForm = ref({});
|
||||||
const notificationSettings = ref([]);
|
const notificationSettings = ref([]);
|
||||||
const notificationForm = ref({});
|
const notificationForm = ref({});
|
||||||
|
const smsBypassNumbers = ref([]);
|
||||||
|
const showBypassDialog = ref(false);
|
||||||
|
const isEditingBypass = ref(false);
|
||||||
|
const editingBypassIndex = ref(-1);
|
||||||
|
const isSavingBypass = ref(false);
|
||||||
|
const bypassForm = ref({
|
||||||
|
phoneNumber: '',
|
||||||
|
label: '',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
const messagingForm = ref({
|
const messagingForm = ref({
|
||||||
smsEnabled: true,
|
smsEnabled: true,
|
||||||
emailEnabled: true,
|
emailEnabled: true,
|
||||||
@@ -672,10 +815,110 @@ const applyNotificationSettings = (payload) => {
|
|||||||
notificationForm.value = form;
|
notificationForm.value = form;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyBypassNumbers = (payload) => {
|
||||||
|
const data = payload?.data?.data || payload?.data || payload || {};
|
||||||
|
smsBypassNumbers.value = Array.isArray(data.smsBypassNumbers) ? [...data.smsBypassNumbers] : [];
|
||||||
|
};
|
||||||
|
|
||||||
const applySettings = (payload) => {
|
const applySettings = (payload) => {
|
||||||
applySmsTemplates(payload);
|
applySmsTemplates(payload);
|
||||||
applyMessaging(payload);
|
applyMessaging(payload);
|
||||||
applyNotificationSettings(payload);
|
applyNotificationSettings(payload);
|
||||||
|
applyBypassNumbers(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddBypassDialog = () => {
|
||||||
|
bypassForm.value = {
|
||||||
|
phoneNumber: '',
|
||||||
|
label: '',
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
isEditingBypass.value = false;
|
||||||
|
editingBypassIndex.value = -1;
|
||||||
|
showBypassDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditBypassDialog = (item, index) => {
|
||||||
|
bypassForm.value = {
|
||||||
|
_id: item._id,
|
||||||
|
phoneNumber: item.phoneNumber || '',
|
||||||
|
label: item.label || '',
|
||||||
|
isActive: item.isActive !== false
|
||||||
|
};
|
||||||
|
isEditingBypass.value = true;
|
||||||
|
editingBypassIndex.value = index;
|
||||||
|
showBypassDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBypassItem = async () => {
|
||||||
|
const phone = String(bypassForm.value.phoneNumber || '').trim();
|
||||||
|
if (!phone || phone.length < 10) {
|
||||||
|
showError('شماره همراه معتبر الزامی است (حداقل ۱۰ رقم)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSavingBypass.value = true;
|
||||||
|
try {
|
||||||
|
const list = [...smsBypassNumbers.value];
|
||||||
|
if (isEditingBypass.value && editingBypassIndex.value >= 0) {
|
||||||
|
list[editingBypassIndex.value] = {
|
||||||
|
...list[editingBypassIndex.value],
|
||||||
|
phoneNumber: phone,
|
||||||
|
label: String(bypassForm.value.label || '').trim(),
|
||||||
|
isActive: bypassForm.value.isActive !== false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
list.push({
|
||||||
|
phoneNumber: phone,
|
||||||
|
label: String(bypassForm.value.label || '').trim(),
|
||||||
|
isActive: bypassForm.value.isActive !== false,
|
||||||
|
createdAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await settingsApi.save({ smsBypassNumbers: list });
|
||||||
|
applySettings(res);
|
||||||
|
showSuccess(isEditingBypass.value ? 'شماره مجاز با موفقیت ویرایش شد' : 'شماره مجاز جدید اضافه شد');
|
||||||
|
showBypassDialog.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSavingBypass.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBypassStatus = async (item, index) => {
|
||||||
|
try {
|
||||||
|
const list = [...smsBypassNumbers.value];
|
||||||
|
list[index] = { ...list[index], isActive: !list[index].isActive };
|
||||||
|
const res = await settingsApi.save({ smsBypassNumbers: list });
|
||||||
|
applySettings(res);
|
||||||
|
showSuccess(`وضعیت شماره ${item.phoneNumber} بهروزرسانی شد`);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBypassItem = (index) => {
|
||||||
|
const item = smsBypassNumbers.value[index];
|
||||||
|
confirm.require({
|
||||||
|
message: `آیا از حذف شماره «${item.phoneNumber}» از لیست مجاز اطمینان دارید؟`,
|
||||||
|
header: 'تایید حذف شماره مجاز',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
acceptLabel: 'بله، حذف شود',
|
||||||
|
rejectLabel: 'انصراف',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
const list = smsBypassNumbers.value.filter((_, i) => i !== index);
|
||||||
|
const res = await settingsApi.save({ smsBypassNumbers: list });
|
||||||
|
applySettings(res);
|
||||||
|
showSuccess('شماره با موفقیت از لیست مجاز حذف شد');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- /src/views/users/UserDetailView.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-detail-view" v-if="profileData">
|
<div class="user-detail-view relative">
|
||||||
|
<LoadingOverlay :loading="isFetching || !profileData" message="در حال بارگذاری پرونده جامع کاربر..." />
|
||||||
|
<div v-if="profileData">
|
||||||
<!-- ── Profile Header & Quick KPI Bar ──────────────────────────────────── -->
|
<!-- ── Profile Header & Quick KPI Bar ──────────────────────────────────── -->
|
||||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||||
<div class="flex flex-column md:flex-row md:align-items-center justify-content-between gap-4 pb-3 border-bottom-1 border-color">
|
<div class="flex flex-column md:flex-row md:align-items-center justify-content-between gap-4 pb-3 border-bottom-1 border-color">
|
||||||
@@ -634,6 +635,7 @@
|
|||||||
:preselected-user-id="String(userId)"
|
:preselected-user-id="String(userId)"
|
||||||
@saved="fetchFullProfile"
|
@saved="fetchFullProfile"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -646,6 +648,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
||||||
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
|
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
|
||||||
import StudentAttendanceChart from '@/components/users/charts/StudentAttendanceChart.vue';
|
import StudentAttendanceChart from '@/components/users/charts/StudentAttendanceChart.vue';
|
||||||
@@ -740,12 +743,17 @@ const getWaitlistSeverity = (status) => {
|
|||||||
return 'secondary';
|
return 'secondary';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFetching = ref(true);
|
||||||
|
|
||||||
const fetchFullProfile = async () => {
|
const fetchFullProfile = async () => {
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await userApi.getFullProfile(userId);
|
const res = await userApi.getFullProfile(userId);
|
||||||
profileData.value = res.data || res;
|
profileData.value = res.data || res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
|
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm relative overflow-hidden">
|
||||||
|
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات کاربر..." />
|
||||||
<form @submit.prevent="handleSubmit" class="grid">
|
<form @submit.prevent="handleSubmit" class="grid">
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
|
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
|
||||||
@@ -207,6 +208,7 @@ import { roleApi } from '@/api/roleApi';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||||||
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
||||||
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
@@ -223,6 +225,7 @@ const { showSuccess, showError, showWarn } = useToast();
|
|||||||
|
|
||||||
const userId = route.params.id;
|
const userId = route.params.id;
|
||||||
const isEditMode = computed(() => !!userId);
|
const isEditMode = computed(() => !!userId);
|
||||||
|
const isFetching = ref(false);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const isResettingPassword = ref(false);
|
const isResettingPassword = ref(false);
|
||||||
const filesSection = ref(null);
|
const filesSection = ref(null);
|
||||||
@@ -341,6 +344,7 @@ const fetchRoles = async () => {
|
|||||||
|
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
isFetching.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await userApi.getOne(userId);
|
const res = await userApi.getOne(userId);
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
@@ -370,6 +374,8 @@ const fetchUser = async () => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user