Compare commits

...
3 Commits
16 changed files with 551 additions and 26 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/
+2 -2
View File
@@ -2,9 +2,9 @@ FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
COPY package*.json .npmrc* ./
RUN npm install
RUN npm install --registry=https://registry.npmmirror.com
COPY . .
+103
View File
@@ -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>
+10 -2
View File
@@ -1,6 +1,7 @@
<!-- /src/views/classes/ClassDetailView.vue -->
<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 || '-'}`">
<PermissionGate permission="financial_reports:read">
<Button
@@ -241,6 +242,7 @@
/>
</template>
</Dialog>
</div>
</div>
</template>
@@ -256,6 +258,7 @@ import { useToast } from '@/composables/useToast';
import { usePermission } from '@/composables/usePermission';
import { getPayableAmount } from '@/utils/paymentAmount';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
@@ -402,12 +405,17 @@ const removeDialogMessage = computed(() => {
: 'آیا این دانشجو از کلاس حذف شود؟';
});
const isFetching = ref(true);
const fetchClass = async () => {
isFetching.value = true;
try {
const res = await classApi.getOne(classId);
classData.value = res.data || res;
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
+6 -1
View File
@@ -24,7 +24,8 @@
</div>
</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">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">نام کلاس *</label>
@@ -347,6 +348,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { usePermission } from '@/composables/usePermission';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
@@ -489,6 +491,7 @@ const applyCourseDefaults = (course) => {
};
const fetchData = async () => {
isFetching.value = true;
try {
const [cResult, pResult, uResult] = await Promise.allSettled([
courseApi.getAll({ limit: 100 }),
@@ -566,6 +569,8 @@ const fetchData = async () => {
}
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
@@ -1,6 +1,7 @@
<!-- /src/views/contactInquiries/ContactInquiryDetailView.vue -->
<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
:title="`${inquiry.name} ${inquiry.surname}`"
subtitle="جزئیات و پیگیری درخواست تماس"
@@ -92,6 +93,7 @@
</div>
</div>
</div>
</div>
</div>
</template>
@@ -102,6 +104,7 @@ import { contactInquiryApi } from '@/api/contactInquiryApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import Button from 'primevue/button';
import Dropdown from 'primevue/select';
import Textarea from 'primevue/textarea';
@@ -111,6 +114,7 @@ const route = useRoute();
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const isFetching = ref(true);
const inquiry = ref(null);
const saving = ref(false);
const form = reactive({ status: 'new', notes: '' });
@@ -136,6 +140,7 @@ const METHOD_LABELS = {
const methodLabel = (value) => METHOD_LABELS[value] || value;
const load = async () => {
isFetching.value = true;
try {
const res = await contactInquiryApi.getOne(route.params.id);
const data = res.data || res;
@@ -150,6 +155,8 @@ const load = async () => {
}
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
+9 -2
View File
@@ -1,6 +1,7 @@
<!-- /src/views/courses/CourseDetailView.vue -->
<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' ? 'دوره خصوصی' : 'دوره عمومی'">
<PermissionGate permission="courses:update">
<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>
</template>
@@ -67,6 +69,7 @@ import { courseApi } from '@/api/courseApi';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
@@ -78,10 +81,12 @@ const route = useRoute();
const courseId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const isFetching = ref(true);
const course = ref(null);
const courseSessions = ref([]);
const fetchDetail = async () => {
isFetching.value = true;
try {
const res = await courseApi.getOne(courseId);
course.value = res.data || res;
@@ -99,6 +104,8 @@ const fetchDetail = async () => {
courseSessions.value = Array.isArray(list) ? list : [];
} catch (e) {
console.warn('Fetch course detail error:', e);
} finally {
isFetching.value = false;
}
};
+7 -1
View File
@@ -9,7 +9,8 @@
</PageHeader>
<!-- 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>
<form @submit.prevent="handleSubmitCourse" class="grid">
<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 PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext';
@@ -178,6 +180,7 @@ const { showSuccess, showError } = useToast();
const courseId = route.params.id;
const isEditMode = computed(() => !!courseId);
const isFetching = ref(false);
const isSubmitting = ref(false);
const loadingClasses = ref(false);
const courseClasses = ref([]);
@@ -219,6 +222,7 @@ const fetchClassesForCourse = async () => {
const fetchCourse = async () => {
if (!courseId) return;
isFetching.value = true;
try {
const res = await courseApi.getOne(courseId);
const data = res.data || res;
@@ -233,6 +237,8 @@ const fetchCourse = async () => {
highlightsText.value = Array.isArray(data.highlights) ? data.highlights.join('\n') : '';
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
+9 -3
View File
@@ -1,6 +1,7 @@
<!-- /src/views/payments/PaymentDetailView.vue -->
<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}`">
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
</PageHeader>
@@ -258,6 +259,7 @@
<Button label="لغو تراکنش" icon="pi pi-ban" severity="danger" :loading="isSubmittingCancel" @click="handleCancelTransaction" />
</template>
</Dialog>
</div>
</div>
</template>
@@ -269,6 +271,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { getPayableAmount } from '@/utils/paymentAmount';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
@@ -352,14 +355,17 @@ const transactionStatus = (trx) => {
return trx.status || (trx.date ? 'paid' : 'pending');
};
const transactionRowClass = (trx) => (isTransactionCancelled(trx) ? 'transaction-row-cancelled' : '');
const isFetching = ref(true);
const fetchPayment = async () => {
isFetching.value = true;
try {
const res = await paymentApi.getOne(paymentId);
payment.value = res.data || res;
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
@@ -1,6 +1,7 @@
<!-- /src/views/pendingStudents/PendingStudentDetailView.vue -->
<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
:title="pending.user?.name || 'ثبت‌نام در انتظار'"
subtitle="بررسی پرداخت، تأیید ثبت‌نام و افزودن به کلاس"
@@ -117,6 +118,7 @@
</div>
</div>
</div>
</div>
</div>
</template>
@@ -127,6 +129,7 @@ import { pendingStudentApi } from '@/api/pendingStudentApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Textarea from 'primevue/textarea';
@@ -136,6 +139,7 @@ const router = useRouter();
const { formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const isFetching = ref(true);
const pending = ref(null);
const adminNotes = ref('');
const approving = ref(false);
@@ -153,6 +157,7 @@ const formatPrice = (amount) => {
};
async function load() {
isFetching.value = true;
try {
const res = await pendingStudentApi.getOne(route.params.id);
pending.value = res.data?.data || res.data;
@@ -160,6 +165,8 @@ async function load() {
} catch (error) {
showError(error?.response?.data?.error?.message || 'بارگذاری ناموفق بود');
router.push('/pending-students');
} finally {
isFetching.value = false;
}
}
+111 -6
View File
@@ -3,16 +3,54 @@
<div class="professor-form-view w-full max-w-4xl mx-auto">
<PageHeader
:title="isEditMode ? $t('professors.editProfessor') : $t('professors.addProfessor')"
:subtitle="isEditMode ? 'ویرایش رزومه و تخصص‌های استاد' : 'ثبت نام و اطلاعات استاد جدید'"
:subtitle="isEditMode ? 'ویرایش رزومه و مشخصات استاد' : 'ثبت مشخصات استاد جدید یا ارتقای کاربر به استاد'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
</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">
<div class="col-12 md:col-6 flex flex-column gap-2">
<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 class="col-12 md:col-6 flex flex-column gap-2">
@@ -47,12 +85,17 @@
<div class="col-12 flex flex-column gap-2">
<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 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 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>
</form>
</div>
@@ -63,11 +106,15 @@
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { professorApi } from '@/api/professorApi';
import { userApi } from '@/api/userApi';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
import SelectButton from 'primevue/selectbutton';
import Select from 'primevue/select';
const route = useRoute();
const router = useRouter();
@@ -75,8 +122,19 @@ const { showSuccess, showError } = useToast();
const professorId = route.params.id;
const isEditMode = computed(() => !!professorId);
const isFetching = 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({
name: '',
surname: '',
@@ -88,8 +146,42 @@ const form = reactive({
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 () => {
if (!professorId) return;
isFetching.value = true;
try {
const res = await professorApi.getOne(professorId);
const data = res.data || res;
@@ -105,6 +197,8 @@ const fetchProfessor = async () => {
});
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
@@ -129,9 +223,16 @@ const handleSubmit = async () => {
shabaNumber: form.shabaNumber || undefined,
bio: form.bio || undefined
};
if (isEditMode.value) {
await professorApi.update(professorId, payload);
showSuccess('اطلاعات استاد با موفقیت ویرایش شد');
} else if (creationMode.value === 'existing' && selectedUserId.value) {
await professorApi.createFromUser({
userId: selectedUserId.value,
...payload
});
showSuccess('کاربر با موفقیت به نقش استاد ارتقا یافت و پروفایل استادی ایجاد شد');
} else {
await professorApi.create(payload);
showSuccess('استاد جدید با موفقیت ایجاد شد');
@@ -145,6 +246,10 @@ const handleSubmit = async () => {
};
onMounted(() => {
fetchProfessor();
if (isEditMode.value) {
fetchProfessor();
} else {
loadUsers();
}
});
</script>
+8 -1
View File
@@ -8,7 +8,9 @@
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
</PageHeader>
<div class="grid">
<div class="grid relative">
<!-- Loading Overlay -->
<LoadingOverlay :loading="isFetching" message="در حال دریافت اطلاعات نقش..." />
<!-- Basic Info -->
<div class="col-12">
<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 { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/constants/permissions';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import InputText from 'primevue/inputtext';
import Checkbox from 'primevue/checkbox';
import Badge from 'primevue/badge';
@@ -160,6 +163,7 @@ const { showSuccess, showError } = useToast();
const roleId = route.params.id;
const isEditMode = computed(() => !!roleId);
const isFetching = ref(false);
const isSubmitting = ref(false);
const isSystemRole = ref(false);
@@ -227,6 +231,7 @@ const togglePermission = (key) => {
// API
const fetchRole = async () => {
if (!roleId) return;
isFetching.value = true;
try {
const res = await roleApi.getOne(roleId);
const data = res.data || res;
@@ -238,6 +243,8 @@ const fetchRole = async () => {
});
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
+7 -1
View File
@@ -8,7 +8,8 @@
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
</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">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب دوره *</label>
@@ -102,6 +103,7 @@ import { professorApi } from '@/api/professorApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
@@ -117,6 +119,7 @@ const { toLatinDigits, toJalaliPickerValue, toGregorianIso, getTodayJalali } = u
const sessionId = route.params.id;
const isEditMode = computed(() => !!sessionId);
const isFetching = ref(false);
const isSubmitting = ref(false);
const courses = ref([]);
const classes = ref([]);
@@ -163,6 +166,7 @@ const onCourseChange = async () => {
};
const fetchData = async () => {
isFetching.value = true;
try {
const [cResult, pResult] = await Promise.allSettled([
courseApi.getAll({ limit: 100 }),
@@ -205,6 +209,8 @@ const fetchData = async () => {
}
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
+243
View File
@@ -81,6 +81,88 @@
/>
</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>
</TabPanel>
@@ -274,6 +356,54 @@
</TabPanel>
</TabPanels>
</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>
</template>
@@ -290,13 +420,16 @@ import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import Dialog from 'primevue/dialog';
import PageHeader from '@/components/common/PageHeader.vue';
import { seedApi } from '@/api/seedApi';
import { settingsApi } from '@/api/settingsApi';
import { useToast } from '@/composables/useToast';
import { usePersianDate } from '@/composables/usePersianDate';
const confirm = useConfirm();
const { showSuccess, showError } = useToast();
const { toPersianDigits, formatJalali } = usePersianDate();
const isSettingsLoading = ref(true);
const isSavingSettings = ref(false);
@@ -306,6 +439,16 @@ const smsTemplates = ref([]);
const templateForm = ref({});
const notificationSettings = 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({
smsEnabled: true,
emailEnabled: true,
@@ -672,10 +815,110 @@ const applyNotificationSettings = (payload) => {
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) => {
applySmsTemplates(payload);
applyMessaging(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 () => {
+10 -2
View File
@@ -1,6 +1,7 @@
<!-- /src/views/users/UserDetailView.vue -->
<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 -->
<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">
@@ -634,6 +635,7 @@
:preselected-user-id="String(userId)"
@saved="fetchFullProfile"
/>
</div>
</div>
</template>
@@ -646,6 +648,7 @@ import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
import StudentAttendanceChart from '@/components/users/charts/StudentAttendanceChart.vue';
@@ -740,12 +743,17 @@ const getWaitlistSeverity = (status) => {
return 'secondary';
};
const isFetching = ref(true);
const fetchFullProfile = async () => {
isFetching.value = true;
try {
const res = await userApi.getFullProfile(userId);
profileData.value = res.data || res;
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};
+7 -1
View File
@@ -8,7 +8,8 @@
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
</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">
<div class="col-12 md:col-6 flex flex-column gap-2">
<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 { useConfirm } from 'primevue/useconfirm';
import PageHeader from '@/components/common/PageHeader.vue';
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
@@ -223,6 +225,7 @@ const { showSuccess, showError, showWarn } = useToast();
const userId = route.params.id;
const isEditMode = computed(() => !!userId);
const isFetching = ref(false);
const isSubmitting = ref(false);
const isResettingPassword = ref(false);
const filesSection = ref(null);
@@ -341,6 +344,7 @@ const fetchRoles = async () => {
const fetchUser = async () => {
if (!userId) return;
isFetching.value = true;
try {
const res = await userApi.getOne(userId);
const data = res.data || res;
@@ -370,6 +374,8 @@ const fetchUser = async () => {
});
} catch (err) {
showError(err);
} finally {
isFetching.value = false;
}
};