feat(users,professors): add student-to-professor promotion dialog, complete student profile view and charts

This commit is contained in:
2026-08-24 22:53:24 +03:30
parent 05d96fd550
commit a2a7e756ad
9 changed files with 1302 additions and 171 deletions
+1
View File
@@ -6,6 +6,7 @@ export const professorApi = {
search: (params) => axiosInstance.get('/professors/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/professors/admin/get-one/${id}`),
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),
delete: (id) => axiosInstance.delete(`/professors/admin/delete/${id}`)
};
+2
View File
@@ -5,8 +5,10 @@ export const userApi = {
getAll: (params) => axiosInstance.get('/users/admin/get-all', { params }),
search: (params) => axiosInstance.get('/users/admin/search', { params }),
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),
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(
`/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>
+17 -1
View File
@@ -3,7 +3,10 @@
<div class="professor-list-view">
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
<PermissionGate permission="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>
</PageHeader>
@@ -33,7 +36,13 @@
<Column field="specialization" header="تخصص">
<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>
</Column>
@@ -75,6 +84,11 @@
:loading="isDeleting"
@confirm="handleDelete"
/>
<AddProfessorFromUserDialog
v-model="showAddFromUserDialog"
@saved="loadData"
/>
</div>
</template>
@@ -88,6 +102,7 @@ import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
@@ -109,6 +124,7 @@ const {
const deleteDialogVisible = ref(false);
const selectedProf = ref(null);
const isDeleting = ref(false);
const showAddFromUserDialog = ref(false);
const confirmDelete = (prof) => {
selectedProf.value = prof;
+605 -111
View File
@@ -1,28 +1,221 @@
<!-- /src/views/users/UserDetailView.vue -->
<template>
<div class="user-detail-view" v-if="user">
<PageHeader :title="user.name || ''" :subtitle="`کد ملی: ${toPersianDigits(user.nationalIdCode || user.nationalId)}`">
<PermissionGate permission="users:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
</PermissionGate>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/users')" />
</PageHeader>
<div class="user-detail-view" v-if="profileData">
<!-- Profile Header & Quick KPI Bar -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
<div class="flex flex-column md:flex-row md:align-items-center justify-content-between gap-4 pb-3 border-bottom-1 border-color">
<div class="flex align-items-center gap-3">
<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">
{{ 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 -->
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
<!-- Action buttons -->
<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>
<Tab value="0">{{ $t('users.tabInfo') }}</Tab>
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
<Tab value="4">گواهینامهها و اسناد</Tab>
<Tab value="0"><i class="pi pi-chart-pie ml-2"></i>داشبورد و تحلیل نموداری</Tab>
<Tab value="1"><i class="pi pi-user ml-2"></i>اطلاعات کاربری</Tab>
<Tab value="2"><i class="pi pi-book ml-2"></i>کلاسها و دورهها ({{ toPersianDigits(classes.length) }})</Tab>
<Tab value="3"><i class="pi pi-calendar ml-2"></i>حضور و غیاب ({{ toPersianDigits(attendances.length) }})</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>
<TabPanels>
<!-- Tab 1: User Info -->
<!-- TAB 0: Analytics & Visual Charts -->
<TabPanel value="0">
<div class="grid p-3">
<div class="p-3">
<div class="grid">
<!-- Attendance Doughnut Chart -->
<div class="col-12 lg:col-6 mb-3">
<div class="surface-ground p-4 border-round-xl border-1 border-color h-full flex flex-column">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-base font-bold text-color m-0">تحلیل وضعیت حضور و غیاب</h3>
<Tag :value="`نرخ حضور: ${toPersianDigits(attendanceSummary.attendanceRate || 0)}٪`" severity="success" />
</div>
<div class="flex-grow-1 flex align-items-center justify-content-center">
<StudentAttendanceChart :summary="attendanceSummary" :height="220" />
</div>
<div class="grid mt-3 pt-3 border-top-1 border-color text-center text-xs">
<div class="col-3"><span class="text-muted block">حاضر</span><b class="text-green-600">{{ toPersianDigits(attendanceSummary.present || 0) }}</b></div>
<div class="col-3"><span class="text-muted block">تاخیر</span><b class="text-orange-500">{{ toPersianDigits(attendanceSummary.late || 0) }}</b></div>
<div class="col-3"><span class="text-muted block">موجه</span><b class="text-blue-500">{{ toPersianDigits(attendanceSummary.excused || 0) }}</b></div>
<div class="col-3"><span class="text-muted block">غایب</span><b class="text-red-500">{{ toPersianDigits(attendanceSummary.absent || 0) }}</b></div>
</div>
</div>
</div>
<!-- Payment Status Chart -->
<div class="col-12 lg:col-6 mb-3">
<div class="surface-ground p-4 border-round-xl border-1 border-color h-full flex flex-column">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-base font-bold text-color m-0">وضعیت صورتحسابها و بدهی</h3>
<Tag :value="financialSummary.remainingDebt === 0 ? 'تسویه کامل' : 'دارای بدهی'" :severity="financialSummary.remainingDebt === 0 ? 'success' : 'danger'" />
</div>
<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>
</TabPanel>
<!-- TAB 1: User Info -->
<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="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">
@@ -33,16 +226,40 @@
<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="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="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']))"
@@ -52,129 +269,181 @@
/>
</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 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="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="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>
<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 Courses -->
<TabPanel value="1">
<!-- TAB 2: Enrolled Classes & Courses -->
<TabPanel value="2">
<div class="p-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">
<Button label="ثبت‌نام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
</PermissionGate>
</div>
<DataTable :value="enrolledCourses" class="p-datatable-sm text-sm">
<Column field="title" header="عنوان دوره" />
<Column field="type" header="نوع">
<DataTable :value="classes" class="p-datatable-sm text-sm" responsiveLayout="scroll">
<template #empty>
<div class="p-4 text-center text-muted text-sm">دانشجو در کلاسی ثبتنام نکرده است.</div>
</template>
<Column field="name" header="نام کلاس">
<template #body="{ data }">
<Tag :value="data.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="data.type === 'Private' ? 'warning' : 'info'" />
<span class="font-bold text-color">{{ data.name }}</span>
</template>
</Column>
<Column field="price" header="شهریه">
<Column field="course.title" header="دوره">
<template #body="{ data }">
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
<span>{{ data.course?.title || '-' }}</span>
</template>
</Column>
<Column field="enrollDate" header="تاریخ ثبت‌نام">
<Column field="professor" header="مدرس">
<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>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 3: Sessions & Attendance -->
<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 -->
<!-- TAB 3: Attendance History -->
<TabPanel value="3">
<div class="p-3">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-lg font-bold m-0">صورتحسابها و پرداختها</h3>
<PermissionGate permission="payments:update">
<h3 class="text-base font-bold text-color m-0">سوابق حضور و غیاب دانشجو در جلسات</h3>
<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')" />
</PermissionGate>
</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="مبلغ کل">
<template #body="{ data }">
<div>{{ toPersianDigits(getPayableAmount(data).toLocaleString()) }} تومان</div>
<small v-if="data.discount" class="text-muted text-xs">
تخفیف {{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان
</small>
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="discount" header="تخفیف">
<template #body="{ data }">
<span :class="data.discount ? 'text-blue-500 font-bold' : ''">{{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان</span>
</template>
</Column>
<Column field="paidAmount" header="پرداختی">
<template #body="{ data }">
{{ toPersianDigits(data.paidAmount?.toLocaleString()) }} تومان
<span class="text-green-600 font-bold">{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان</span>
</template>
</Column>
<Column field="status" header="وضعیت">
@@ -182,26 +451,166 @@
<StatusTag :status="data.status" type="payment" />
</template>
</Column>
<Column field="dueDate" header="تاریخ سررسید">
<Column field="dueDate" header="سررسید">
<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>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 5: Certificates & Documents -->
<TabPanel value="4">
<!-- TAB 5: Certificates & Documents -->
<TabPanel value="5">
<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)" />
</div>
</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>
</Tabs>
<!-- Enroll Modal -->
<Dialog v-model:visible="showEnrollModal" header="ثبت‌نام دانشجو در دوره جدید" modal :style="{ width: '450px' }">
<!-- Enroll Course Modal -->
<Dialog v-model:visible="showEnrollModal" header="ثبت‌نام دانشجو در دوره آموزشی" modal :style="{ width: '450px' }">
<div class="flex flex-column gap-3 py-2">
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
<Dropdown
@@ -218,21 +627,30 @@
<Button label="تایید ثبت‌نام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
</template>
</Dialog>
<!-- Promote to Professor Dialog -->
<AddProfessorFromUserDialog
v-model="showPromoteModal"
:preselected-user-id="String(userId)"
@saved="fetchFullProfile"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { userApi } from '@/api/userApi';
import { courseApi } from '@/api/courseApi';
import { usePersianDate } from '@/composables/usePersianDate';
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 StatusTag from '@/components/common/StatusTag.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 TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
@@ -244,35 +662,88 @@ import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
import ProgressBar from 'primevue/progressbar';
const route = useRoute();
const userId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const user = ref(null);
const enrolledCourses = ref([]);
const attendances = ref([]);
const payments = ref([]);
const profileData = ref(null);
const user = computed(() => profileData.value?.user || {});
const classes = computed(() => profileData.value?.classes || []);
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 showPromoteModal = ref(false);
const allCourses = ref([]);
const selectedCourseId = ref(null);
const isEnrolling = ref(false);
const isSendingSms = ref(false);
const getAttendanceSeverity = (status) => {
if (status === 'حاضر' || status === 'present') return 'success';
if (status === 'غایب' || status === 'absent') return 'danger';
if (status === 'تاخیر' || status === 'late') return 'warning';
const getInitials = (name) => {
if (!name) return 'U';
const parts = name.trim().split(/\s+/);
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';
};
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 {
const res = await userApi.getOne(userId);
user.value = res.data || res;
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
payments.value = user.value.payments || [];
const res = await userApi.getFullProfile(userId);
profileData.value = res.data || res;
} catch (err) {
showError(err);
}
@@ -295,7 +766,7 @@ const handleEnroll = async () => {
await userApi.enroll(userId, { courseId: selectedCourseId.value });
showSuccess('دانشجو با موفقیت در دوره ثبت‌نام شد');
showEnrollModal.value = false;
fetchUserDetail();
fetchFullProfile();
} catch (err) {
showError(err);
} 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(() => {
fetchUserDetail();
fetchFullProfile();
fetchAllCourses();
});
</script>
<style scoped>
.avatar-box {
border: 2px solid var(--p-primary-color);
}
</style>
+107 -11
View File
@@ -2,11 +2,53 @@
<template>
<div class="user-list-view">
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
<PermissionGate permission="users:create">
<Button :label="$t('users.addUser')" icon="pi pi-user-plus" @click="$router.push('/users/create')" />
<div class="flex gap-2">
<PermissionGate permission="professors:create">
<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>
<!-- 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
:items="items"
:totalCount="totalCount"
@@ -19,23 +61,32 @@
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام" sortable>
<Column field="name" header="نام دانشجو / کاربر" sortable>
<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">
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary text-sm">
{{ 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>
</Column>
<Column field="nationalIdCode" header="کد ملی">
<template #body="{ data }">
{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}
<span dir="ltr">{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}</span>
</template>
</Column>
<Column field="phoneNumber" header="شماره همراه">
<template #body="{ data }">
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
<span dir="ltr">{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}</span>
</template>
</Column>
@@ -47,14 +98,15 @@
:key="messenger"
:value="messenger"
severity="info"
class="text-xs"
/>
</div>
</template>
</Column>
<Column field="registeredClassesCount" header="کلاس‌های ثبت‌نامی">
<Column field="registeredClassesCount" header="کلاس‌های ثبت‌نامی" sortable>
<template #body="{ data }">
{{ toPersianDigits(data.registeredClassesCount ?? 0) }}
<Badge :value="toPersianDigits(data.registeredClassesCount ?? 0)" :severity="data.registeredClassesCount > 0 ? 'info' : 'secondary'" />
</template>
</Column>
@@ -64,11 +116,24 @@
</template>
</Column>
<Column header="عمولیات" style="width: 130px">
<Column header="عملیات" style="width: 150px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<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 permission="users:update">
@@ -88,11 +153,17 @@
:loading="isDeleting"
@confirm="handleDelete"
/>
<AddProfessorFromUserDialog
v-model="promoteDialogVisible"
:preselected-user-id="selectedPromoteUserId"
@saved="loadData"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
@@ -100,11 +171,13 @@ import { userApi } from '@/api/userApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Badge from 'primevue/badge';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
@@ -124,6 +197,29 @@ const deleteDialogVisible = ref(false);
const selectedUser = ref(null);
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) => {
selectedUser.value = user;
deleteDialogVisible.value = true;