feat(users,professors): add student-to-professor promotion dialog, complete student profile view and charts
This commit is contained in:
@@ -6,6 +6,7 @@ export const professorApi = {
|
|||||||
search: (params) => axiosInstance.get('/professors/admin/search', { params }),
|
search: (params) => axiosInstance.get('/professors/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/professors/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/professors/admin/get-one/${id}`),
|
||||||
create: (data) => axiosInstance.post('/professors/admin/create', data),
|
create: (data) => axiosInstance.post('/professors/admin/create', data),
|
||||||
|
createFromUser: (data) => axiosInstance.post('/professors/admin/create-from-user', data),
|
||||||
update: (id, data) => axiosInstance.put(`/professors/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/professors/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/professors/admin/delete/${id}`)
|
delete: (id) => axiosInstance.delete(`/professors/admin/delete/${id}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ export const userApi = {
|
|||||||
getAll: (params) => axiosInstance.get('/users/admin/get-all', { params }),
|
getAll: (params) => axiosInstance.get('/users/admin/get-all', { params }),
|
||||||
search: (params) => axiosInstance.get('/users/admin/search', { params }),
|
search: (params) => axiosInstance.get('/users/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`),
|
||||||
|
getFullProfile: (id) => axiosInstance.get(`/users/admin/${id}/full-profile`),
|
||||||
create: (data) => axiosInstance.post('/users/admin/create', data),
|
create: (data) => axiosInstance.post('/users/admin/create', data),
|
||||||
update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data),
|
||||||
|
promoteToProfessor: (id, data) => axiosInstance.post(`/users/admin/${id}/promote-to-professor`, data),
|
||||||
resetPasswordAndSms: (id) => axiosInstance.post(
|
resetPasswordAndSms: (id) => axiosInstance.post(
|
||||||
`/users/admin/${id}/reset-password-sms`,
|
`/users/admin/${id}/reset-password-sms`,
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<!-- /src/components/professors/AddProfessorFromUserDialog.vue -->
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="visible"
|
||||||
|
header="افزودن استاد از بین دانشجویان / کاربران"
|
||||||
|
modal
|
||||||
|
:style="{ width: '600px', maxWidth: '95vw' }"
|
||||||
|
:closable="!isSubmitting"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<Message severity="info" :closable="false" class="m-0 text-xs">
|
||||||
|
با تبدیل این دانشجو به استاد، دسترسیها و نقش سیستمی وی به <b>«استاد»</b> ارتقا مییابد و یک پروفایل استادی جدید برای ایشان ایجاد میشود. نام کاربری و رمز عبور قبلی کاربر حفظ خواهد شد.
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<!-- 1. User Selection -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">انتخاب دانشجو / کاربر *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="userOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="دانشجو را از لیست انتخاب کنید یا نام/کد ملی را جستجو کنید"
|
||||||
|
filter
|
||||||
|
:filterFields="['name', 'nationalId', 'phoneNumber', 'username']"
|
||||||
|
:loading="isLoadingUsers"
|
||||||
|
class="w-full text-sm"
|
||||||
|
@change="onUserSelected"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex flex-column gap-1 py-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<span class="font-bold text-sm text-color">{{ option.name }}</span>
|
||||||
|
<Tag :value="option.roleName || 'دانشجو'" severity="secondary" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-3 text-xs text-muted">
|
||||||
|
<span v-if="option.nationalId">کد ملی: {{ toPersianDigits(option.nationalId) }}</span>
|
||||||
|
<span v-if="option.phoneNumber">همراه: {{ toPersianDigits(option.phoneNumber) }}</span>
|
||||||
|
<span v-if="option.username">(@{{ option.username }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Selected User Details Preview -->
|
||||||
|
<div v-if="selectedUser" class="surface-ground p-3 border-round border-1 border-color flex flex-column gap-2 text-xs">
|
||||||
|
<div class="flex align-items-center justify-content-between border-bottom-1 border-color pb-2">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-user text-primary font-bold"></i>
|
||||||
|
<span class="font-bold text-sm text-color">{{ selectedUser.name }}</span>
|
||||||
|
</div>
|
||||||
|
<Tag :value="`نقش فعلی: ${selectedUser.role?.name || 'کاربر / دانشجو'}`" severity="info" />
|
||||||
|
</div>
|
||||||
|
<div class="grid pt-1 m-0">
|
||||||
|
<div class="col-6 p-1"><span class="text-muted">کد ملی: </span><span class="font-bold" dir="ltr">{{ toPersianDigits(selectedUser.nationalIdCode || selectedUser.nationalId || '-') }}</span></div>
|
||||||
|
<div class="col-6 p-1"><span class="text-muted">شماره همراه: </span><span class="font-bold" dir="ltr">{{ toPersianDigits(selectedUser.phoneNumber || selectedUser.phone || '-') }}</span></div>
|
||||||
|
<div class="col-6 p-1" v-if="selectedUser.email"><span class="text-muted">ایمیل: </span><span>{{ selectedUser.email }}</span></div>
|
||||||
|
<div class="col-6 p-1" v-if="selectedUser.education"><span class="text-muted">تحصیلات: </span><span>{{ selectedUser.education }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Form fields for Professor details -->
|
||||||
|
<div class="grid mt-2" v-if="selectedUser">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">نام استاد *</label>
|
||||||
|
<InputText v-model.trim="form.name" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">نام خانوادگی استاد *</label>
|
||||||
|
<InputText v-model.trim="form.surname" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">شماره کارت جهت تسویه</label>
|
||||||
|
<InputText v-model.trim="form.cardNumber" class="w-full text-sm" dir="ltr" placeholder="۱۶ رقمی" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">شماره شبا (IBAN)</label>
|
||||||
|
<InputText v-model.trim="form.shabaNumber" class="w-full text-sm" dir="ltr" placeholder="IR..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">حوزههای تخصص و مهارتها</label>
|
||||||
|
<Chips v-model="form.expertise" separator="," placeholder="تخصصها را وارد کرده و Enter بزنید" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">رزومه و بیوگرافی استاد</label>
|
||||||
|
<Textarea v-model="form.bio" rows="2" class="w-full text-sm" placeholder="سوابق تدریس، مهارتها و توضیحات تکمیلی..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-end gap-2">
|
||||||
|
<Button label="انصراف" text severity="secondary" :disabled="isSubmitting" @click="visible = false" />
|
||||||
|
<Button
|
||||||
|
label="ارتقا به استاد و ثبت"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
severity="success"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
:disabled="!selectedUser"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { professorApi } from '@/api/professorApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Dropdown from 'primevue/select';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Chips from 'primevue/chips';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Message from 'primevue/message';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
preselectedUserId: { type: String, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawUsers = ref([]);
|
||||||
|
const isLoadingUsers = ref(false);
|
||||||
|
const selectedUserId = ref(null);
|
||||||
|
const selectedUser = ref(null);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
cardNumber: '',
|
||||||
|
shabaNumber: '',
|
||||||
|
expertise: [],
|
||||||
|
bio: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const userOptions = computed(() => {
|
||||||
|
return rawUsers.value.map((u) => ({
|
||||||
|
label: `${u.name || ''} - ${u.phoneNumber || ''}`,
|
||||||
|
value: u._id || u.id,
|
||||||
|
name: u.name,
|
||||||
|
nationalId: u.nationalIdCode || u.nationalId,
|
||||||
|
phoneNumber: u.phoneNumber || u.phone,
|
||||||
|
username: u.username,
|
||||||
|
roleName: u.role?.name,
|
||||||
|
raw: u
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
isLoadingUsers.value = true;
|
||||||
|
try {
|
||||||
|
const res = await userApi.getAll({ limit: 200 });
|
||||||
|
const data = res.data || res;
|
||||||
|
rawUsers.value = data.items || data.users || data || [];
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isLoadingUsers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserSelected = () => {
|
||||||
|
const found = rawUsers.value.find((u) => String(u._id || u.id) === String(selectedUserId.value));
|
||||||
|
selectedUser.value = found || null;
|
||||||
|
if (found) {
|
||||||
|
const rawName = String(found.name || '').trim();
|
||||||
|
const parts = rawName.split(/\s+/);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
form.name = parts[0];
|
||||||
|
form.surname = parts.slice(1).join(' ');
|
||||||
|
} else {
|
||||||
|
form.name = rawName;
|
||||||
|
form.surname = '';
|
||||||
|
}
|
||||||
|
form.cardNumber = found.cardNumber || '';
|
||||||
|
form.shabaNumber = found.shabaNumber || found.iban || '';
|
||||||
|
form.expertise = [];
|
||||||
|
form.bio = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedUserId.value) {
|
||||||
|
showError('لطفاً ابتدا یک دانشجو را انتخاب کنید');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.name || !form.surname) {
|
||||||
|
showError('نام و نام خانوادگی استاد الزامی است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await professorApi.createFromUser({
|
||||||
|
userId: selectedUserId.value,
|
||||||
|
name: form.name,
|
||||||
|
surname: form.surname,
|
||||||
|
cardNumber: form.cardNumber || undefined,
|
||||||
|
shabaNumber: form.shabaNumber || undefined,
|
||||||
|
expertise: form.expertise,
|
||||||
|
bio: form.bio || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
showSuccess('دانشجو با موفقیت به نقش استاد ارتقا یافت و در لیست اساتید ثبت شد');
|
||||||
|
visible.value = false;
|
||||||
|
emit('saved');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchUsers();
|
||||||
|
if (props.preselectedUserId) {
|
||||||
|
selectedUserId.value = props.preselectedUserId;
|
||||||
|
onUserSelected();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedUserId.value = null;
|
||||||
|
selectedUser.value = null;
|
||||||
|
form.name = '';
|
||||||
|
form.surname = '';
|
||||||
|
form.cardNumber = '';
|
||||||
|
form.shabaNumber = '';
|
||||||
|
form.expertise = [];
|
||||||
|
form.bio = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!-- /src/components/users/charts/StudentAttendanceChart.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="student-attendance-chart-wrap flex flex-column align-items-center justify-content-center">
|
||||||
|
<div v-if="hasData" class="chart-container" :style="{ height: height + 'px' }">
|
||||||
|
<Doughnut :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-column align-items-center justify-content-center h-full p-4 text-muted text-sm gap-2">
|
||||||
|
<i class="pi pi-calendar-times text-2xl text-400"></i>
|
||||||
|
<span>اطلاعات حضور و غیابی برای این دانشجو ثبت نشده است</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Doughnut } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
summary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
late: 0,
|
||||||
|
excused: 0,
|
||||||
|
total: 0,
|
||||||
|
attendanceRate: 0
|
||||||
|
})
|
||||||
|
},
|
||||||
|
height: { type: Number, default: 220 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
const s = props.summary || {};
|
||||||
|
return (s.present || 0) + (s.absent || 0) + (s.late || 0) + (s.excused || 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const s = props.summary || {};
|
||||||
|
return {
|
||||||
|
labels: ['حاضر', 'تاخیر', 'موجه', 'غایب'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [s.present || 0, s.late || 0, s.excused || 0, s.absent || 0],
|
||||||
|
backgroundColor: ['#10b981', '#f59e0b', '#3b82f6', '#ef4444'],
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '70%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
font: { family: "'Vazirmatn', sans-serif", size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.label}: ${toPersianDigits(ctx.parsed)} جلسه`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-attendance-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!-- /src/components/users/charts/StudentPaymentStatusChart.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="student-payment-status-chart-wrap flex flex-column align-items-center justify-content-center">
|
||||||
|
<div v-if="hasData" class="chart-container" :style="{ height: height + 'px' }">
|
||||||
|
<Doughnut :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-column align-items-center justify-content-center h-full p-4 text-muted text-sm gap-2">
|
||||||
|
<i class="pi pi-credit-card text-2xl text-400"></i>
|
||||||
|
<span>صورتحساب یا پرداختی برای این دانشجو یافت نشد</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Doughnut } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { STATUS_COLORS, STATUS_LABELS_FA } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
breakdown: { type: Array, default: () => [] }, // [{ status, count, amount }]
|
||||||
|
height: { type: Number, default: 220 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
return (props.breakdown || []).some((b) => b.amount > 0 || b.count > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const list = (props.breakdown || []).filter((b) => b.amount > 0 || b.count > 0);
|
||||||
|
return {
|
||||||
|
labels: list.map((b) => STATUS_LABELS_FA[b.status] || b.status),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: list.map((b) => b.amount),
|
||||||
|
backgroundColor: list.map((b) => STATUS_COLORS[b.status] || '#94a3b8'),
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '70%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
font: { family: "'Vazirmatn', sans-serif", size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const item = (props.breakdown || [])[ctx.dataIndex];
|
||||||
|
const count = item ? toPersianDigits(item.count) : '';
|
||||||
|
return `${ctx.label}: ${formatToman(ctx.parsed)} (${count} فاکتور)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-payment-status-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!-- /src/components/users/charts/StudentPaymentTimelineChart.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="student-payment-timeline-chart-wrap">
|
||||||
|
<div v-if="hasData" class="chart-container" :style="{ height: height + 'px' }">
|
||||||
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-column align-items-center justify-content-center h-full p-4 text-muted text-sm gap-2">
|
||||||
|
<i class="pi pi-chart-bar text-2xl text-400"></i>
|
||||||
|
<span>تراکنش مالی ثبت شدهای در طول زمان یافت نشد</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Bar } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { baseGridOptions } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
timeline: { type: Array, default: () => [] }, // [{ month: 'YYYY-MM', amount: 10000, count: 2 }]
|
||||||
|
height: { type: Number, default: 220 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
return (props.timeline || []).some((t) => t.amount > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const list = props.timeline || [];
|
||||||
|
return {
|
||||||
|
labels: list.map((t) => t.month),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'مبلغ پرداختی (تومان)',
|
||||||
|
data: list.map((t) => t.amount),
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.75)',
|
||||||
|
hoverBackgroundColor: '#10b981',
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: baseGridOptions(),
|
||||||
|
y: {
|
||||||
|
...baseGridOptions(),
|
||||||
|
ticks: {
|
||||||
|
...baseGridOptions().ticks,
|
||||||
|
callback: (value) => formatToman(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const item = (props.timeline || [])[ctx.dataIndex];
|
||||||
|
const count = item ? toPersianDigits(item.count) : '0';
|
||||||
|
return `پرداختی: ${formatToman(ctx.parsed.y)} (${count} تراکنش)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-payment-timeline-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
<div class="professor-list-view">
|
<div class="professor-list-view">
|
||||||
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
|
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
|
||||||
<PermissionGate permission="professors:create">
|
<PermissionGate permission="professors:create">
|
||||||
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
|
<div class="flex gap-2">
|
||||||
|
<Button label="افزودن از بین دانشجویان" icon="pi pi-user-plus" severity="secondary" outlined @click="showAddFromUserDialog = true" />
|
||||||
|
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
|
||||||
|
</div>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -33,7 +36,13 @@
|
|||||||
|
|
||||||
<Column field="specialization" header="تخصص">
|
<Column field="specialization" header="تخصص">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.specialization || 'عمومی'" severity="secondary" />
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<template v-if="data.expertise?.length">
|
||||||
|
<Tag v-for="exp in data.expertise.slice(0, 2)" :key="exp" :value="exp" severity="info" class="text-xs" />
|
||||||
|
<Tag v-if="data.expertise.length > 2" :value="`+${data.expertise.length - 2}`" severity="secondary" class="text-xs" />
|
||||||
|
</template>
|
||||||
|
<Tag v-else :value="data.specialization || 'عمومی'" severity="secondary" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -75,6 +84,11 @@
|
|||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddProfessorFromUserDialog
|
||||||
|
v-model="showAddFromUserDialog"
|
||||||
|
@saved="loadData"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -88,6 +102,7 @@ import PageHeader from '@/components/common/PageHeader.vue';
|
|||||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
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 AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.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';
|
||||||
@@ -109,6 +124,7 @@ const {
|
|||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const selectedProf = ref(null);
|
const selectedProf = ref(null);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
|
const showAddFromUserDialog = ref(false);
|
||||||
|
|
||||||
const confirmDelete = (prof) => {
|
const confirmDelete = (prof) => {
|
||||||
selectedProf.value = prof;
|
selectedProf.value = prof;
|
||||||
|
|||||||
+649
-155
@@ -1,180 +1,449 @@
|
|||||||
<!-- /src/views/users/UserDetailView.vue -->
|
<!-- /src/views/users/UserDetailView.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="user-detail-view" v-if="user">
|
<div class="user-detail-view" v-if="profileData">
|
||||||
<PageHeader :title="user.name || ''" :subtitle="`کد ملی: ${toPersianDigits(user.nationalIdCode || user.nationalId)}`">
|
<!-- ── Profile Header & Quick KPI Bar ──────────────────────────────────── -->
|
||||||
<PermissionGate permission="users:update">
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||||
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
|
<div class="flex flex-column md:flex-row md:align-items-center justify-content-between gap-4 pb-3 border-bottom-1 border-color">
|
||||||
</PermissionGate>
|
<div class="flex align-items-center gap-3">
|
||||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/users')" />
|
<div class="avatar-box w-4rem h-4rem border-circle bg-primary-100 text-primary flex align-items-center justify-content-center text-2xl font-bold flex-shrink-0 shadow-1">
|
||||||
</PageHeader>
|
{{ getInitials(user.name) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<h1 class="text-xl md:text-2xl font-bold text-color m-0">{{ user.name }}</h1>
|
||||||
|
<Tag :value="user.role?.name || 'دانشجو'" :severity="getRoleSeverity(user.role?.name)" />
|
||||||
|
<StatusTag :status="user.isActive !== false" />
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-3 text-xs text-muted mt-1 flex-wrap">
|
||||||
|
<span v-if="user.uniqueCode" class="font-semibold text-primary">کد کاربری: {{ user.uniqueCode }}</span>
|
||||||
|
<span>کد ملی: <b dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId || '-') }}</b></span>
|
||||||
|
<span>شماره همراه: <b dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone || '-') }}</b></span>
|
||||||
|
<span v-if="user.username">نام کاربری: <b dir="ltr">@{{ user.username }}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Profile Tabs -->
|
<!-- Action buttons -->
|
||||||
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
|
<div class="flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||||
|
<PermissionGate permission="users:update">
|
||||||
|
<Button
|
||||||
|
v-if="user.role?.name !== 'Professor'"
|
||||||
|
label="ارتقا به استاد"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
severity="help"
|
||||||
|
size="small"
|
||||||
|
@click="showPromoteModal = true"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="users:enroll">
|
||||||
|
<Button label="ثبتنام دوره" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="users:update">
|
||||||
|
<Button
|
||||||
|
label="پیامک رمز"
|
||||||
|
icon="pi pi-key"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
:loading="isSendingSms"
|
||||||
|
@click="handleResetPasswordSms"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="users:update">
|
||||||
|
<Button label="ویرایش" icon="pi pi-pencil" severity="warning" size="small" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" size="small" @click="$router.push('/users')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick KPI Row -->
|
||||||
|
<div class="grid pt-3 m-0">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کل صورتحسابها</span>
|
||||||
|
<span class="text-lg font-bold text-color">{{ toPersianDigits(financialSummary.totalPayable?.toLocaleString()) }} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg bg-blue-100 text-blue-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-receipt text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مجموع پرداختیها</span>
|
||||||
|
<span class="text-lg font-bold text-green-600">{{ toPersianDigits(financialSummary.totalPaid?.toLocaleString()) }} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg bg-green-100 text-green-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-check-circle text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مانده بدهی</span>
|
||||||
|
<span class="text-lg font-bold" :class="financialSummary.remainingDebt > 0 ? 'text-red-500' : 'text-color'">
|
||||||
|
{{ toPersianDigits(financialSummary.remainingDebt?.toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg" :class="financialSummary.remainingDebt > 0 ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'" flex align-items-center justify-content-center>
|
||||||
|
<i class="pi" :class="financialSummary.remainingDebt > 0 ? 'pi-exclamation-circle' : 'pi-verified'" text-lg></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">درصد حضور در جلسات</span>
|
||||||
|
<span class="text-lg font-bold text-primary">{{ toPersianDigits(attendanceSummary.attendanceRate || 0) }}٪</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg bg-purple-100 text-purple-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-calendar-check text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Main Tabs ────────────────────────────────────────────────────────── -->
|
||||||
|
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">{{ $t('users.tabInfo') }}</Tab>
|
<Tab value="0"><i class="pi pi-chart-pie ml-2"></i>داشبورد و تحلیل نموداری</Tab>
|
||||||
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
|
<Tab value="1"><i class="pi pi-user ml-2"></i>اطلاعات کاربری</Tab>
|
||||||
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
|
<Tab value="2"><i class="pi pi-book ml-2"></i>کلاسها و دورهها ({{ toPersianDigits(classes.length) }})</Tab>
|
||||||
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
|
<Tab value="3"><i class="pi pi-calendar ml-2"></i>حضور و غیاب ({{ toPersianDigits(attendances.length) }})</Tab>
|
||||||
<Tab value="4">گواهینامهها و اسناد</Tab>
|
<Tab value="4"><i class="pi pi-wallet ml-2"></i>امور مالی و فاکتورها ({{ toPersianDigits(payments.length) }})</Tab>
|
||||||
|
<Tab value="5"><i class="pi pi-file ml-2"></i>اسناد و مدارک</Tab>
|
||||||
|
<Tab value="6"><i class="pi pi-clock ml-2"></i>لیست انتظار ({{ toPersianDigits(waitlist.length) }})</Tab>
|
||||||
|
<Tab value="7"><i class="pi pi-history ml-2"></i>سوابق فعالیت و پیامکها</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<!-- Tab 1: User Info -->
|
<!-- ── TAB 0: Analytics & Visual Charts ──────────────────────────────── -->
|
||||||
<TabPanel value="0">
|
<TabPanel value="0">
|
||||||
<div class="grid p-3">
|
<div class="p-3">
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="grid">
|
||||||
<span class="text-muted text-xs block mb-1">نام</span>
|
<!-- Attendance Doughnut Chart -->
|
||||||
<span class="font-bold text-color text-base">{{ user.name }}</span>
|
<div class="col-12 lg:col-6 mb-3">
|
||||||
</div>
|
<div class="surface-ground p-4 border-round-xl border-1 border-color h-full flex flex-column">
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
<span class="text-muted text-xs block mb-1">جنسیت</span>
|
<h3 class="text-base font-bold text-color m-0">تحلیل وضعیت حضور و غیاب</h3>
|
||||||
<span class="font-bold text-color text-base">{{ user.gender === 'male' ? 'مرد' : user.gender === 'female' ? 'زن' : '-' }}</span>
|
<Tag :value="`نرخ حضور: ${toPersianDigits(attendanceSummary.attendanceRate || 0)}٪`" severity="success" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="flex-grow-1 flex align-items-center justify-content-center">
|
||||||
<span class="text-muted text-xs block mb-1">کد ملی</span>
|
<StudentAttendanceChart :summary="attendanceSummary" :height="220" />
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId) }}</span>
|
</div>
|
||||||
</div>
|
<div class="grid mt-3 pt-3 border-top-1 border-color text-center text-xs">
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="col-3"><span class="text-muted block">حاضر</span><b class="text-green-600">{{ toPersianDigits(attendanceSummary.present || 0) }}</b></div>
|
||||||
<span class="text-muted text-xs block mb-1">شماره همراه</span>
|
<div class="col-3"><span class="text-muted block">تاخیر</span><b class="text-orange-500">{{ toPersianDigits(attendanceSummary.late || 0) }}</b></div>
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone) }}</span>
|
<div class="col-3"><span class="text-muted block">موجه</span><b class="text-blue-500">{{ toPersianDigits(attendanceSummary.excused || 0) }}</b></div>
|
||||||
</div>
|
<div class="col-3"><span class="text-muted block">غایب</span><b class="text-red-500">{{ toPersianDigits(attendanceSummary.absent || 0) }}</b></div>
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
</div>
|
||||||
<span class="text-muted text-xs block mb-1">پست الکترونیکی</span>
|
</div>
|
||||||
<span class="font-bold text-color text-base">{{ user.email || '-' }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<!-- Payment Status Chart -->
|
||||||
<span class="text-muted text-xs block mb-1">پیامرسان ترجیحی</span>
|
<div class="col-12 lg:col-6 mb-3">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="surface-ground p-4 border-round-xl border-1 border-color h-full flex flex-column">
|
||||||
<Tag
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
v-for="messenger in (Array.isArray(user.preferredMessenger) ? user.preferredMessenger : (user.preferredMessenger ? [user.preferredMessenger] : ['SMS']))"
|
<h3 class="text-base font-bold text-color m-0">وضعیت صورتحسابها و بدهی</h3>
|
||||||
:key="messenger"
|
<Tag :value="financialSummary.remainingDebt === 0 ? 'تسویه کامل' : 'دارای بدهی'" :severity="financialSummary.remainingDebt === 0 ? 'success' : 'danger'" />
|
||||||
:value="messenger"
|
</div>
|
||||||
severity="info"
|
<div class="flex-grow-1 flex align-items-center justify-content-center">
|
||||||
/>
|
<StudentPaymentStatusChart :breakdown="chartsData.paymentsBreakdown" :height="220" />
|
||||||
|
</div>
|
||||||
|
<div class="grid mt-3 pt-3 border-top-1 border-color text-center text-xs">
|
||||||
|
<div class="col-4"><span class="text-muted block">کل شهریه</span><b>{{ toPersianDigits(financialSummary.totalTuition?.toLocaleString()) }}</b></div>
|
||||||
|
<div class="col-4"><span class="text-muted block">تخفیف کل</span><b class="text-blue-500">{{ toPersianDigits(financialSummary.totalDiscount?.toLocaleString()) }}</b></div>
|
||||||
|
<div class="col-4"><span class="text-muted block">مانده بدهی</span><b class="text-red-500">{{ toPersianDigits(financialSummary.remainingDebt?.toLocaleString()) }}</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction Timeline -->
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="surface-ground p-4 border-round-xl border-1 border-color">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 class="text-base font-bold text-color m-0">روند پرداختها و تراکنشهای مالی</h3>
|
||||||
|
<span class="text-xs text-muted">مبالغ بر حسب ماه تراکنش</span>
|
||||||
|
</div>
|
||||||
|
<StudentPaymentTimelineChart :timeline="chartsData.monthlyTransactions" :height="200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Class progress meters -->
|
||||||
|
<div class="col-12" v-if="classes.length">
|
||||||
|
<div class="surface-ground p-4 border-round-xl border-1 border-color">
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">وضعیت پیشرفت در کلاسهای ثبتنامی</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="cls in classes" :key="cls._id" class="col-12 md:col-6 mb-2">
|
||||||
|
<div class="surface-card p-3 border-round-lg border-1 border-color">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="font-bold text-sm text-color">{{ cls.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ cls.course?.title || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center justify-content-between text-xs text-muted mb-1">
|
||||||
|
<span>مدرس: {{ cls.professor ? `${cls.professor.name} ${cls.professor.surname}` : 'تعیین نشده' }}</span>
|
||||||
|
<span>شهریه: {{ toPersianDigits(cls.tuitionFee?.toLocaleString()) }} تومان</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar :value="cls.numberOfSessions ? Math.min(100, Math.round(((cls.heldSessionsCount || 0) / cls.numberOfSessions) * 100)) : 0" :showValue="false" style="height: 6px;" />
|
||||||
|
<div class="flex justify-content-between text-xs text-muted mt-1">
|
||||||
|
<span>برنامه: {{ cls.days?.length ? `${cls.days.length} روز در هفته` : '-' }} {{ cls.startTime || '' }}</span>
|
||||||
|
<span v-if="cls.numberOfSessions">{{ toPersianDigits(cls.numberOfSessions) }} جلسه</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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="user.role?.name || 'دانشجو'" severity="secondary" />
|
|
||||||
</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-bold text-color text-base">{{ user.fatherName || '-' }}</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-bold text-color text-base" dir="ltr">{{ user.birthDate ? new Date(user.birthDate).toLocaleDateString('fa-IR') : '-' }}</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-bold text-color text-base" dir="ltr">{{ user.birthCertificateNumber || '-' }}</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-bold text-color text-base">{{ user.placeOfIssue || '-' }}</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-bold text-color text-base" dir="ltr">{{ user.postalCode || '-' }}</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-bold text-color text-base">{{ user.education || '-' }}</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-bold text-color text-base" dir="ltr">{{ user.cardNumber || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">شماره شبا (IBAN)</span>
|
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ user.shabaNumber || user.iban || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
|
|
||||||
<span class="text-color text-sm">{{ user.address || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3" v-if="user.adminNotes?.length">
|
|
||||||
<span class="text-muted text-xs block mb-1">یادداشتهای ادمین</span>
|
|
||||||
<ul class="m-0 pr-3 text-sm line-height-3">
|
|
||||||
<li v-for="(note, i) in user.adminNotes" :key="i">{{ note }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 2: Enrolled Courses -->
|
<!-- ── TAB 1: User Info ──────────────────────────────────────────────── -->
|
||||||
<TabPanel value="1">
|
<TabPanel value="1">
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">اطلاعات هویتی و شناسنامهای</h3>
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<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-bold text-color text-base">{{ user.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-bold text-color text-base">{{ user.gender === 'male' ? 'مرد' : user.gender === 'female' ? 'زن' : '-' }}</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-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId) }}</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-bold text-color text-base" dir="ltr">{{ user.birthCertificateNumber || '-' }}</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-bold text-color text-base" dir="ltr">{{ user.birthDate ? formatJalali(user.birthDate) : '-' }}</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-bold text-color text-base">{{ user.fatherName || '-' }}</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-bold text-color text-base">{{ user.placeOfIssue || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">اطلاعات تماس و پیامرسانها</h3>
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<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-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone) }}</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-bold text-color text-base">{{ user.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>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.parentPhoneNumber || '-') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">پیامرسانهای ترجیحی</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<Tag
|
||||||
|
v-for="messenger in (Array.isArray(user.preferredMessenger) ? user.preferredMessenger : (user.preferredMessenger ? [user.preferredMessenger] : ['SMS']))"
|
||||||
|
:key="messenger"
|
||||||
|
:value="messenger"
|
||||||
|
severity="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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-bold text-color text-base" dir="ltr">{{ user.postalCode || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
|
||||||
|
<span class="text-color text-sm">{{ user.address || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">اطلاعات مالی و تحصیلی</h3>
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<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-bold text-color text-base">{{ user.education || '-' }}</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-bold text-color text-base" dir="ltr">{{ user.cardNumber || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">شماره شبا (IBAN)</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ user.shabaNumber || user.iban || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="user.adminNotes?.length">
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">یادداشتهای مدیریت</h3>
|
||||||
|
<div class="surface-ground p-3 border-round-lg border-1 border-color">
|
||||||
|
<ul class="m-0 pr-3 text-sm line-height-3">
|
||||||
|
<li v-for="(note, i) in user.adminNotes" :key="i">{{ note }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 2: Enrolled Classes & Courses ─────────────────────────────── -->
|
||||||
|
<TabPanel value="2">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex align-items-center justify-content-between mb-3">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
<h3 class="text-lg font-bold m-0">دورههای ثبتنام شده</h3>
|
<h3 class="text-base font-bold text-color m-0">کلاسهای ثبتنام شده دانشجو</h3>
|
||||||
<PermissionGate permission="users:enroll">
|
<PermissionGate permission="users:enroll">
|
||||||
<Button label="ثبتنام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
<Button label="ثبتنام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable :value="enrolledCourses" class="p-datatable-sm text-sm">
|
<DataTable :value="classes" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||||
<Column field="title" header="عنوان دوره" />
|
<template #empty>
|
||||||
<Column field="type" header="نوع">
|
<div class="p-4 text-center text-muted text-sm">دانشجو در کلاسی ثبتنام نکرده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="name" header="نام کلاس">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="data.type === 'Private' ? 'warning' : 'info'" />
|
<span class="font-bold text-color">{{ data.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="price" header="شهریه">
|
<Column field="course.title" header="دوره">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
|
<span>{{ data.course?.title || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="enrollDate" header="تاریخ ثبتنام">
|
<Column field="professor" header="مدرس">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatJalali(data.enrollDate || data.createdAt) }}
|
<span>{{ data.professor ? `${data.professor.name} ${data.professor.surname}` : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="tuitionFee" header="شهریه">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ toPersianDigits((data.tuitionFee || 0).toLocaleString()) }} تومان
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="numberOfSessions" header="تعداد جلسات">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ toPersianDigits(data.numberOfSessions || '-') }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="startDate" header="تاریخ شروع">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.startDate ? formatJalali(data.startDate) : '-' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="isActive" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<StatusTag :status="data.isActive !== false" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 3: Sessions & Attendance -->
|
<!-- ── TAB 3: Attendance History ─────────────────────────────────────── -->
|
||||||
<TabPanel value="2">
|
|
||||||
<div class="p-3">
|
|
||||||
<h3 class="text-lg font-bold mb-3">تاریخچه حضور و غیاب دانشجو</h3>
|
|
||||||
<DataTable :value="attendances" class="p-datatable-sm text-sm">
|
|
||||||
<Column field="sessionTitle" header="عنوان جلسه / دوره" />
|
|
||||||
<Column field="date" header="تاریخ جلسه">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ formatJalali(data.date) }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="status" header="وضعیت حضور">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="data.status" :severity="getAttendanceSeverity(data.status)" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="note" header="یادداشت" />
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<!-- Tab 4: Payments -->
|
|
||||||
<TabPanel value="3">
|
<TabPanel value="3">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex align-items-center justify-content-between mb-3">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
<h3 class="text-lg font-bold m-0">صورتحسابها و پرداختها</h3>
|
<h3 class="text-base font-bold text-color m-0">سوابق حضور و غیاب دانشجو در جلسات</h3>
|
||||||
<PermissionGate permission="payments:update">
|
<div class="flex gap-2">
|
||||||
|
<Tag :value="`حاضر: ${toPersianDigits(attendanceSummary.present || 0)}`" severity="success" />
|
||||||
|
<Tag :value="`غایب: ${toPersianDigits(attendanceSummary.absent || 0)}`" severity="danger" />
|
||||||
|
<Tag :value="`تاخیر: ${toPersianDigits(attendanceSummary.late || 0)}`" severity="warning" />
|
||||||
|
<Tag :value="`موجه: ${toPersianDigits(attendanceSummary.excused || 0)}`" severity="info" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :value="attendances" class="p-datatable-sm text-sm" paginator :rows="10" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ سابقه حضوری برای این دانشجو یافت نشد.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="topic" header="عنوان جلسه / مبحث">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-color">{{ data.topic || data.class?.name || 'جلسه آموزشی' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="course.title" header="دوره / کلاس">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span>{{ data.course?.title || data.class?.name || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="day" header="تاریخ جلسه">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.day) }}
|
||||||
|
<span v-if="data.startTime" class="text-xs text-muted mr-1" dir="ltr">({{ data.startTime }})</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="attendanceStatus" header="وضعیت حضور">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getAttendanceLabel(data.attendanceStatus)" :severity="getAttendanceSeverity(data.attendanceStatus)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="attendanceNote" header="یادداشت مدرس / حضور">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-muted text-xs">{{ data.attendanceNote || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 4: Invoices & Transactions ───────────────────────────────── -->
|
||||||
|
<TabPanel value="4">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 class="text-base font-bold text-color m-0">صورتحسابها و فاکتورهای آموزشی</h3>
|
||||||
|
<PermissionGate permission="payments:create">
|
||||||
<Button label="ثبت پرداخت جدید" icon="pi pi-wallet" size="small" severity="success" @click="$router.push('/payments')" />
|
<Button label="ثبت پرداخت جدید" icon="pi pi-wallet" size="small" severity="success" @click="$router.push('/payments')" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable :value="payments" class="p-datatable-sm text-sm">
|
<!-- Invoices Table -->
|
||||||
|
<DataTable :value="payments" class="p-datatable-sm text-sm mb-4" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ صورتحسابی برای این دانشجو ثبت نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="uniqueCode" header="کد فاکتور">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-primary" dir="ltr">{{ data.uniqueCode || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="course" header="دوره / کلاس">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span>{{ data.course?.title || data.classes?.[0]?.name || 'شهریه دوره' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
<Column field="amount" header="مبلغ کل">
|
<Column field="amount" header="مبلغ کل">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div>{{ toPersianDigits(getPayableAmount(data).toLocaleString()) }} تومان</div>
|
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
|
||||||
<small v-if="data.discount" class="text-muted text-xs">
|
</template>
|
||||||
تخفیف {{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان
|
</Column>
|
||||||
</small>
|
<Column field="discount" header="تخفیف">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :class="data.discount ? 'text-blue-500 font-bold' : ''">{{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="paidAmount" header="پرداختی">
|
<Column field="paidAmount" header="پرداختی">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.paidAmount?.toLocaleString()) }} تومان
|
<span class="text-green-600 font-bold">{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="status" header="وضعیت">
|
<Column field="status" header="وضعیت">
|
||||||
@@ -182,26 +451,166 @@
|
|||||||
<StatusTag :status="data.status" type="payment" />
|
<StatusTag :status="data.status" type="payment" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="dueDate" header="تاریخ سررسید">
|
<Column field="dueDate" header="سررسید">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatJalali(data.dueDate) }}
|
{{ data.dueDate ? formatJalali(data.dueDate) : '-' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Bank Transactions Section -->
|
||||||
|
<h3 class="text-base font-bold text-color mb-3 pt-3 border-top-1 border-color">تراکنشهای بانکی ثبت شده</h3>
|
||||||
|
<DataTable :value="transactions" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ تراکنش بانکی ثبت نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="date" header="تاریخ تراکنش">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.date || data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="amount" header="مبلغ">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<b class="text-green-600">{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان</b>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="method" header="روش پرداخت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getMethodLabel(data.method)" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="receiptNumber" header="شماره فیش / پیگیری">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '-') }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<StatusTag :status="data.status" type="payment" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="notes" header="توضیحات">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs text-muted">{{ data.notes || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 5: Certificates & Documents -->
|
<!-- ── TAB 5: Certificates & Documents ──────────────────────────────── -->
|
||||||
<TabPanel value="4">
|
<TabPanel value="5">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
|
<div v-if="certificates.length" class="mb-4">
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">گواهینامههای رسمی صادر شده</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="cert in certificates" :key="cert._id" class="col-12 sm:col-6 md:col-4">
|
||||||
|
<div class="surface-ground p-3 border-round-lg border-1 border-color flex flex-column gap-2">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<span class="font-bold text-sm text-color">{{ cert.title }}</span>
|
||||||
|
<Tag value="رسمی" severity="success" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted">دوره: {{ cert.course?.title || '-' }}</span>
|
||||||
|
<span class="text-xs text-muted">تاریخ صدور: {{ formatJalali(cert.issuedAt) }}</span>
|
||||||
|
<a v-if="cert.fileUrl" :href="cert.fileUrl" target="_blank" class="p-button p-button-sm p-button-outlined mt-2 text-center text-xs">
|
||||||
|
<i class="pi pi-download ml-1"></i> دانلود گواهینامه
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-bold text-color mb-3 pt-2">مدارک و فایلهای پیوست دانشجو</h3>
|
||||||
<UserFilesSection :user-id="String(userId)" />
|
<UserFilesSection :user-id="String(userId)" />
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 6: Waitlist History ──────────────────────────────────────── -->
|
||||||
|
<TabPanel value="6">
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">سوابق ثبتنام در لیست انتظار</h3>
|
||||||
|
<DataTable :value="waitlist" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ درخواستی در لیست انتظار ثبت نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="course.title" header="دوره مورد تقاضا" />
|
||||||
|
<Column field="class.name" header="کلاس تخصیصیافته">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span>{{ data.class?.name || 'هنوز تخصیص نیافته' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="registeredAt" header="تاریخ ثبت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.registeredAt || data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getWaitlistLabel(data.status)" :severity="getWaitlistSeverity(data.status)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 7: Activity Logs & Notifications ─────────────────────────── -->
|
||||||
|
<TabPanel value="7">
|
||||||
|
<div class="p-3">
|
||||||
|
<!-- Notifications sent -->
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">پیامکها و اطلاعیههای ارسالی به دانشجو</h3>
|
||||||
|
<DataTable :value="notifications" class="p-datatable-sm text-sm mb-4" paginator :rows="5" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ اطلاعیهای ارسال نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="channel" header="کانال">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.channel === 'sms' ? 'پیامک' : data.channel === 'email' ? 'ایمیل' : data.channel" severity="info" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="subject" header="موضوع" />
|
||||||
|
<Column field="body" header="متن پیام">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs line-height-3">{{ data.body }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.status" :severity="data.status === 'delivered' || data.status === 'sent' ? 'success' : 'warning'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="createdAt" header="تاریخ ارسال">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Activity logs -->
|
||||||
|
<h3 class="text-base font-bold text-color mb-3 pt-3 border-top-1 border-color">لاگهای فعالیتهای اخیر</h3>
|
||||||
|
<DataTable :value="activityLogs" class="p-datatable-sm text-sm" paginator :rows="5" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ لاگ فعالیتی یافت نشد.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="action" header="عملیات">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.action" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="resource" header="بخش" />
|
||||||
|
<Column field="description" header="توضیحات" />
|
||||||
|
<Column field="createdAt" header="زمان">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<!-- Enroll Modal -->
|
<!-- Enroll Course Modal -->
|
||||||
<Dialog v-model:visible="showEnrollModal" header="ثبتنام دانشجو در دوره جدید" modal :style="{ width: '450px' }">
|
<Dialog v-model:visible="showEnrollModal" header="ثبتنام دانشجو در دوره آموزشی" modal :style="{ width: '450px' }">
|
||||||
<div class="flex flex-column gap-3 py-2">
|
<div class="flex flex-column gap-3 py-2">
|
||||||
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
|
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -218,21 +627,30 @@
|
|||||||
<Button label="تایید ثبتنام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
|
<Button label="تایید ثبتنام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Promote to Professor Dialog -->
|
||||||
|
<AddProfessorFromUserDialog
|
||||||
|
v-model="showPromoteModal"
|
||||||
|
:preselected-user-id="String(userId)"
|
||||||
|
@saved="fetchFullProfile"
|
||||||
|
/>
|
||||||
</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 { userApi } from '@/api/userApi';
|
import { userApi } from '@/api/userApi';
|
||||||
import { courseApi } from '@/api/courseApi';
|
import { courseApi } from '@/api/courseApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { getPayableAmount } from '@/utils/paymentAmount';
|
|
||||||
import PageHeader from '@/components/common/PageHeader.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 UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
||||||
|
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
|
||||||
|
import StudentAttendanceChart from '@/components/users/charts/StudentAttendanceChart.vue';
|
||||||
|
import StudentPaymentStatusChart from '@/components/users/charts/StudentPaymentStatusChart.vue';
|
||||||
|
import StudentPaymentTimelineChart from '@/components/users/charts/StudentPaymentTimelineChart.vue';
|
||||||
import Tabs from 'primevue/tabs';
|
import Tabs from 'primevue/tabs';
|
||||||
import TabList from 'primevue/tablist';
|
import TabList from 'primevue/tablist';
|
||||||
import Tab from 'primevue/tab';
|
import Tab from 'primevue/tab';
|
||||||
@@ -244,35 +662,88 @@ import DataTable from 'primevue/datatable';
|
|||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Dialog from 'primevue/dialog';
|
import Dialog from 'primevue/dialog';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
|
import ProgressBar from 'primevue/progressbar';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userId = route.params.id;
|
const userId = route.params.id;
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
const user = ref(null);
|
const profileData = ref(null);
|
||||||
const enrolledCourses = ref([]);
|
const user = computed(() => profileData.value?.user || {});
|
||||||
const attendances = ref([]);
|
const classes = computed(() => profileData.value?.classes || []);
|
||||||
const payments = ref([]);
|
const attendances = computed(() => profileData.value?.attendances || []);
|
||||||
|
const payments = computed(() => profileData.value?.payments || []);
|
||||||
|
const transactions = computed(() => profileData.value?.transactions || []);
|
||||||
|
const certificates = computed(() => profileData.value?.certificates || []);
|
||||||
|
const waitlist = computed(() => profileData.value?.waitlist || []);
|
||||||
|
const notifications = computed(() => profileData.value?.notifications || []);
|
||||||
|
const activityLogs = computed(() => profileData.value?.activityLogs || []);
|
||||||
|
const financialSummary = computed(() => profileData.value?.financialSummary || {});
|
||||||
|
const attendanceSummary = computed(() => profileData.value?.attendanceSummary || {});
|
||||||
|
const chartsData = computed(() => profileData.value?.chartsData || {});
|
||||||
|
|
||||||
const showEnrollModal = ref(false);
|
const showEnrollModal = ref(false);
|
||||||
|
const showPromoteModal = ref(false);
|
||||||
const allCourses = ref([]);
|
const allCourses = ref([]);
|
||||||
const selectedCourseId = ref(null);
|
const selectedCourseId = ref(null);
|
||||||
const isEnrolling = ref(false);
|
const isEnrolling = ref(false);
|
||||||
|
const isSendingSms = ref(false);
|
||||||
|
|
||||||
const getAttendanceSeverity = (status) => {
|
const getInitials = (name) => {
|
||||||
if (status === 'حاضر' || status === 'present') return 'success';
|
if (!name) return 'U';
|
||||||
if (status === 'غایب' || status === 'absent') return 'danger';
|
const parts = name.trim().split(/\s+/);
|
||||||
if (status === 'تاخیر' || status === 'late') return 'warning';
|
return parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : parts[0][0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleSeverity = (roleName) => {
|
||||||
|
if (roleName === 'SuperAdmin' || roleName === 'Admin') return 'danger';
|
||||||
|
if (roleName === 'Professor') return 'help';
|
||||||
|
if (roleName === 'Secretary') return 'warning';
|
||||||
return 'info';
|
return 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUserDetail = async () => {
|
const getAttendanceLabel = (status) => {
|
||||||
|
if (status === 'present') return 'حاضر';
|
||||||
|
if (status === 'absent') return 'غایب';
|
||||||
|
if (status === 'late') return 'تاخیر';
|
||||||
|
if (status === 'excused') return 'موجه';
|
||||||
|
return status || 'تعیین نشده';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAttendanceSeverity = (status) => {
|
||||||
|
if (status === 'present') return 'success';
|
||||||
|
if (status === 'absent') return 'danger';
|
||||||
|
if (status === 'late') return 'warning';
|
||||||
|
if (status === 'excused') return 'info';
|
||||||
|
return 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMethodLabel = (method) => {
|
||||||
|
if (method === 'card') return 'کارت به کارت';
|
||||||
|
if (method === 'online') return 'پرداخت آنلاین';
|
||||||
|
if (method === 'cash') return 'نقدی';
|
||||||
|
return method || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWaitlistLabel = (status) => {
|
||||||
|
if (status === 'waiting') return 'در انتظار';
|
||||||
|
if (status === 'enrolled') return 'ثبتنام شده';
|
||||||
|
if (status === 'cancelled') return 'لغو شده';
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWaitlistSeverity = (status) => {
|
||||||
|
if (status === 'waiting') return 'warning';
|
||||||
|
if (status === 'enrolled') return 'success';
|
||||||
|
if (status === 'cancelled') return 'danger';
|
||||||
|
return 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFullProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await userApi.getOne(userId);
|
const res = await userApi.getFullProfile(userId);
|
||||||
user.value = res.data || res;
|
profileData.value = res.data || res;
|
||||||
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
|
|
||||||
payments.value = user.value.payments || [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
}
|
}
|
||||||
@@ -295,7 +766,7 @@ const handleEnroll = async () => {
|
|||||||
await userApi.enroll(userId, { courseId: selectedCourseId.value });
|
await userApi.enroll(userId, { courseId: selectedCourseId.value });
|
||||||
showSuccess('دانشجو با موفقیت در دوره ثبتنام شد');
|
showSuccess('دانشجو با موفقیت در دوره ثبتنام شد');
|
||||||
showEnrollModal.value = false;
|
showEnrollModal.value = false;
|
||||||
fetchUserDetail();
|
fetchFullProfile();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -303,8 +774,31 @@ const handleEnroll = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetPasswordSms = async () => {
|
||||||
|
isSendingSms.value = true;
|
||||||
|
try {
|
||||||
|
const res = await userApi.resetPasswordAndSms(userId);
|
||||||
|
const data = res.data || res;
|
||||||
|
if (data.smsSent) {
|
||||||
|
showSuccess(`رمز عبور بازنشانی و پیامک به شماره ${data.phoneNumber} ارسال شد`);
|
||||||
|
} else {
|
||||||
|
showSuccess(`رمز عبور جدید: ${data.generatedCredentials?.password || ''}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSendingSms.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchUserDetail();
|
fetchFullProfile();
|
||||||
fetchAllCourses();
|
fetchAllCourses();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar-box {
|
||||||
|
border: 2px solid var(--p-primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,11 +2,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-list-view">
|
<div class="user-list-view">
|
||||||
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
|
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
|
||||||
<PermissionGate permission="users:create">
|
<div class="flex gap-2">
|
||||||
<Button :label="$t('users.addUser')" icon="pi pi-user-plus" @click="$router.push('/users/create')" />
|
<PermissionGate permission="professors:create">
|
||||||
</PermissionGate>
|
<Button label="افزودن استاد از دانشجویان" icon="pi pi-user-plus" severity="secondary" outlined @click="openPromoteDialog(null)" />
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate permission="users:create">
|
||||||
|
<Button :label="$t('users.addUser')" icon="pi pi-plus" @click="$router.push('/users/create')" />
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Top KPI Stats Row -->
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-4">
|
||||||
|
<div class="surface-card p-3 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کل کاربران ثبتشده</span>
|
||||||
|
<span class="text-2xl font-bold text-color">{{ toPersianDigits(totalCount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl bg-primary-50 text-primary flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-users text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-4">
|
||||||
|
<div class="surface-card p-3 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کاربران فعال</span>
|
||||||
|
<span class="text-2xl font-bold text-green-600">{{ toPersianDigits(activeUsersCount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl bg-green-50 text-green-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-check-circle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-4">
|
||||||
|
<div class="surface-card p-3 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مجموع ثبتنامها در کلاس</span>
|
||||||
|
<span class="text-2xl font-bold text-blue-600">{{ toPersianDigits(totalEnrollmentsCount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl bg-blue-50 text-blue-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-book text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTableWrapper
|
<DataTableWrapper
|
||||||
:items="items"
|
:items="items"
|
||||||
:totalCount="totalCount"
|
:totalCount="totalCount"
|
||||||
@@ -19,23 +61,32 @@
|
|||||||
@sort-change="onSort"
|
@sort-change="onSort"
|
||||||
@search-change="onSearch"
|
@search-change="onSearch"
|
||||||
>
|
>
|
||||||
<Column field="name" header="نام" sortable>
|
<Column field="name" header="نام دانشجو / کاربر" sortable>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
|
<div class="flex flex-column gap-1">
|
||||||
{{ data.name }}
|
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary text-sm">
|
||||||
</router-link>
|
{{ data.name }}
|
||||||
|
</router-link>
|
||||||
|
<span v-if="data.uniqueCode" class="text-xs text-muted" dir="ltr">{{ data.uniqueCode }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="role" header="نقش سیستم">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.role?.name || 'دانشجو'" :severity="getRoleSeverity(data.role?.name)" class="text-xs" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="nationalIdCode" header="کد ملی">
|
<Column field="nationalIdCode" header="کد ملی">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}
|
<span dir="ltr">{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="phoneNumber" header="شماره همراه">
|
<Column field="phoneNumber" header="شماره همراه">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
|
<span dir="ltr">{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -47,14 +98,15 @@
|
|||||||
:key="messenger"
|
:key="messenger"
|
||||||
:value="messenger"
|
:value="messenger"
|
||||||
severity="info"
|
severity="info"
|
||||||
|
class="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="registeredClassesCount" header="کلاسهای ثبتنامی">
|
<Column field="registeredClassesCount" header="کلاسهای ثبتنامی" sortable>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.registeredClassesCount ?? 0) }}
|
<Badge :value="toPersianDigits(data.registeredClassesCount ?? 0)" :severity="data.registeredClassesCount > 0 ? 'info' : 'secondary'" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -64,11 +116,24 @@
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="عمولیات" style="width: 130px">
|
<Column header="عملیات" style="width: 150px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex align-items-center gap-1">
|
<div class="flex align-items-center gap-1">
|
||||||
<PermissionGate permission="users:read">
|
<PermissionGate permission="users:read">
|
||||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/users/${data._id || data.id}`)" />
|
<Button icon="pi pi-id-card" text rounded size="small" v-tooltip.top="'مشاهده پروفایل جامع'" @click="$router.push(`/users/${data._id || data.id}`)" />
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="professors:create">
|
||||||
|
<Button
|
||||||
|
v-if="data.role?.name !== 'Professor'"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="help"
|
||||||
|
v-tooltip.top="'ارتقا به استاد'"
|
||||||
|
@click="openPromoteDialog(data._id || data.id)"
|
||||||
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
|
|
||||||
<PermissionGate permission="users:update">
|
<PermissionGate permission="users:update">
|
||||||
@@ -88,11 +153,17 @@
|
|||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddProfessorFromUserDialog
|
||||||
|
v-model="promoteDialogVisible"
|
||||||
|
:preselected-user-id="selectedPromoteUserId"
|
||||||
|
@saved="loadData"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useDataTable } from '@/composables/useDataTable';
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
@@ -100,11 +171,13 @@ import { userApi } from '@/api/userApi';
|
|||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.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';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Tag from 'primevue/tag';
|
import Tag from 'primevue/tag';
|
||||||
|
import Badge from 'primevue/badge';
|
||||||
|
|
||||||
const { toPersianDigits } = usePersianDate();
|
const { toPersianDigits } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
@@ -124,6 +197,29 @@ const deleteDialogVisible = ref(false);
|
|||||||
const selectedUser = ref(null);
|
const selectedUser = ref(null);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
|
|
||||||
|
const promoteDialogVisible = ref(false);
|
||||||
|
const selectedPromoteUserId = ref(null);
|
||||||
|
|
||||||
|
const activeUsersCount = computed(() => {
|
||||||
|
return items.value.filter((u) => u.isActive !== false).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalEnrollmentsCount = computed(() => {
|
||||||
|
return items.value.reduce((sum, u) => sum + (u.registeredClassesCount || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRoleSeverity = (roleName) => {
|
||||||
|
if (roleName === 'SuperAdmin' || roleName === 'Admin') return 'danger';
|
||||||
|
if (roleName === 'Professor') return 'help';
|
||||||
|
if (roleName === 'Secretary') return 'warning';
|
||||||
|
return 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPromoteDialog = (userId) => {
|
||||||
|
selectedPromoteUserId.value = userId ? String(userId) : null;
|
||||||
|
promoteDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = (user) => {
|
const confirmDelete = (user) => {
|
||||||
selectedUser.value = user;
|
selectedUser.value = user;
|
||||||
deleteDialogVisible.value = true;
|
deleteDialogVisible.value = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user