feat: add profile, class unenroll, notify toggles, and keep sidebar width
Give admins a profile password page, class removal, and SMS/email/bot checkboxes, and stop the sidebar from shrinking on edit pages.
This commit is contained in:
@@ -5,5 +5,6 @@ export const authApi = {
|
|||||||
login: (data) => axiosInstance.post('/auth/login', data),
|
login: (data) => axiosInstance.post('/auth/login', data),
|
||||||
refresh: (refreshToken) => axiosInstance.post('/auth/refresh', { refreshToken }),
|
refresh: (refreshToken) => axiosInstance.post('/auth/refresh', { refreshToken }),
|
||||||
logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }),
|
logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }),
|
||||||
|
changePassword: (data) => axiosInstance.put('/auth/change-password', data),
|
||||||
getSelf: () => axiosInstance.get('/users/user/get-self')
|
getSelf: () => axiosInstance.get('/users/user/get-self')
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-1
@@ -8,5 +8,8 @@ export const classApi = {
|
|||||||
create: (data) => axiosInstance.post('/classes/admin/create', data),
|
create: (data) => axiosInstance.post('/classes/admin/create', data),
|
||||||
update: (id, data) => axiosInstance.put(`/classes/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/classes/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/classes/admin/delete/${id}`),
|
delete: (id) => axiosInstance.delete(`/classes/admin/delete/${id}`),
|
||||||
registerUsers: (classId, userIds) => axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds })
|
registerUsers: (classId, userIds, notify) =>
|
||||||
|
axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds, notify }),
|
||||||
|
removeUser: (classId, userId) =>
|
||||||
|
axiosInstance.delete(`/classes/admin/${classId}/students/${userId}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!-- /src/components/common/NotifyChannelsField.vue -->
|
||||||
|
<template>
|
||||||
|
<fieldset class="notify-channels m-0 p-3 border-1 border-color border-round">
|
||||||
|
<legend class="font-semibold text-sm px-2">ارسال اطلاعرسانی</legend>
|
||||||
|
<div class="flex flex-column sm:flex-row flex-wrap gap-3">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="notify.sms" binary inputId="notify-sms" />
|
||||||
|
<label for="notify-sms" class="text-sm cursor-pointer">ارسال پیامک</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="notify.email" binary inputId="notify-email" />
|
||||||
|
<label for="notify-email" class="text-sm cursor-pointer">ارسال ایمیل</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="notify.bot" binary inputId="notify-bot" />
|
||||||
|
<label for="notify-bot" class="text-sm cursor-pointer">ارسال پیام ربات</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted m-0 mt-2 line-height-3">
|
||||||
|
پیامک در صورت فعال بودن ارسال میشود. ارسال ایمیل و پیام ربات هنوز پیادهسازی نشده است.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
notify: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notify-channels {
|
||||||
|
background: var(--surface-ground, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-channels legend {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- /src/components/layout/AppSidebar.vue -->
|
<!-- /src/components/layout/AppSidebar.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="app-sidebar p-3 surface-card border-left-1 border-color flex flex-column h-full">
|
<div class="app-sidebar p-3 surface-card border-left-1 border-color flex flex-column h-full flex-shrink-0">
|
||||||
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color">
|
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color">
|
||||||
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
|
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
|
||||||
<span class="font-bold text-xl text-color">گام نو</span>
|
<span class="font-bold text-xl text-color">گام نو</span>
|
||||||
@@ -62,11 +62,16 @@ function isMenuActive(to) {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.menu-item {
|
.menu-item {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -76,5 +81,8 @@ function isMenuActive(to) {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -122,10 +122,7 @@ const menuItems = ref([
|
|||||||
label: t('app.profile'),
|
label: t('app.profile'),
|
||||||
icon: 'pi pi-user',
|
icon: 'pi pi-user',
|
||||||
command: () => {
|
command: () => {
|
||||||
const id = authStore.user?._id || authStore.user?.id;
|
router.push('/profile');
|
||||||
if (id) {
|
|
||||||
router.push(`/users/edit/${id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard-layout min-h-screen flex flex-column">
|
<div class="dashboard-layout min-h-screen flex flex-column">
|
||||||
<AppTopbar />
|
<AppTopbar />
|
||||||
<div class="layout-main-container flex flex-grow-1">
|
<div class="layout-main-container flex flex-grow-1 min-w-0">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main class="layout-main flex-grow-1 p-4 overflow-x-hidden">
|
<main class="layout-main flex-grow-1 p-4 overflow-x-hidden min-w-0">
|
||||||
<AppBreadcrumb />
|
<AppBreadcrumb />
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
@@ -23,7 +23,12 @@ import AppBreadcrumb from '@/components/layout/AppBreadcrumb.vue';
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-main-container {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-main {
|
.layout-main {
|
||||||
|
min-width: 0;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+3
-2
@@ -13,7 +13,7 @@
|
|||||||
"updated": "تاریخ بروزرسانی",
|
"updated": "تاریخ بروزرسانی",
|
||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری...",
|
||||||
"noData": "اطلاعاتی یافت نشد",
|
"noData": "اطلاعاتی یافت نشد",
|
||||||
"profile": "پروفایل",
|
"profile": "پروفایل من",
|
||||||
"logout": "خروج"
|
"logout": "خروج"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -42,7 +42,8 @@
|
|||||||
"contactInquiries": "درخواستهای تماس",
|
"contactInquiries": "درخواستهای تماس",
|
||||||
"certificates": "گواهی نامه ها",
|
"certificates": "گواهی نامه ها",
|
||||||
"logs": "گزارش فعالیتها",
|
"logs": "گزارش فعالیتها",
|
||||||
"settings": "تنظیمات"
|
"settings": "تنظیمات",
|
||||||
|
"profile": "پروفایل من"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "مدیریت کاربران",
|
"title": "مدیریت کاربران",
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export const routes = [
|
|||||||
name: 'DashboardHome',
|
name: 'DashboardHome',
|
||||||
component: () => import('@/views/dashboard/DashboardHomeView.vue')
|
component: () => import('@/views/dashboard/DashboardHomeView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
name: 'Profile',
|
||||||
|
component: () => import('@/views/profile/ProfileView.vue'),
|
||||||
|
meta: { title: 'پروفایل' }
|
||||||
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,6 +52,22 @@
|
|||||||
<Column header="موبایل">
|
<Column header="موبایل">
|
||||||
<template #body="{ data }"><span dir="ltr">{{ data.phoneNumber || '—' }}</span></template>
|
<template #body="{ data }"><span dir="ltr">{{ data.phoneNumber || '—' }}</span></template>
|
||||||
</Column>
|
</Column>
|
||||||
|
<Column v-if="canManageStudents" header="عملیات" style="width: 90px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<PermissionGate permission="classes:register_users">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-user-minus"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'حذف از کلاس'"
|
||||||
|
:loading="removingUserId === String(data._id || data.id)"
|
||||||
|
@click="confirmRemoveStudent(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,19 +110,30 @@
|
|||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model="removeDialogVisible"
|
||||||
|
title="حذف از کلاس"
|
||||||
|
:message="removeDialogMessage"
|
||||||
|
:loading="!!removingUserId"
|
||||||
|
@confirm="handleRemoveStudent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { classApi } from '@/api/classApi';
|
import { classApi } from '@/api/classApi';
|
||||||
import { sessionApi } from '@/api/sessionApi';
|
import { sessionApi } from '@/api/sessionApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { usePermission } from '@/composables/usePermission';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.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 PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
@@ -114,11 +141,23 @@ import Column from 'primevue/column';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const classId = route.params.id;
|
const classId = route.params.id;
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
const { showError } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
||||||
|
|
||||||
const classData = ref(null);
|
const classData = ref(null);
|
||||||
const sessions = ref([]);
|
const sessions = ref([]);
|
||||||
const loadingSessions = ref(false);
|
const loadingSessions = ref(false);
|
||||||
|
const removeDialogVisible = ref(false);
|
||||||
|
const removingUserId = ref('');
|
||||||
|
const studentToRemove = ref(null);
|
||||||
|
|
||||||
|
const removeDialogMessage = computed(() => {
|
||||||
|
const name = studentToRemove.value?.name;
|
||||||
|
return name
|
||||||
|
? `آیا «${name}» از این کلاس حذف شود؟`
|
||||||
|
: 'آیا این دانشجو از کلاس حذف شود؟';
|
||||||
|
});
|
||||||
|
|
||||||
const fetchClass = async () => {
|
const fetchClass = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -159,4 +198,26 @@ onMounted(() => {
|
|||||||
fetchClass();
|
fetchClass();
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const confirmRemoveStudent = (student) => {
|
||||||
|
studentToRemove.value = student;
|
||||||
|
removeDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStudent = async () => {
|
||||||
|
const userId = studentToRemove.value?._id || studentToRemove.value?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
removingUserId.value = String(userId);
|
||||||
|
try {
|
||||||
|
const res = await classApi.removeUser(classId, userId);
|
||||||
|
classData.value = res.data || res;
|
||||||
|
removeDialogVisible.value = false;
|
||||||
|
studentToRemove.value = null;
|
||||||
|
showSuccess('دانشجو از کلاس حذف شد');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
removingUserId.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -96,6 +96,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<NotifyChannelsField :notify="registerNotify" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable :value="students" class="p-datatable-sm text-sm" emptyMessage="هنوز دانشجویی ثبتنام نشده است">
|
<DataTable :value="students" class="p-datatable-sm text-sm" emptyMessage="هنوز دانشجویی ثبتنام نشده است">
|
||||||
<Column header="#" style="width: 50px">
|
<Column header="#" style="width: 50px">
|
||||||
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
||||||
@@ -108,6 +112,22 @@
|
|||||||
<span dir="ltr">{{ data.phoneNumber || '—' }}</span>
|
<span dir="ltr">{{ data.phoneNumber || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
<Column v-if="canManageStudents" header="عملیات" style="width: 90px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<PermissionGate permission="classes:register_users">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-user-minus"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'حذف از کلاس'"
|
||||||
|
:loading="removingUserId === String(data._id || data.id)"
|
||||||
|
@click="confirmRemoveStudent(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,6 +139,14 @@
|
|||||||
:default-session-count="defaultSessionCount"
|
:default-session-count="defaultSessionCount"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model="removeDialogVisible"
|
||||||
|
title="حذف از کلاس"
|
||||||
|
:message="removeDialogMessage"
|
||||||
|
:loading="!!removingUserId"
|
||||||
|
@confirm="handleRemoveStudent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,9 +160,13 @@ import { professorApi } from '@/api/professorApi';
|
|||||||
import { userApi } from '@/api/userApi';
|
import { userApi } from '@/api/userApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { usePermission } from '@/composables/usePermission';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.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 ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import InputNumber from 'primevue/inputnumber';
|
import InputNumber from 'primevue/inputnumber';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
@@ -149,14 +181,20 @@ import DatePicker from 'vue3-persian-datetime-picker';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const { toPersianDigits, toLatinDigits } = usePersianDate();
|
const { toPersianDigits, toLatinDigits } = usePersianDate();
|
||||||
|
|
||||||
const classId = route.params.id;
|
const classId = route.params.id;
|
||||||
const queryCourseId = route.query.courseId ? String(route.query.courseId) : null;
|
const queryCourseId = route.query.courseId ? String(route.query.courseId) : null;
|
||||||
const isEditMode = computed(() => !!classId);
|
const isEditMode = computed(() => !!classId);
|
||||||
|
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const registering = ref(false);
|
const registering = ref(false);
|
||||||
const defaultSessionCount = ref(12);
|
const defaultSessionCount = ref(12);
|
||||||
|
const registerNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
const removeDialogVisible = ref(false);
|
||||||
|
const removingUserId = ref('');
|
||||||
|
const studentToRemove = ref(null);
|
||||||
|
|
||||||
const courses = ref([]);
|
const courses = ref([]);
|
||||||
const professors = ref([]);
|
const professors = ref([]);
|
||||||
@@ -176,6 +214,13 @@ const form = reactive({
|
|||||||
adminNotes: []
|
adminNotes: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const removeDialogMessage = computed(() => {
|
||||||
|
const name = studentToRemove.value?.name;
|
||||||
|
return name
|
||||||
|
? `آیا «${name}» از این کلاس حذف شود؟`
|
||||||
|
: 'آیا این دانشجو از کلاس حذف شود؟';
|
||||||
|
});
|
||||||
|
|
||||||
const availableUsers = computed(() => {
|
const availableUsers = computed(() => {
|
||||||
const enrolled = new Set(students.value.map((s) => String(s._id)));
|
const enrolled = new Set(students.value.map((s) => String(s._id)));
|
||||||
return allUsers.value
|
return allUsers.value
|
||||||
@@ -308,7 +353,7 @@ const registerSelected = async () => {
|
|||||||
if (!selectedUserIds.value.length) return;
|
if (!selectedUserIds.value.length) return;
|
||||||
registering.value = true;
|
registering.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await classApi.registerUsers(classId, selectedUserIds.value);
|
const res = await classApi.registerUsers(classId, selectedUserIds.value, { ...registerNotify });
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
students.value = data.students || [];
|
students.value = data.students || [];
|
||||||
selectedUserIds.value = [];
|
selectedUserIds.value = [];
|
||||||
@@ -320,5 +365,28 @@ const registerSelected = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmRemoveStudent = (student) => {
|
||||||
|
studentToRemove.value = student;
|
||||||
|
removeDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStudent = async () => {
|
||||||
|
const userId = studentToRemove.value?._id || studentToRemove.value?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
removingUserId.value = String(userId);
|
||||||
|
try {
|
||||||
|
const res = await classApi.removeUser(classId, userId);
|
||||||
|
const data = res.data || res;
|
||||||
|
students.value = data.students || [];
|
||||||
|
removeDialogVisible.value = false;
|
||||||
|
studentToRemove.value = null;
|
||||||
|
showSuccess('دانشجو از کلاس حذف شد');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
removingUserId.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(fetchData);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" />
|
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="createNotify" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
|
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
|
||||||
@@ -148,6 +150,7 @@ import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
|||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.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 Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Tag from 'primevue/tag';
|
import Tag from 'primevue/tag';
|
||||||
@@ -182,6 +185,7 @@ const createForm = reactive({
|
|||||||
amount: 0,
|
amount: 0,
|
||||||
dueDate: ''
|
dueDate: ''
|
||||||
});
|
});
|
||||||
|
const createNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
|
||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const selectedPayment = ref(null);
|
const selectedPayment = ref(null);
|
||||||
@@ -238,6 +242,9 @@ const resetCreateForm = () => {
|
|||||||
createForm.classes = [];
|
createForm.classes = [];
|
||||||
createForm.amount = 0;
|
createForm.amount = 0;
|
||||||
createForm.dueDate = '';
|
createForm.dueDate = '';
|
||||||
|
createNotify.sms = true;
|
||||||
|
createNotify.email = true;
|
||||||
|
createNotify.bot = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
@@ -270,7 +277,7 @@ const handleCreatePayment = async () => {
|
|||||||
|
|
||||||
isCreating.value = true;
|
isCreating.value = true;
|
||||||
try {
|
try {
|
||||||
await paymentApi.create(createForm);
|
await paymentApi.create({ ...createForm, notify: { ...createNotify } });
|
||||||
showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
|
showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
|
||||||
showCreateModal.value = false;
|
showCreateModal.value = false;
|
||||||
loadData();
|
loadData();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<!-- /src/views/profile/ProfileView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="profile-view w-full max-w-4xl mx-auto">
|
||||||
|
<PageHeader title="پروفایل من" subtitle="مشاهده اطلاعات حساب و تغییر رمز عبور" />
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="surface-card p-5 border-round border-1 border-color shadow-sm">
|
||||||
|
<p class="text-muted text-sm m-0">در حال بارگذاری پروفایل…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="profile">
|
||||||
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||||
|
<div class="flex align-items-center gap-3 mb-4 pb-3 border-bottom-1 border-color">
|
||||||
|
<Avatar icon="pi pi-user" size="large" shape="circle" class="bg-primary text-white" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-color m-0">{{ profile.name || profile.username }}</h2>
|
||||||
|
<p class="text-muted text-sm m-0 mt-1" dir="ltr">{{ profile.username }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">نام</span>
|
||||||
|
<span class="font-semibold text-color text-sm">{{ profile.name || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">نام کاربری</span>
|
||||||
|
<span class="font-semibold text-color text-sm" dir="ltr">{{ profile.username || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">نقش</span>
|
||||||
|
<Tag :value="roleLabel" severity="info" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">شماره همراه</span>
|
||||||
|
<span class="font-semibold text-color text-sm" dir="ltr">{{ profile.phoneNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">ایمیل</span>
|
||||||
|
<span class="font-semibold text-color text-sm" dir="ltr">{{ profile.email || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">وضعیت حساب</span>
|
||||||
|
<StatusTag :status="profile.isActive !== false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
||||||
|
<h2 class="text-lg font-bold text-color m-0 mb-1">تغییر رمز عبور</h2>
|
||||||
|
<p class="text-muted text-sm mt-1 mb-4">
|
||||||
|
برای تغییر رمز، ابتدا رمز فعلی را وارد کنید. پس از ذخیره، نشستهای دیگر از حساب خارج میشوند.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleChangePassword" class="grid">
|
||||||
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="current-password">رمز عبور فعلی *</label>
|
||||||
|
<Password
|
||||||
|
inputId="current-password"
|
||||||
|
v-model="passwordForm.currentPassword"
|
||||||
|
toggleMask
|
||||||
|
:feedback="false"
|
||||||
|
class="w-full"
|
||||||
|
inputClass="w-full text-sm"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-6"></div>
|
||||||
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="new-password">رمز عبور جدید *</label>
|
||||||
|
<Password
|
||||||
|
inputId="new-password"
|
||||||
|
v-model="passwordForm.newPassword"
|
||||||
|
toggleMask
|
||||||
|
:feedback="false"
|
||||||
|
class="w-full"
|
||||||
|
inputClass="w-full text-sm"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-muted">حداقل ۶ نویسه</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="confirm-password">تکرار رمز عبور جدید *</label>
|
||||||
|
<Password
|
||||||
|
inputId="confirm-password"
|
||||||
|
v-model="passwordForm.confirmPassword"
|
||||||
|
toggleMask
|
||||||
|
:feedback="false"
|
||||||
|
class="w-full"
|
||||||
|
inputClass="w-full text-sm"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 flex justify-content-end gap-2 mt-3 pt-3 border-top-1 border-color">
|
||||||
|
<Button type="submit" label="تغییر رمز عبور" icon="pi pi-lock" :loading="isSubmitting" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { authApi } from '@/api/authApi';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import Avatar from 'primevue/avatar';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Password from 'primevue/password';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 6;
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const profile = ref(null);
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
const role = profile.value?.role;
|
||||||
|
if (!role) return '—';
|
||||||
|
return typeof role === 'object' ? (role.name || '—') : String(role);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPasswordForm = () => {
|
||||||
|
passwordForm.currentPassword = '';
|
||||||
|
passwordForm.newPassword = '';
|
||||||
|
passwordForm.confirmPassword = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await authApi.getSelf();
|
||||||
|
profile.value = res.data || res;
|
||||||
|
if (profile.value) {
|
||||||
|
authStore.setUser({
|
||||||
|
...(authStore.user || {}),
|
||||||
|
...profile.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!passwordForm.currentPassword) {
|
||||||
|
showError('رمز عبور فعلی را وارد کنید');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!passwordForm.newPassword || passwordForm.newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
showError('رمز عبور جدید باید حداقل ۶ نویسه باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||||
|
showError('رمز عبور جدید و تکرار آن یکسان نیستند');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (passwordForm.newPassword === passwordForm.currentPassword) {
|
||||||
|
showError('رمز عبور جدید باید با رمز فعلی متفاوت باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await authApi.changePassword({
|
||||||
|
currentPassword: passwordForm.currentPassword,
|
||||||
|
newPassword: passwordForm.newPassword,
|
||||||
|
refreshToken: authStore.refreshToken
|
||||||
|
});
|
||||||
|
resetPasswordForm();
|
||||||
|
showSuccess('رمز عبور با موفقیت تغییر کرد');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchProfile);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.p-password) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-password-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="user-form-view w-full max-w-4xl mx-auto">
|
<div class="user-form-view w-full max-w-4xl mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
:title="isEditMode ? $t('users.editUser') : $t('users.addUser')"
|
:title="isEditMode ? $t('users.editUser') : $t('users.addUser')"
|
||||||
:subtitle="isEditMode ? 'ویرایش اطلاعات حساب کاربر' : 'ایجاد حساب کاربری جدید — نام کاربری و رمز عبور بهصورت خودکار ساخته و پیامک میشود'"
|
:subtitle="isEditMode ? 'ویرایش اطلاعات حساب کاربر' : 'ایجاد حساب کاربری جدید — نام کاربری و رمز عبور بهصورت خودکار ساخته میشود'"
|
||||||
>
|
>
|
||||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
|
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -123,9 +123,13 @@
|
|||||||
<InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" />
|
<InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isEditMode" class="col-12">
|
||||||
|
<NotifyChannelsField :notify="createNotify" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="!isEditMode" class="col-12">
|
<div v-if="!isEditMode" class="col-12">
|
||||||
<p class="text-sm text-color-secondary m-0 line-height-3">
|
<p class="text-sm text-color-secondary m-0 line-height-3">
|
||||||
نام کاربری و رمز عبور ساده بهصورت خودکار ساخته میشود و از طریق پیامک برای کاربر ارسال میگردد. بعداً میتوان رمز را تغییر داد.
|
نام کاربری و رمز عبور ساده بهصورت خودکار ساخته میشود. اگر ارسال پیامک فعال باشد، اطلاعات ورود برای کاربر پیامک میگردد. بعداً میتوان رمز را تغییر داد.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,6 +180,7 @@ import { useToast } from '@/composables/useToast';
|
|||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.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 InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import MultiSelect from 'primevue/multiselect';
|
import MultiSelect from 'primevue/multiselect';
|
||||||
@@ -193,6 +198,7 @@ const filesSection = ref(null);
|
|||||||
const roles = ref([]);
|
const roles = ref([]);
|
||||||
const credentialsVisible = ref(false);
|
const credentialsVisible = ref(false);
|
||||||
const createdCredentials = reactive({ username: '', password: '' });
|
const createdCredentials = reactive({ username: '', password: '' });
|
||||||
|
const createNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
|
||||||
const messengerOptions = [
|
const messengerOptions = [
|
||||||
{ label: 'پیامک (SMS)', value: 'SMS' },
|
{ label: 'پیامک (SMS)', value: 'SMS' },
|
||||||
@@ -342,7 +348,7 @@ const handleSubmit = async () => {
|
|||||||
showSuccess('اطلاعات کاربر با موفقیت ویرایش شد');
|
showSuccess('اطلاعات کاربر با موفقیت ویرایش شد');
|
||||||
router.push('/users');
|
router.push('/users');
|
||||||
} else {
|
} else {
|
||||||
const res = await userApi.create(payload);
|
const res = await userApi.create({ ...payload, notify: { ...createNotify } });
|
||||||
const data = res.data || res;
|
const data = res.data || res;
|
||||||
const creds = data.generatedCredentials || {};
|
const creds = data.generatedCredentials || {};
|
||||||
createdCredentials.username = creds.username || data.username || '';
|
createdCredentials.username = creds.username || data.username || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user