Initial commit: admin dashboard for GameNo.

Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit 1a34c3b133
94 changed files with 12016 additions and 0 deletions
@@ -0,0 +1,158 @@
<!-- /src/views/attendances/AttendanceEntryView.vue -->
<template>
<div class="attendance-entry-view" v-if="session">
<PageHeader
:title="$t('attendances.title')"
:subtitle="`دوره: ${session.course?.title || '-'} | کلاس: ${session.class?.name || '-'} | تاریخ: ${formatJalali(session.day)} (${session.startTime || ''} تا ${session.endTime || ''})`"
>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/sessions')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-3" v-if="session.note">
<span class="text-muted text-xs block mb-1">یادداشت جلسه</span>
<p class="m-0 white-space-pre-wrap">{{ session.note }}</p>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-4 pb-3 border-bottom-1 border-color">
<div>
<h2 class="text-lg font-bold text-color m-0">لیست دانشجویان کلاس</h2>
<span class="text-muted text-xs">وضعیت حضور هر دانشجو را مشخص کنید</span>
</div>
<div class="flex align-items-center gap-2">
<Button label="علامت‌گذاری همه به عنوان حاضر" icon="pi pi-check-circle" size="small" text severity="success" @click="markAllPresent" />
</div>
</div>
<DataTable :value="studentList" class="p-datatable-sm text-sm" emptyMessage="دانشجویی در این کلاس ثبت‌نام نشده است">
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column field="name" header="نام و نام خانوادگی دانشجو">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
<span class="text-xs text-muted block">کد ملی: {{ toPersianDigits(data.nationalIdCode || '—') }}</span>
</template>
</Column>
<Column field="status" header="وضعیت حضور">
<template #body="{ data }">
<SelectButton
v-model="data.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="text-xs"
/>
</template>
</Column>
<Column field="note" header="یادداشت / توضیحات">
<template #body="{ data }">
<InputText v-model="data.note" class="w-full text-xs" placeholder="دلیل غیبت یا تاخیر..." />
</template>
</Column>
</DataTable>
<div class="flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
<Button
:label="$t('attendances.submitAttendance')"
icon="pi pi-save"
severity="success"
:loading="isSubmitting"
:disabled="!studentList.length"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { sessionApi } from '@/api/sessionApi';
import { attendanceApi } from '@/api/attendanceApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import SelectButton from 'primevue/selectbutton';
import InputText from 'primevue/inputtext';
const route = useRoute();
const router = useRouter();
const sessionId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const session = ref(null);
const studentList = ref([]);
const isSubmitting = ref(false);
const statusOptions = [
{ label: 'حاضر', value: 'present' },
{ label: 'غایب', value: 'absent' },
{ label: 'تاخیر', value: 'late' },
{ label: 'موجه', value: 'excused' }
];
const fetchSessionAttendance = async () => {
try {
const res = await sessionApi.getOne(sessionId);
const data = res.data || res;
session.value = data;
const roster = data.class?.students || [];
const attendanceByUser = new Map(
(data.attendanceList || []).map((row) => [
String(row.user?._id || row.user),
row
])
);
studentList.value = roster.map((user) => {
const existing = attendanceByUser.get(String(user._id));
return {
userId: user._id,
name: user.name || '',
surname: user.surname || '',
nationalIdCode: user.nationalIdCode || '',
status: existing?.status || 'present',
note: existing?.note || ''
};
});
} catch (err) {
showError(err);
}
};
const markAllPresent = () => {
studentList.value.forEach((s) => { s.status = 'present'; });
};
const handleSubmit = async () => {
isSubmitting.value = true;
try {
const payloadList = studentList.value.map((s) => ({
user: s.userId,
status: s.status,
note: s.note
}));
await attendanceApi.recordSessionAttendance(sessionId, payloadList);
showSuccess('حضور و غیاب جلسه با موفقیت ثبت شد');
router.push('/sessions');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(fetchSessionAttendance);
</script>
+158
View File
@@ -0,0 +1,158 @@
<!-- /src/views/auth/LoginView.vue -->
<template>
<div class="login-card-container">
<div class="login-view surface-card p-4 sm:p-5 border-round-2xl border-1 border-color shadow-lg">
<div class="text-center mb-4">
<div class="inline-flex align-items-center justify-content-center w-4rem h-4rem border-circle bg-primary-light text-primary mb-3">
<i class="pi pi-building text-3xl"></i>
</div>
<h1 class="text-2xl font-bold text-color m-0">{{ $t('auth.loginTitle') }}</h1>
<p class="text-muted text-sm mt-2 mb-0">{{ $t('auth.welcome') }}</p>
</div>
<!-- Credentials Hint Box -->
<div class="surface-ground p-3 border-round border-1 border-color mb-4 text-xs">
<div class="font-bold text-primary mb-1 flex align-items-center gap-1">
<i class="pi pi-key"></i>
<span>اطلاعات ورود مدیر سیستم:</span>
</div>
<div class="flex justify-content-between align-items-center mt-1">
<span>نام کاربری: <code class="font-bold text-color">superadmin</code></span>
<span>رمز عبور: <code class="font-bold text-color">SuperAdminSecret123!</code></span>
</div>
<Button label="جاگذاری اطلاعات حساب مدیر" size="small" text class="w-full mt-2 text-xs p-1" @click="fillDemo" />
</div>
<form @submit.prevent="handleLogin" class="flex flex-column gap-3">
<div class="flex flex-column gap-2">
<label for="username" class="font-semibold text-sm text-color">{{ $t('auth.username') }}</label>
<IconField iconPosition="right">
<InputIcon class="pi pi-user" />
<InputText
id="username"
v-model.trim="form.username"
class="w-full text-sm"
:class="{ 'p-invalid': errors.username }"
placeholder="superadmin"
autocomplete="username"
/>
</IconField>
<small v-if="errors.username" class="text-red-500 text-xs">{{ errors.username }}</small>
</div>
<div class="flex flex-column gap-2">
<label for="password" class="font-semibold text-sm text-color">{{ $t('auth.password') }}</label>
<IconField iconPosition="right">
<InputIcon class="pi pi-lock" />
<Password
id="password"
v-model="form.password"
:feedback="false"
toggleMask
class="w-full text-sm"
inputClass="w-full text-sm"
:class="{ 'p-invalid': errors.password }"
placeholder="SuperAdminSecret123!"
autocomplete="current-password"
/>
</IconField>
<small v-if="errors.password" class="text-red-500 text-xs">{{ errors.password }}</small>
</div>
<Button
type="submit"
:label="$t('auth.loginButton')"
icon="pi pi-sign-in"
class="w-full mt-2 font-bold"
:loading="loading"
/>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';
import { useToast } from '@/composables/useToast';
import { useI18n } from 'vue-i18n';
import InputText from 'primevue/inputtext';
import Password from 'primevue/password';
import Button from 'primevue/button';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const { showSuccess, showError } = useToast();
const { t } = useI18n();
const form = reactive({
username: 'superadmin',
password: 'SuperAdminSecret123!'
});
const errors = reactive({
username: '',
password: ''
});
const loading = ref(false);
const fillDemo = () => {
form.username = 'superadmin';
form.password = 'SuperAdminSecret123!';
};
const validate = () => {
let valid = true;
errors.username = '';
errors.password = '';
if (!form.username) {
errors.username = 'ورود نام کاربری الزامی است';
valid = false;
}
if (!form.password) {
errors.password = 'ورود رمز عبور الزامی است';
valid = false;
}
return valid;
};
const handleLogin = async () => {
if (!validate()) return;
loading.value = true;
try {
await authStore.login(form);
showSuccess(t('auth.loginSuccess'));
const redirectPath = route.query.redirect || '/';
router.push(redirectPath);
} catch (err) {
console.error('Login error:', err);
showError(err);
} finally {
loading.value = false;
}
};
</script>
<style scoped lang="scss">
.login-card-container {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
max-width: 28rem !important;
margin: auto !important;
}
.login-view {
width: 100% !important;
background: var(--surface-card);
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<!-- /src/views/classes/ClassDetailView.vue -->
<template>
<div class="class-detail-view" v-if="classData">
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
<Button label="ویرایش" icon="pi pi-pencil" class="ml-2" @click="$router.push(`/classes/edit/${classId}`)" />
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-lg font-bold text-color mb-3">مشخصات کلاس</h2>
<div class="grid">
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">استاد مدرس</span>
<span class="font-bold text-color text-sm">
{{ classData.professor ? `${classData.professor.name || ''} ${classData.professor.surname || ''}`.trim() : '—' }}
</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">ظرفیت کل</span>
<span class="font-bold text-color text-sm">{{ toPersianDigits(classData.capacity || 0) }} نفر</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">تعداد ثبتنامیها</span>
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">شهریه</span>
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.tuitionFee || 0).toLocaleString('en-US')) }} تومان</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">بازه زمانی</span>
<span class="font-bold text-color text-sm">
{{ formatJalali(classData.startDate) }} {{ formatJalali(classData.endDate) }}
</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">وضعیت</span>
<StatusTag :status="classData.isActive !== false" />
</div>
</div>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-lg font-bold mb-3">دانشجویان</h2>
<DataTable :value="classData.students || []" class="p-datatable-sm text-sm" emptyMessage="دانشجویی ثبت نشده">
<Column header="#">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام">
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }"><span dir="ltr">{{ data.phoneNumber || '—' }}</span></template>
</Column>
</DataTable>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3">
<h2 class="text-lg font-bold m-0">جلسات کلاس</h2>
<Button
label="مدیریت جلسات"
icon="pi pi-calendar"
text
size="small"
@click="$router.push(`/classes/edit/${classId}`)"
/>
</div>
<TableSkeleton v-if="loadingSessions" :rows="5" :columns="5" />
<DataTable
v-else
:value="sessions"
class="p-datatable-sm text-sm"
emptyMessage="جلسه‌ای برای این کلاس ثبت نشده است"
>
<Column header="#">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="موضوع">
<template #body="{ data }">{{ data.topic || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatJalali(data.day || data.date) }}</template>
</Column>
<Column header="زمان">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { classApi } from '@/api/classApi';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const route = useRoute();
const classId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showError } = useToast();
const classData = ref(null);
const sessions = ref([]);
const loadingSessions = ref(false);
const fetchClass = async () => {
try {
const res = await classApi.getOne(classId);
classData.value = res.data || res;
} catch (err) {
showError(err);
}
};
const fetchSessions = async () => {
loadingSessions.value = true;
try {
const res = await sessionApi.getAll({
limit: 100,
class: classId,
sortBy: 'day',
sortOrder: 'asc'
});
if (Array.isArray(res)) {
sessions.value = res;
} else if (Array.isArray(res?.data)) {
sessions.value = res.data;
} else if (Array.isArray(res?.items)) {
sessions.value = res.items;
} else {
sessions.value = [];
}
} catch (err) {
showError(err);
sessions.value = [];
} finally {
loadingSessions.value = false;
}
};
onMounted(() => {
fetchClass();
fetchSessions();
});
</script>
+316
View File
@@ -0,0 +1,316 @@
<!-- /src/views/classes/ClassFormView.vue -->
<template>
<div class="class-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
>
<Button
label="انصراف"
text
severity="secondary"
@click="goBack"
/>
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">نام کلاس *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" placeholder="مثلا: گروه الف - تابستان" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">دوره آموزشی مرتبط *</label>
<Dropdown v-model="form.course" :options="courses" optionLabel="title" optionValue="_id" placeholder="دوره را انتخاب کنید" class="w-full text-sm" filter />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">استاد مدرس</label>
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="استاد را انتخاب کنید" class="w-full text-sm" filter />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ظرفیت کلاس</label>
<InputNumber v-model="form.capacity" :min="1" class="w-full text-sm" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">شهریه کلاس</label>
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label>
<DatePicker v-model="form.startDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ پایان</label>
<DatePicker v-model="form.endDate" class="w-full text-sm" placeholder="1403/03/01" />
</div>
<div class="col-12 md:col-4 flex align-items-center gap-2 mt-4">
<InputSwitch v-model="form.isActive" />
<label class="font-semibold text-sm">کلاس فعال است</label>
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="goBack" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
<template v-if="isEditMode">
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-3">
<div>
<h2 class="text-lg font-bold m-0">دانشجویان کلاس</h2>
<p class="text-muted text-sm m-0 mt-1">دانشجویان ثبتنامشده در این کلاس</p>
</div>
<Tag :value="`${toPersianDigits(students.length)} نفر`" severity="info" />
</div>
<div class="flex flex-column md:flex-row gap-2 mb-3">
<MultiSelect
v-model="selectedUserIds"
:options="availableUsers"
optionLabel="label"
optionValue="_id"
placeholder="افزودن دانشجو…"
filter
display="chip"
class="flex-grow-1 text-sm"
/>
<Button
label="ثبت‌نام در کلاس"
icon="pi pi-user-plus"
:loading="registering"
:disabled="!selectedUserIds.length"
@click="registerSelected"
/>
</div>
<DataTable :value="students" class="p-datatable-sm text-sm" emptyMessage="هنوز دانشجویی ثبت‌نام نشده است">
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام">
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }">
<span dir="ltr">{{ data.phoneNumber || '—' }}</span>
</template>
</Column>
</DataTable>
</div>
<ClassSessionsSection
v-if="classId"
:class-id="String(classId)"
:course-id="form.course ? String(form.course) : ''"
:professor-id="form.professor ? String(form.professor) : null"
:default-session-count="defaultSessionCount"
/>
</template>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import moment from 'jalali-moment';
import { classApi } from '@/api/classApi';
import { courseApi } from '@/api/courseApi';
import { professorApi } from '@/api/professorApi';
import { userApi } from '@/api/userApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import InputSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import DatePicker from 'vue3-persian-datetime-picker';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const { toPersianDigits, toLatinDigits } = usePersianDate();
const classId = route.params.id;
const queryCourseId = route.query.courseId ? String(route.query.courseId) : null;
const isEditMode = computed(() => !!classId);
const isSubmitting = ref(false);
const registering = ref(false);
const defaultSessionCount = ref(12);
const courses = ref([]);
const professors = ref([]);
const students = ref([]);
const allUsers = ref([]);
const selectedUserIds = ref([]);
const form = reactive({
name: '',
course: queryCourseId,
professor: null,
capacity: 20,
tuitionFee: 0,
startDate: '',
endDate: '',
isActive: true
});
const availableUsers = computed(() => {
const enrolled = new Set(students.value.map((s) => String(s._id)));
return allUsers.value
.filter((u) => !enrolled.has(String(u._id)))
.map((u) => ({
...u,
label: `${u.name || ''} ${u.surname || ''}${u.phoneNumber || ''}`.trim()
}));
});
const goBack = () => {
if (form.course) {
router.push(`/courses/edit/${form.course}`);
return;
}
router.push('/classes');
};
const toGregorianIso = (jalaliValue) => {
if (!jalaliValue) return undefined;
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
const latin = toLatinDigits(String(jalaliValue));
const m = moment(latin, 'jYYYY/jMM/jDD');
return m.isValid() ? m.toDate().toISOString() : undefined;
};
const toJalaliDisplay = (value) => {
if (!value) return '';
return moment(value).locale('fa').format('jYYYY/jMM/jDD');
};
const applyCourseDefaults = (course) => {
if (!course) return;
if (!form.professor && (course.professor?._id || course.professor)) {
form.professor = course.professor?._id || course.professor;
}
if (course.capacity) form.capacity = course.capacity;
if (course.price != null) form.tuitionFee = course.price;
if (course.sectionCount) defaultSessionCount.value = course.sectionCount;
};
const fetchData = async () => {
try {
const [cRes, pRes, uRes] = await Promise.all([
courseApi.getAll({ limit: 100 }),
professorApi.getAll({ limit: 100 }),
userApi.getAll({ limit: 200 })
]);
const cData = cRes.data || cRes;
courses.value = Array.isArray(cData) ? cData : (cData.items || cData.courses || cData.data || []);
const pData = pRes.data || pRes;
professors.value = (Array.isArray(pData) ? pData : (pData.items || pData.professors || pData.data || [])).map((p) => ({
...p,
name: `${p.name || ''} ${p.surname || ''}`.trim()
}));
const uData = uRes.data || uRes;
allUsers.value = Array.isArray(uData) ? uData : (uData.items || uData.users || uData.data || []);
if (classId) {
const res = await classApi.getOne(classId);
const data = res.data || res;
Object.assign(form, {
name: data.name || '',
course: data.course?._id || data.course || null,
professor: data.professor?._id || data.professor || null,
capacity: data.capacity || 20,
tuitionFee: data.tuitionFee || 0,
startDate: toJalaliDisplay(data.startDate),
endDate: toJalaliDisplay(data.endDate),
isActive: data.isActive !== false
});
students.value = data.students || [];
if (data.course?.sectionCount) defaultSessionCount.value = data.course.sectionCount;
} else if (queryCourseId) {
const linked = courses.value.find((c) => String(c._id) === String(queryCourseId));
if (linked) {
applyCourseDefaults(linked);
} else {
try {
const one = await courseApi.getOne(queryCourseId);
applyCourseDefaults(one.data || one);
} catch (e) {
console.warn('Course prefetch error:', e);
}
}
}
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.name) { showError('نام کلاس الزامی است'); return; }
if (!form.course) { showError('دوره مرتبط الزامی است'); return; }
isSubmitting.value = true;
try {
const payload = {
name: form.name,
course: form.course,
professor: form.professor || undefined,
capacity: form.capacity,
tuitionFee: form.tuitionFee,
startDate: toGregorianIso(form.startDate),
endDate: toGregorianIso(form.endDate),
isActive: form.isActive
};
if (isEditMode.value) {
await classApi.update(classId, payload);
showSuccess('کلاس با موفقیت ویرایش شد');
await fetchData();
} else {
const res = await classApi.create(payload);
const created = res.data || res;
showSuccess('کلاس جدید با موفقیت ایجاد شد');
router.push(`/classes/edit/${created._id || created.id}`);
}
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
const registerSelected = async () => {
if (!selectedUserIds.value.length) return;
registering.value = true;
try {
const res = await classApi.registerUsers(classId, selectedUserIds.value);
const data = res.data || res;
students.value = data.students || [];
selectedUserIds.value = [];
showSuccess('دانشجویان با موفقیت به کلاس اضافه شدند');
} catch (err) {
showError(err);
} finally {
registering.value = false;
}
};
onMounted(fetchData);
</script>
+93
View File
@@ -0,0 +1,93 @@
<!-- /src/views/classes/ClassListView.vue -->
<template>
<div class="class-list-view">
<PageHeader title="مدیریت کلاس‌ها" subtitle="لیست گروه‌ها و کلاس‌های در حال برگزاری">
<PermissionGate permission="classes:create">
<Button label="تعریف کلاس جدید" icon="pi pi-plus" @click="$router.push('/classes/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام کلاس" sortable>
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }}</span>
</template>
</Column>
<Column field="course.title" header="دوره مرتبط">
<template #body="{ data }">
{{ data.course?.title || data.courseTitle || '-' }}
</template>
</Column>
<Column field="capacity" header="ظرفیت">
<template #body="{ data }">
{{ toPersianDigits(data.capacity || 0) }} نفر
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-eye"
text
rounded
size="small"
severity="info"
@click="$router.push(`/classes/view/${data._id || data.id}`)"
/>
<PermissionGate permission="classes:update">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
@click="$router.push(`/classes/edit/${data._id || data.id}`)"
/>
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { classApi } from '@/api/classApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
const { toPersianDigits } = usePersianDate();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(classApi.getAll);
onMounted(() => {
loadData();
});
</script>
@@ -0,0 +1,173 @@
<!-- /src/views/contactInquiries/ContactInquiryDetailView.vue -->
<template>
<div class="contact-inquiry-detail" v-if="inquiry">
<PageHeader
:title="`${inquiry.name} ${inquiry.surname}`"
subtitle="جزئیات و پیگیری درخواست تماس"
>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/contact-inquiries')" />
</PageHeader>
<div class="grid">
<div class="col-12 lg:col-7">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-3">
<h2 class="text-lg font-bold mb-3">اطلاعات تماس</h2>
<div class="grid">
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">کد ملی</span>
<span class="font-semibold" dir="ltr">{{ inquiry.nationalIdCode ? toPersianDigits(inquiry.nationalIdCode) : '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">موبایل</span>
<span class="font-semibold" dir="ltr">{{ inquiry.phoneNumber || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">ایمیل</span>
<span class="font-semibold" dir="ltr">{{ inquiry.email || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">زمان ثبت</span>
<span class="font-semibold">{{ formatJalali(inquiry.createdAt, 'jYYYY/jMM/jDD HH:mm') }}</span>
</div>
<div class="col-12">
<span class="text-muted text-xs block mb-1">روشهای ترجیحی تماس</span>
<div class="flex flex-wrap gap-1">
<Tag
v-for="method in (inquiry.preferredContactMethods || [])"
:key="method"
:value="methodLabel(method)"
/>
</div>
</div>
<div class="col-12">
<span class="text-muted text-xs block mb-1">دورههای مورد علاقه</span>
<div class="flex flex-wrap gap-1">
<Tag
v-for="course in (inquiry.courses || [])"
:key="course._id || course"
:value="course.title || course"
severity="info"
/>
<span v-if="!(inquiry.courses || []).length" class="text-muted">انتخاب نشده</span>
</div>
</div>
<div class="col-12">
<span class="text-muted text-xs block mb-1">پیام کاربر</span>
<p class="m-0 white-space-pre-wrap">{{ inquiry.message || '—' }}</p>
</div>
</div>
</div>
</div>
<div class="col-12 lg:col-5">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold mb-3">پیگیری</h2>
<div class="flex flex-column gap-3">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">وضعیت</label>
<Dropdown
v-model="form.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full text-sm"
/>
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">یادداشت داخلی</label>
<Textarea
v-model="form.notes"
rows="8"
class="w-full text-sm"
placeholder="نتیجه تماس، زمان مناسب تماس بعدی و …"
/>
</div>
<Button
label="ذخیره پیگیری"
icon="pi pi-save"
:loading="saving"
@click="save"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { contactInquiryApi } from '@/api/contactInquiryApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import Button from 'primevue/button';
import Dropdown from 'primevue/select';
import Textarea from 'primevue/textarea';
import Tag from 'primevue/tag';
const route = useRoute();
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const inquiry = ref(null);
const saving = ref(false);
const form = reactive({ status: 'new', notes: '' });
const statusOptions = [
{ label: 'جدید', value: 'new' },
{ label: 'مشاهده‌شده', value: 'seen' },
{ label: 'تماس بعدی', value: 'call_later' },
{ label: 'تماس گرفته شد', value: 'contacted' },
{ label: 'بسته', value: 'closed' }
];
const METHOD_LABELS = {
WhatsApp: 'واتساپ',
Telegram: 'تلگرام',
Soroush: 'سروش',
Bale: 'بله',
Eitaa: 'ایتا',
SMS: 'پیامک',
Call: 'تماس تلفنی'
};
const methodLabel = (value) => METHOD_LABELS[value] || value;
const load = async () => {
try {
const res = await contactInquiryApi.getOne(route.params.id);
const data = res.data || res;
inquiry.value = data;
form.status = data.status || 'new';
form.notes = data.notes || '';
if (data.status === 'new') {
await contactInquiryApi.update(route.params.id, { status: 'seen' });
form.status = 'seen';
inquiry.value.status = 'seen';
}
} catch (err) {
showError(err);
}
};
const save = async () => {
saving.value = true;
try {
const res = await contactInquiryApi.update(route.params.id, {
status: form.status,
notes: form.notes
});
inquiry.value = res.data || res;
showSuccess('پیگیری ذخیره شد');
} catch (err) {
showError(err);
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
@@ -0,0 +1,172 @@
<!-- /src/views/contactInquiries/ContactInquiryListView.vue -->
<template>
<div class="contact-inquiry-list-view">
<PageHeader title="درخواست‌های تماس" subtitle="پیگیری علاقه‌مندان و درخواست‌های مشاوره از وب‌سایت">
<div class="flex flex-wrap gap-2 align-items-center">
<Dropdown
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="وضعیت"
showClear
class="w-12rem text-sm"
@change="onStatusFilter"
/>
<Dropdown
v-model="selectedCourse"
:options="courses"
optionLabel="title"
optionValue="_id"
placeholder="فیلتر دوره"
showClear
filter
class="w-16rem text-sm"
@change="onCourseFilter"
/>
</div>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
<span v-if="data.nationalIdCode" class="text-xs text-muted block" dir="ltr">
کد ملی: {{ toPersianDigits(data.nationalIdCode) }}
</span>
</template>
</Column>
<Column field="phoneNumber" header="تماس">
<template #body="{ data }">
<span class="block" dir="ltr">{{ data.phoneNumber || '—' }}</span>
<span v-if="data.email" class="text-xs text-muted block" dir="ltr">{{ data.email }}</span>
</template>
</Column>
<Column field="courses" header="دوره‌های انتخابی">
<template #body="{ data }">
<div class="flex flex-wrap gap-1">
<Tag
v-for="course in (data.courses || [])"
:key="course._id || course"
:value="course.title || course"
severity="info"
class="text-xs"
/>
<span v-if="!(data.courses || []).length" class="text-muted text-xs"></span>
</div>
</template>
</Column>
<Column field="preferredContactMethods" header="روش تماس">
<template #body="{ data }">
<span class="text-sm">{{ (data.preferredContactMethods || []).map(methodLabel).join('، ') || '—' }}</span>
</template>
</Column>
<Column field="status" header="وضعیت" sortable>
<template #body="{ data }">
<StatusTag :status="data.status" type="contact" />
</template>
</Column>
<Column field="createdAt" header="زمان ثبت" sortable>
<template #body="{ data }">
{{ formatJalali(data.createdAt, 'jYYYY/jMM/jDD HH:mm') }}
</template>
</Column>
<Column header="عملیات" style="width: 90px">
<template #body="{ data }">
<Button
icon="pi pi-eye"
text
rounded
size="small"
v-tooltip.top="'مشاهده و پیگیری'"
@click="$router.push(`/contact-inquiries/view/${data._id}`)"
/>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { contactInquiryApi } from '@/api/contactInquiryApi';
import { courseApi } from '@/api/courseApi';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Dropdown from 'primevue/select';
import Tag from 'primevue/tag';
const { toPersianDigits, formatJalali } = usePersianDate();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch,
setFilter
} = useDataTable(contactInquiryApi.getAll, { sortBy: 'createdAt', sortOrder: 'desc' });
const courses = ref([]);
const selectedStatus = ref(null);
const selectedCourse = ref(null);
const statusOptions = [
{ label: 'جدید', value: 'new' },
{ label: 'مشاهده‌شده', value: 'seen' },
{ label: 'تماس بعدی', value: 'call_later' },
{ label: 'تماس گرفته شد', value: 'contacted' },
{ label: 'بسته', value: 'closed' }
];
const METHOD_LABELS = {
WhatsApp: 'واتساپ',
Telegram: 'تلگرام',
Soroush: 'سروش',
Bale: 'بله',
Eitaa: 'ایتا',
SMS: 'پیامک',
Call: 'تماس تلفنی'
};
const methodLabel = (value) => METHOD_LABELS[value] || value;
const onStatusFilter = () => setFilter('status', selectedStatus.value || '');
const onCourseFilter = () => setFilter('course', selectedCourse.value || '');
onMounted(async () => {
loadData();
try {
const res = await courseApi.getAll({ limit: 100 });
const data = res.data || res;
courses.value = Array.isArray(data) ? data : (data.items || data.courses || data.data || []);
} catch {
courses.value = [];
}
});
</script>
+112
View File
@@ -0,0 +1,112 @@
<!-- /src/views/courses/CourseDetailView.vue -->
<template>
<div class="course-detail-view" v-if="course">
<PageHeader :title="course.title" :subtitle="`استاد: ${course.professor?.name || course.professor || '-'}`">
<PermissionGate permission="courses:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
</PermissionGate>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/courses')" />
</PageHeader>
<div class="grid mb-4">
<div class="col-12 md:col-8">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<h3 class="text-lg font-bold mb-3">توضیحات دوره</h3>
<p class="text-color text-sm line-height-3">{{ course.description || 'توضیحاتی برای این دوره ثبت نشده است.' }}</p>
</div>
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
<h3 class="text-lg font-bold mb-3">جلسات دوره</h3>
<DataTable :value="courseSessions" class="p-datatable-sm text-sm">
<Column field="sessionNumber" header="#" style="width: 60px">
<template #body="{ data }">{{ toPersianDigits(data.sessionNumber) }}</template>
</Column>
<Column field="topic" header="موضوع جلسه" />
<Column field="date" header="تاریخ برگزاری">
<template #body="{ data }">{{ formatJalali(data.date) }}</template>
</Column>
<Column field="time" header="زمان">
<template #body="{ data }">{{ data.startTime }} - {{ data.endTime }}</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'Scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
<div class="col-12 md:col-4">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<h3 class="text-lg font-bold mb-3">اطلاعات تکمیلی</h3>
<div class="flex flex-column gap-3">
<div>
<span class="text-muted text-xs block mb-1">نوع دوره</span>
<Tag :value="course.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="course.type === 'Private' ? 'warning' : 'info'" />
</div>
<div>
<span class="text-muted text-xs block mb-1">شهریه</span>
<span class="font-bold text-color">{{ toPersianDigits(course.price?.toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">ظرفیت</span>
<span class="font-bold text-color">{{ toPersianDigits(course.capacity) }} نفر</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مدرک رسمی</span>
<Tag :value="course.isOfficial ? 'دارای مدرک رسمی' : 'بدون مدرک رسمی'" :severity="course.isOfficial ? 'success' : 'secondary'" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { courseApi } from '@/api/courseApi';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import PageHeader from '@/components/common/PageHeader.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const route = useRoute();
const courseId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const course = ref(null);
const courseSessions = ref([]);
const fetchDetail = async () => {
try {
const res = await courseApi.getOne(courseId);
course.value = res.data || res;
const sessionRes = await sessionApi.getAll({
courseId,
limit: 100,
sortBy: 'day',
sortOrder: 'asc'
});
const sessionData = sessionRes.data || sessionRes;
const list = Array.isArray(sessionData)
? sessionData
: (sessionData.items || sessionData.sessions || sessionData.data || []);
courseSessions.value = Array.isArray(list) ? list : [];
} catch (e) {
console.warn('Fetch course detail error:', e);
}
};
onMounted(() => {
fetchDetail();
});
</script>
+303
View File
@@ -0,0 +1,303 @@
<!-- /src/views/courses/CourseFormView.vue -->
<template>
<div class="course-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? $t('courses.editCourse') : $t('courses.addCourse')"
:subtitle="isEditMode ? 'ویرایش مشخصات دوره و مدیریت کلاس‌ها' : 'تعریف دوره آموزشی جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/courses')" />
</PageHeader>
<!-- Course Details -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-lg font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات اصلی دوره</h2>
<form @submit.prevent="handleSubmitCourse" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.courseTitle') }} *</label>
<InputText v-model.trim="form.title" class="w-full text-sm" :class="{ 'p-invalid': errors.title }" />
<small v-if="errors.title" class="text-red-500 text-xs">{{ errors.title }}</small>
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.type') }}</label>
<Dropdown v-model="form.type" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.price') }} *</label>
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" suffix=" تومان" />
<small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.professor') }}</label>
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="انتخاب استاد" class="w-full text-sm" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.capacity') }}</label>
<InputNumber v-model="form.capacity" class="w-full text-sm" :min="1" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تعداد جلسات</label>
<InputNumber v-model="form.sectionCount" class="w-full text-sm" :min="1" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت هر جلسه</label>
<InputNumber v-model="form.hoursPerSection" class="w-full text-sm" :min="0" :maxFractionDigits="1" />
</div>
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
<InputSwitch v-model="form.isOfficial" />
<label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
</div>
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
<InputSwitch v-model="form.showOnFrontend" />
<label class="font-semibold text-sm">نمایش در وبسایت</label>
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.description') }}</label>
<Textarea v-model="form.description" rows="3" class="w-full text-sm" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">نکات برجسته (هر خط یک مورد)</label>
<Textarea
v-model="highlightsText"
rows="4"
class="w-full text-sm"
placeholder="مثال:&#10;مناسب مبتدیان&#10;همراه با گواهی‌نامه&#10;تمرین عملی"
/>
</div>
<div class="col-12 flex justify-content-end mt-3">
<Button type="submit" label="ذخیره مشخصات دوره" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
<!-- Classes of this course (edit mode only) -->
<div v-if="isEditMode" class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div>
<h2 class="text-lg font-bold m-0">کلاسهای این دوره</h2>
<p class="text-muted text-sm m-0 mt-1">برای هر کلاس میتوانید دانشجویان و جلسات را جداگانه مدیریت کنید</p>
</div>
<Button
label="تعریف کلاس جدید"
icon="pi pi-plus"
@click="$router.push(`/classes/create?courseId=${courseId}`)"
/>
</div>
<TableSkeleton v-if="loadingClasses" :rows="5" :columns="6" />
<DataTable
v-else
:value="courseClasses"
class="p-datatable-sm text-sm"
emptyMessage="هنوز کلاسی برای این دوره تعریف نشده است"
>
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام کلاس">
<template #body="{ data }">
<span class="font-bold">{{ data.name }}</span>
</template>
</Column>
<Column header="استاد">
<template #body="{ data }">
{{ data.professor ? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() : '—' }}
</template>
</Column>
<Column header="ظرفیت" style="width: 90px">
<template #body="{ data }">{{ toPersianDigits(data.capacity || 0) }}</template>
</Column>
<Column header="دانشجو" style="width: 90px">
<template #body="{ data }">{{ toPersianDigits((data.students || []).length) }}</template>
</Column>
<Column header="وضعیت" style="width: 90px">
<template #body="{ data }">
<StatusTag :status="data.isActive !== false" />
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-eye"
text
rounded
size="small"
severity="info"
v-tooltip.top="'مشاهده'"
@click="$router.push(`/classes/view/${data._id || data.id}`)"
/>
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
v-tooltip.top="'ویرایش / جلسات'"
@click="$router.push(`/classes/edit/${data._id || data.id}`)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<div v-else class="surface-card p-4 border-round border-1 border-color shadow-sm">
<p class="text-muted text-sm m-0">پس از ذخیره دوره، میتوانید کلاسهای آن را در همین صفحه تعریف کنید. جلسات از داخل هر کلاس ساخته میشوند.</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { courseApi } from '@/api/courseApi';
import { professorApi } from '@/api/professorApi';
import { classApi } from '@/api/classApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/select';
import Textarea from 'primevue/textarea';
import InputSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const route = useRoute();
const router = useRouter();
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const courseId = route.params.id;
const isEditMode = computed(() => !!courseId);
const isSubmitting = ref(false);
const loadingClasses = ref(false);
const professors = ref([]);
const courseClasses = ref([]);
const typeOptions = [
{ label: 'عمومی', value: 'General' },
{ label: 'خصوصی', value: 'Private' }
];
const form = reactive({
title: '',
description: '',
type: 'General',
price: 1500000,
professor: null,
capacity: 20,
sectionCount: 12,
hoursPerSection: 1.5,
isOfficial: true,
showOnFrontend: true
});
const highlightsText = ref('');
const errors = reactive({
title: '',
price: ''
});
const fetchProfessors = async () => {
try {
const res = await professorApi.getAll({ limit: 100 });
const data = res.data || res;
professors.value = data.items || data.professors || data || [];
} catch (e) {
console.warn('Professors fetch error:', e);
}
};
const fetchClassesForCourse = async () => {
if (!courseId) return;
loadingClasses.value = true;
try {
const res = await classApi.getAll({ limit: 100, courseId });
const data = res.data || res;
courseClasses.value = Array.isArray(data) ? data : (data.items || data.data || []);
} catch (e) {
console.warn('Classes fetch error:', e);
} finally {
loadingClasses.value = false;
}
};
const fetchCourse = async () => {
if (!courseId) return;
try {
const res = await courseApi.getOne(courseId);
const data = res.data || res;
Object.assign(form, {
title: data.title || '',
description: data.description || '',
type: data.type || 'General',
price: data.price || 0,
professor: data.professor?._id || data.professor || null,
capacity: data.capacity || 20,
sectionCount: data.sectionCount || 1,
hoursPerSection: data.hoursPerSection ?? 1.5,
isOfficial: data.isOfficial !== false,
showOnFrontend: data.showOnFrontend !== false
});
highlightsText.value = Array.isArray(data.highlights) ? data.highlights.join('\n') : '';
} catch (err) {
showError(err);
}
};
const handleSubmitCourse = async () => {
if (!form.title) { errors.title = 'عنوان دوره الزامی است'; return; }
if (form.price === null) { errors.price = 'شهریه الزامی است'; return; }
isSubmitting.value = true;
try {
const payload = {
...form,
highlights: highlightsText.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
};
if (isEditMode.value) {
await courseApi.update(courseId, payload);
showSuccess('مشخصات دوره با موفقیت ویرایش شد');
} else {
const res = await courseApi.create(payload);
const newCourse = res.data || res;
showSuccess('دوره جدید با موفقیت ایجاد شد');
if (newCourse._id || newCourse.id) {
router.push(`/courses/edit/${newCourse._id || newCourse.id}`);
}
}
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchProfessors();
fetchCourse();
fetchClassesForCourse();
});
</script>
+144
View File
@@ -0,0 +1,144 @@
<!-- /src/views/courses/CourseListView.vue -->
<template>
<div class="course-list-view">
<PageHeader :title="$t('courses.title')" :subtitle="$t('courses.subtitle')">
<PermissionGate permission="courses:create">
<Button :label="$t('courses.addCourse')" icon="pi pi-plus" @click="$router.push('/courses/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="title" header="عنوان دوره" sortable>
<template #body="{ data }">
<router-link :to="`/courses/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.title }}
</router-link>
</template>
</Column>
<Column field="type" header="نوع دوره">
<template #body="{ data }">
<Tag :value="data.type === 'Private' ? $t('courses.typePrivate') : $t('courses.typeGeneral')" :severity="data.type === 'Private' ? 'warning' : 'info'" />
</template>
</Column>
<Column field="price" header="شهریه">
<template #body="{ data }">
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="rating" header="امتیاز">
<template #body="{ data }">
<Rating :modelValue="data.rating || 5" readonly :cancel="false" />
</template>
</Column>
<Column field="isOfficial" header="مدرک رسمی">
<template #body="{ data }">
<Tag :value="data.isOfficial ? $t('courses.official') : $t('courses.unofficial')" :severity="data.isOfficial ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="تخفیف فعال">
<template #body="{ data }">
<Tag v-if="data.activeDiscount" value="دارای تخفیف" severity="danger" />
<span v-else class="text-muted text-xs">-</span>
</template>
</Column>
<Column header="عملیات" style="width: 130px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="courses:read">
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/courses/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="courses:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/courses/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="courses:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { courseApi } from '@/api/courseApi';
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 Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Rating from 'primevue/rating';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(courseApi.getAll);
const deleteDialogVisible = ref(false);
const selectedCourse = ref(null);
const isDeleting = ref(false);
const confirmDelete = (course) => {
selectedCourse.value = course;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedCourse.value) return;
isDeleting.value = true;
try {
await courseApi.delete(selectedCourse.value._id || selectedCourse.value.id);
showSuccess('دوره آموزشی با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+432
View File
@@ -0,0 +1,432 @@
<!-- /src/views/dashboard/DashboardHomeView.vue -->
<template>
<div class="dashboard-home-view">
<PageHeader title="داشبورد مدیریتی" subtitle="خلاصه آمار، عملکرد و دسترسی‌های سریع سیستم">
<Button icon="pi pi-refresh" text rounded v-tooltip.top="'بروزرسانی'" :loading="isLoading" @click="loadStats" />
</PageHeader>
<!-- Stats Cards -->
<div class="grid mb-4">
<div class="col-12 sm:col-6 lg:col-3" v-for="stat in statCards" :key="stat.title">
<div class="stat-card surface-card p-4 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between gap-3">
<div class="flex-grow-1">
<span class="text-muted text-xs block mb-1">{{ stat.title }}</span>
<div class="flex align-items-baseline gap-2">
<span class="text-3xl font-bold text-color">
<span v-if="isLoading"></span>
<span v-else>{{ toPersianDigits(stat.value) }}</span>
</span>
<span v-if="stat.sub !== undefined && !isLoading" class="text-xs text-muted">
({{ toPersianDigits(stat.sub) }} فعال)
</span>
</div>
</div>
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="stat.bgClass">
<i :class="stat.icon" class="text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid mb-4">
<!-- Daily Enrollment Chart -->
<div class="col-12 lg:col-8">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<div class="flex align-items-center justify-content-between mb-4">
<h3 class="text-base font-bold text-color m-0">روند ثبتنام کاربران (۱۴ روز اخیر)</h3>
<Tag value="روزانه" severity="info" />
</div>
<div class="chart-container" style="height: 200px;">
<div v-if="isLoading" class="flex align-items-center justify-content-center h-full">
<ProgressSpinner style="width: 40px; height: 40px;" />
</div>
<div v-else-if="!dailyLabels.length" class="flex align-items-center justify-content-center h-full text-muted">
<span>دادهای برای نمایش وجود ندارد</span>
</div>
<div v-else class="bar-chart-wrapper h-full flex align-items-end gap-1">
<div
v-for="(item, idx) in dailyEnrollmentData"
:key="idx"
class="bar-group flex flex-column align-items-center gap-1 flex-grow-1"
>
<span class="text-xs font-bold text-primary">{{ toPersianDigits(item.count) }}</span>
<div
class="bar-fill border-round-top"
:style="{ height: getBarHeight(item.count, maxEnrollment) + 'px', minHeight: '4px' }"
></div>
<span class="bar-label text-muted text-center">{{ item.label }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Summary Doughnuts -->
<div class="col-12 lg:col-4">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<h3 class="text-base font-bold text-color m-0 mb-4">وضعیت فعال / کل</h3>
<div class="flex flex-column gap-3">
<div v-for="ring in ringStats" :key="ring.label" class="flex align-items-center gap-3">
<div class="ring-chart flex-shrink-0" :style="getRingStyle(ring)">
<span class="ring-pct text-xs font-bold">{{ ring.pct }}٪</span>
</div>
<div class="flex-grow-1">
<div class="text-sm font-semibold text-color">{{ ring.label }}</div>
<div class="text-xs text-muted mt-1">
<span class="font-bold" :class="ring.activeClass">{{ toPersianDigits(ring.active) }}</span>
<span class="mx-1">/</span>
<span>{{ toPersianDigits(ring.total) }} کل</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Access -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
<h3 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">دسترسی سریع</h3>
<div class="grid">
<div
v-for="link in quickLinks"
:key="link.to"
class="col-6 sm:col-4 lg:col-2"
>
<div
class="quick-link-card border-round-xl p-3 flex flex-column align-items-center gap-2 cursor-pointer text-center"
:class="link.cardClass"
@click="$router.push(link.to)"
>
<div class="quick-link-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center" :class="link.iconBg">
<i :class="[link.icon, 'text-xl', link.iconColor]"></i>
</div>
<span class="text-sm font-semibold text-color">{{ link.label }}</span>
<Tag v-if="link.badge !== undefined" :value="toPersianDigits(link.badge)" :severity="link.badgeSeverity || 'secondary'" class="text-xs" />
</div>
</div>
</div>
</div>
<!-- Recent Sessions -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-base font-bold text-color m-0">آخرین جلسات</h3>
<Button label="مشاهده همه" icon="pi pi-arrow-left" text size="small" @click="$router.push('/sessions')" />
</div>
<DataTable :value="recentSessions" class="p-datatable-sm text-sm" :loading="isLoading">
<template #empty>
<div class="text-center text-muted p-4">هنوز جلسهای ثبت نشده است</div>
</template>
<Column field="topic" header="موضوع">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
</template>
</Column>
<Column field="course" header="دوره">
<template #body="{ data }">
{{ data.course?.title || data.courseTitle || '—' }}
</template>
</Column>
<Column field="class" header="کلاس">
<template #body="{ data }">
{{ data.class?.name || '—' }}
</template>
</Column>
<Column field="professor" header="استاد">
<template #body="{ data }">
{{
data.professor
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
: '—'
}}
</template>
</Column>
<Column field="day" header="تاریخ">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="time" header="ساعت">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
import { dashboardApi } from '@/api/dashboardApi';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import ProgressSpinner from 'primevue/progressspinner';
const { toPersianDigits, formatJalali } = usePersianDate();
// ── State ─────────────────────────────────────────────────────────────────────
const isLoading = ref(false);
const stats = ref({
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
recentSessions: [],
charts: { dailyUsers: [], dailySessions: [] }
});
// ── Stat Cards ────────────────────────────────────────────────────────────────
const statCards = computed(() => [
{
title: 'کل کاربران',
value: stats.value.totals.users,
sub: stats.value.totals.activeUsers,
icon: 'pi pi-users text-blue-500',
bgClass: 'bg-blue-100'
},
{
title: 'اساتید موسسه',
value: stats.value.totals.professors,
sub: stats.value.totals.activeProfessors,
icon: 'pi pi-id-card text-green-500',
bgClass: 'bg-green-100'
},
{
title: 'دوره‌های آموزشی',
value: stats.value.totals.courses,
icon: 'pi pi-book text-purple-500',
bgClass: 'bg-purple-100'
},
{
title: 'کل جلسات',
value: stats.value.totals.sessions,
icon: 'pi pi-calendar text-orange-500',
bgClass: 'bg-orange-100'
}
]);
// ── Ring Stats ────────────────────────────────────────────────────────────────
const ringStats = computed(() => {
const t = stats.value.totals;
return [
{
label: 'کاربران فعال',
total: t.users,
active: t.activeUsers,
pct: t.users ? Math.round((t.activeUsers / t.users) * 100) : 0,
color: '#3B82F6',
activeClass: 'text-blue-500'
},
{
label: 'اساتید فعال',
total: t.professors,
active: t.activeProfessors,
pct: t.professors ? Math.round((t.activeProfessors / t.professors) * 100) : 0,
color: '#10B981',
activeClass: 'text-green-500'
}
];
});
// ── Chart Helpers ─────────────────────────────────────────────────────────────
const dailyEnrollmentData = computed(() => {
const raw = stats.value.charts?.dailyUsers || [];
return raw.map((item) => ({
count: item.count || 0,
label: formatJalali(item.date || buildDateFromParts(item._id), 'jD jMMMM')
}));
});
const buildDateFromParts = (parts) => {
if (!parts?.year || !parts?.month || !parts?.day) return null;
return new Date(parts.year, parts.month - 1, parts.day);
};
const dailyLabels = computed(() => dailyEnrollmentData.value.map((i) => i.label));
const maxEnrollment = computed(() => {
const counts = dailyEnrollmentData.value.map((i) => i.count);
return Math.max(...counts, 1);
});
const MAX_BAR_HEIGHT = 140;
const getBarHeight = (val, max) => Math.max(4, (val / max) * MAX_BAR_HEIGHT);
const getRingStyle = (ring) => {
const deg = (ring.pct / 100) * 360;
return {
background: `conic-gradient(${ring.color} ${deg}deg, var(--surface-border) ${deg}deg)`,
borderRadius: '50%',
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative'
};
};
// ── Recent Sessions ───────────────────────────────────────────────────────────
const recentSessions = computed(() => stats.value.recentSessions || []);
// ── Quick Links ───────────────────────────────────────────────────────────────
const quickLinks = computed(() => [
{
to: '/users/create',
label: 'افزودن کاربر',
icon: 'pi pi-user-plus',
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
cardClass: 'quick-link-blue',
badge: stats.value.totals.users,
badgeSeverity: 'info'
},
{
to: '/professors/create',
label: 'افزودن استاد',
icon: 'pi pi-plus-circle',
iconBg: 'bg-green-100',
iconColor: 'text-green-600',
cardClass: 'quick-link-green',
badge: stats.value.totals.professors,
badgeSeverity: 'success'
},
{
to: '/courses/create',
label: 'دوره جدید',
icon: 'pi pi-book',
iconBg: 'bg-purple-100',
iconColor: 'text-purple-600',
cardClass: 'quick-link-purple',
badge: stats.value.totals.courses,
badgeSeverity: 'secondary'
},
{
to: '/sessions/create',
label: 'جلسه جدید',
icon: 'pi pi-calendar-plus',
iconBg: 'bg-orange-100',
iconColor: 'text-orange-600',
cardClass: 'quick-link-orange',
badge: stats.value.totals.sessions,
badgeSeverity: 'warning'
},
{
to: '/payments',
label: 'امور مالی',
icon: 'pi pi-wallet',
iconBg: 'bg-teal-100',
iconColor: 'text-teal-600',
cardClass: 'quick-link-teal'
},
{
to: '/roles',
label: 'نقش‌ها',
icon: 'pi pi-shield',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
cardClass: 'quick-link-red'
}
]);
// ── Load Data ─────────────────────────────────────────────────────────────────
const loadStats = async () => {
isLoading.value = true;
try {
const res = await dashboardApi.getStats();
const data = res.data || res;
// Support both { data: { totals... } } and { totals... } structures
stats.value = data.data || data;
} catch (err) {
console.warn('Dashboard stats not available, showing zeros:', err.message);
// Graceful fallback — zeros, no crash
} finally {
isLoading.value = false;
}
};
onMounted(() => {
loadStats();
});
</script>
<style scoped lang="scss">
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1) !important;
}
}
.stat-icon {
transition: transform 0.2s ease;
.stat-card:hover & {
transform: scale(1.05);
}
}
.bar-chart-wrapper {
padding-bottom: 4px;
}
.bar-fill {
width: 100%;
background: linear-gradient(to top, var(--primary-color), var(--primary-color-light, #818cf8));
min-width: 12px;
transition: height 0.4s ease;
}
.bar-label {
font-size: 9px;
line-height: 1.2;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ring-chart {
position: relative;
.ring-pct {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--surface-card);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
}
}
.quick-link-card {
background: var(--surface-ground);
transition: all 0.2s ease;
border: 1px solid transparent;
&:hover {
transform: translateY(-3px);
border-color: var(--primary-color);
background: var(--primary-50, rgba(99, 102, 241, 0.04));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<!-- /src/views/errors/ForbiddenView.vue -->
<template>
<div class="forbidden-view flex flex-column align-items-center justify-content-center min-h-screen text-center p-4">
<div class="inline-flex align-items-center justify-content-center w-6rem h-6rem border-circle bg-red-100 text-red-600 mb-4">
<i class="pi pi-lock text-5xl"></i>
</div>
<h1 class="text-4xl font-bold text-color mb-2">{{ $t('errors.forbiddenTitle') }}</h1>
<p class="text-muted text-base max-w-28rem mb-4">{{ $t('errors.forbiddenMessage') }}</p>
<Button :label="$t('errors.backToDashboard')" icon="pi pi-home" @click="$router.push('/')" />
</div>
</template>
<script setup>
import Button from 'primevue/button';
</script>
+15
View File
@@ -0,0 +1,15 @@
<!-- /src/views/errors/NotFoundView.vue -->
<template>
<div class="not-found-view flex flex-column align-items-center justify-content-center min-h-screen text-center p-4">
<div class="inline-flex align-items-center justify-content-center w-6rem h-6rem border-circle bg-orange-100 text-orange-600 mb-4">
<i class="pi pi-exclamation-circle text-5xl"></i>
</div>
<h1 class="text-4xl font-bold text-color mb-2">{{ $t('errors.notFoundTitle') }}</h1>
<p class="text-muted text-base max-w-28rem mb-4">{{ $t('errors.notFoundMessage') }}</p>
<Button :label="$t('errors.backToDashboard')" icon="pi pi-home" @click="$router.push('/')" />
</div>
</template>
<script setup>
import Button from 'primevue/button';
</script>
@@ -0,0 +1,131 @@
<!-- /src/views/notifications/NotificationListView.vue -->
<template>
<div class="notification-list-view">
<PageHeader :title="$t('notifications.title')" :subtitle="$t('notifications.subtitle')" />
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="recipient" header="گیرنده">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.user?.name || data.recipient || '-' }}</span>
<span class="text-xs text-muted block" dir="ltr">{{ data.user?.phoneNumber || data.targetAddress || '' }}</span>
</template>
</Column>
<Column field="channel" header="کانال ارسال">
<template #body="{ data }">
<Tag :value="getChannelLabel(data.channel)" :severity="getChannelSeverity(data.channel)" />
</template>
</Column>
<Column field="subject" header="موضوع / متن">
<template #body="{ data }">
<span class="text-sm truncate max-w-20rem block" :title="data.subject || data.content">
{{ data.subject || data.content || '-' }}
</span>
</template>
</Column>
<Column field="status" header="وضعیت تحویل">
<template #body="{ data }">
<StatusTag :status="data.status || 'delivered'" type="notification" />
</template>
</Column>
<Column field="retryCount" header="تعداد تلاش">
<template #body="{ data }">
{{ toPersianDigits(data.retryCount || 0) }}
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<PermissionGate permission="notifications:retry">
<Button
v-if="data.status === 'failed' || data.status === 'ناموفق'"
icon="pi pi-refresh"
label="تلاش مجدد"
size="small"
severity="warning"
:loading="retryingId === (data._id || data.id)"
@click="handleRetry(data)"
/>
</PermissionGate>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { notificationApi } from '@/api/notificationApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.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';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(notificationApi.getAll);
const retryingId = ref(null);
const getChannelLabel = (ch) => {
if (ch === 'bale') return 'پیام‌رسان بله';
if (ch === 'sms') return 'پیامک SMS';
if (ch === 'email') return 'ایمیل';
return ch || 'بله';
};
const getChannelSeverity = (ch) => {
if (ch === 'bale') return 'success';
if (ch === 'sms') return 'info';
if (ch === 'email') return 'warning';
return 'secondary';
};
const handleRetry = async (item) => {
const id = item._id || item.id;
retryingId.value = id;
try {
await notificationApi.retry(id);
showSuccess('ارسال مجدد با موفقیت درخواست شد');
loadData();
} catch (err) {
showError(err);
} finally {
retryingId.value = null;
}
};
onMounted(() => {
loadData();
});
</script>
+176
View File
@@ -0,0 +1,176 @@
<!-- /src/views/payments/PaymentDetailView.vue -->
<template>
<div class="payment-detail-view" v-if="payment">
<PageHeader :title="$t('payments.paymentDetail')" :subtitle="`کد صورتحساب: ${payment._id || payment.id}`">
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
</PageHeader>
<div class="grid mb-4">
<!-- Summary Card -->
<div class="col-12 lg:col-4">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<h3 class="text-lg font-bold mb-3">خلاصه صورتحساب</h3>
<div class="flex flex-column gap-3">
<div>
<span class="text-muted text-xs block mb-1">نام کاربر / دانشجو</span>
<span class="font-bold text-color text-base">{{ payment.user?.name || payment.userName || 'کاربر' }} {{ payment.user?.surname || '' }}</span>
</div>
<div v-if="payment.classes && payment.classes.length">
<span class="text-muted text-xs block mb-1">کلاسهای مربوطه</span>
<div class="flex flex-wrap gap-1 mt-1">
<Tag v-for="c in payment.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
</div>
</div>
<div v-else-if="payment.course">
<span class="text-muted text-xs block mb-1">دوره آموزشی</span>
<span class="font-semibold text-color text-sm">{{ payment.course?.title }}</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مبلغ کل صورتحساب</span>
<span class="font-bold text-color text-xl">{{ toPersianDigits(payment.amount?.toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مبلغ کل دریافتی</span>
<span class="font-bold text-green-600 text-lg">{{ toPersianDigits((payment.paidAmount || 0).toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">باقیمانده</span>
<span class="font-bold text-red-500 text-lg">{{ toPersianDigits((payment.amount - (payment.paidAmount || 0)).toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">وضعیت پرداخت</span>
<StatusTag :status="payment.status" type="payment" />
</div>
</div>
</div>
</div>
<!-- Transaction History & Form -->
<div class="col-12 lg:col-8">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-lg font-bold m-0">تاریخچه تراکنشهای دریافتی</h3>
<PermissionGate permission="payments:update">
<Button label="ثبت تراکنش جدید" icon="pi pi-plus" size="small" severity="success" @click="showTransactionModal = true" />
</PermissionGate>
</div>
<DataTable :value="payment.transactions || []" class="p-datatable-sm text-sm">
<Column field="amount" header="مبلغ تراکنش">
<template #body="{ data }">
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="method" header="روش پرداخت">
<template #body="{ data }">
<Tag :value="data.method === 'online' ? 'درگاه آنلاین' : (data.method === 'card' ? 'کارت به کارت' : 'نقدی')" severity="info" />
</template>
</Column>
<Column field="receiptNumber" header="شماره فیش / پیگیری">
<template #body="{ data }">
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '-') }}</span>
</template>
</Column>
<Column field="createdAt" header="تاریخ ثبت">
<template #body="{ data }">
{{ formatJalali(data.createdAt || data.date) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Record New Transaction Modal -->
<Dialog v-model:visible="showTransactionModal" header="ثبت تراکنش جدید" modal :style="{ width: '450px' }">
<div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
<InputNumber v-model="trxForm.amount" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">روش پرداخت *</label>
<Dropdown v-model="trxForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">شماره فیش / پیگیری *</label>
<InputText v-model.trim="trxForm.receiptNumber" class="w-full text-sm" dir="ltr" />
</div>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showTransactionModal = false" />
<Button label="ثبت تراکنش" icon="pi pi-check" severity="success" :loading="isSubmittingTrx" @click="handleRecordTransaction" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { paymentApi } from '@/api/paymentApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
const route = useRoute();
const paymentId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const payment = ref(null);
const showTransactionModal = ref(false);
const isSubmittingTrx = ref(false);
const methodOptions = [
{ label: 'کارت به کارت', value: 'card' },
{ label: 'درگاه آنلاین', value: 'online' },
{ label: 'نقدی / شبا', value: 'cash' }
];
const trxForm = reactive({
amount: 500000,
method: 'card',
receiptNumber: ''
});
const fetchPayment = async () => {
try {
const res = await paymentApi.getOne(paymentId);
payment.value = res.data || res;
} catch (err) {
showError(err);
}
};
const handleRecordTransaction = async () => {
if (!trxForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
if (!trxForm.receiptNumber) { showError('لطفا شماره فیش یا پیگیری را وارد کنید'); return; }
isSubmittingTrx.value = true;
try {
await paymentApi.recordTransaction(paymentId, trxForm);
showSuccess('تراکنش جدید با موفقیت ثبت شد');
showTransactionModal.value = false;
fetchPayment();
} catch (err) {
showError(err);
} finally {
isSubmittingTrx.value = false;
}
};
onMounted(() => {
fetchPayment();
});
</script>
+250
View File
@@ -0,0 +1,250 @@
<!-- /src/views/payments/PaymentListView.vue -->
<template>
<div class="payment-list-view">
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
<PermissionGate permission="payments:create">
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="showCreateModal = true" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="user" header="نام دانشجو / کاربر">
<template #body="{ data }">
<router-link :to="`/payments/view/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.user?.name || data.userName || 'کاربر' }} {{ data.user?.surname || '' }}
</router-link>
</template>
</Column>
<Column field="classes" header="کلاس‌های مربوطه">
<template #body="{ data }">
<div class="flex flex-wrap gap-1" v-if="data.classes && data.classes.length">
<Tag v-for="c in data.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
</div>
<span v-else-if="data.course" class="text-muted text-xs">{{ data.course?.title }}</span>
<span v-else class="text-muted text-xs">-</span>
</template>
</Column>
<Column field="amount" header="مبلغ کل (تومان)" sortable>
<template #body="{ data }">
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان
</template>
</Column>
<Column field="paidAmount" header="مبلغ پرداختی">
<template #body="{ data }">
{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان
</template>
</Column>
<Column field="dueDate" header="تاریخ سررسید" sortable>
<template #body="{ data }">
{{ formatJalali(data.dueDate) }}
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status" type="payment" />
</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="payments:read">
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="payments:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<!-- Create Payment Modal -->
<Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '480px' }">
<div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
<Dropdown v-model="createForm.user" :options="usersList" optionLabel="fullName" optionValue="_id" filter placeholder="کاربر را انتخاب کنید" class="w-full text-sm" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس مربوطه *</label>
<MultiSelect
v-model="createForm.classes"
:options="classesList"
optionLabel="name"
optionValue="_id"
display="chip"
placeholder="کلاس‌ها را انتخاب کنید"
class="w-full text-sm"
@change="onClassesSelected"
/>
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ کل صورتحساب (تومان) *</label>
<InputNumber v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ سررسید *</label>
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
<Button label="ایجاد صورتحساب" icon="pi pi-check" severity="success" :loading="isCreating" @click="handleCreatePayment" />
</template>
</Dialog>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { paymentApi } from '@/api/paymentApi';
import { userApi } from '@/api/userApi';
import { classApi } from '@/api/classApi';
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 StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber';
import DatePicker from 'vue3-persian-datetime-picker';
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(paymentApi.getAll);
const showCreateModal = ref(false);
const usersList = ref([]);
const classesList = ref([]);
const isCreating = ref(false);
const createForm = reactive({
user: null,
classes: [],
amount: 0,
dueDate: ''
});
const deleteDialogVisible = ref(false);
const selectedPayment = ref(null);
const isDeleting = ref(false);
const onClassesSelected = () => {
if (!createForm.classes || createForm.classes.length === 0) return;
let totalFee = 0;
createForm.classes.forEach(classId => {
const c = classesList.value.find(item => (item._id || item.id) === classId);
if (c) {
totalFee += (c.tuitionFee || c.course?.price || 0);
}
});
if (totalFee > 0) {
createForm.amount = totalFee;
}
};
const fetchDropdownData = async () => {
try {
const [uRes, cRes] = await Promise.all([
userApi.getAll({ limit: 150 }),
classApi.getAll({ limit: 100 })
]);
const uData = uRes.data || uRes;
const rawUsers = uData.items || uData.users || uData || [];
usersList.value = rawUsers.map(u => ({ ...u, fullName: `${u.name} ${u.surname}` }));
const cData = cRes.data || cRes;
classesList.value = cData.items || cData.classes || cData || [];
} catch (e) {
console.warn('Dropdown fetch error:', e);
}
};
const handleCreatePayment = async () => {
if (!createForm.user) { showError('لطفا کاربر را انتخاب کنید'); return; }
if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
isCreating.value = true;
try {
await paymentApi.create(createForm);
showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
showCreateModal.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isCreating.value = false;
}
};
const confirmDelete = (payment) => {
selectedPayment.value = payment;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedPayment.value) return;
isDeleting.value = true;
try {
await paymentApi.delete(selectedPayment.value._id || selectedPayment.value.id);
showSuccess('صورتحساب با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
fetchDropdownData();
});
</script>
+112
View File
@@ -0,0 +1,112 @@
<!-- /src/views/professors/ProfessorFormView.vue -->
<template>
<div class="professor-form-view w-full max-w-4xl mx-auto">
<PageHeader
:title="isEditMode ? $t('professors.editProfessor') : $t('professors.addProfessor')"
:subtitle="isEditMode ? 'ویرایش رزومه و تخصص‌های استاد' : 'ثبت نام و اطلاعات استاد جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
<InputText v-model.trim="form.surname" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.phone') }} *</label>
<InputText v-model.trim="form.phone" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.email') }}</label>
<InputText v-model.trim="form.email" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('professors.bio') }}</label>
<Textarea v-model="form.bio" rows="3" class="w-full text-sm" />
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { professorApi } from '@/api/professorApi';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const professorId = route.params.id;
const isEditMode = computed(() => !!professorId);
const isSubmitting = ref(false);
const form = reactive({
name: '',
surname: '',
phone: '',
email: '',
bio: ''
});
const fetchProfessor = async () => {
if (!professorId) return;
try {
const res = await professorApi.getOne(professorId);
const data = res.data || res;
Object.assign(form, {
name: data.name || '',
surname: data.surname || '',
phone: data.phone || '',
email: data.email || '',
bio: data.bio || ''
});
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.name || !form.surname) { showError('نام و نام خانوادگی الزامی است'); return; }
isSubmitting.value = true;
try {
if (isEditMode.value) {
await professorApi.update(professorId, form);
showSuccess('اطلاعات استاد با موفقیت ویرایش شد');
} else {
await professorApi.create(form);
showSuccess('استاد جدید با موفقیت ایجاد شد');
}
router.push('/professors');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchProfessor();
});
</script>
+130
View File
@@ -0,0 +1,130 @@
<!-- /src/views/professors/ProfessorListView.vue -->
<template>
<div class="professor-list-view">
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
<PermissionGate permission="professors:create">
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام و نام خانوادگی" sortable>
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
</template>
</Column>
<Column field="specialization" header="تخصص">
<template #body="{ data }">
<Tag :value="data.specialization || 'عمومی'" severity="secondary" />
</template>
</Column>
<Column field="phoneNumber" header="شماره همراه">
<template #body="{ data }">
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
</template>
</Column>
<Column field="email" header="ایمیل">
<template #body="{ data }">
{{ data.email || '-' }}
</template>
</Column>
<Column field="activeCoursesCount" header="دوره‌های فعال">
<template #body="{ data }">
{{ toPersianDigits(data.activeCoursesCount || data.courses?.length || 0) }}
</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="professors:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/professors/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="professors:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { professorApi } from '@/api/professorApi';
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 Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(professorApi.getAll);
const deleteDialogVisible = ref(false);
const selectedProf = ref(null);
const isDeleting = ref(false);
const confirmDelete = (prof) => {
selectedProf.value = prof;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedProf.value) return;
isDeleting.value = true;
try {
await professorApi.delete(selectedProf.value._id || selectedProf.value.id);
showSuccess('استاد با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+306
View File
@@ -0,0 +1,306 @@
<!-- /src/views/roles/RoleFormView.vue -->
<template>
<div class="role-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? $t('roles.editRole') : $t('roles.addRole')"
:subtitle="isEditMode ? 'ویرایش مشخصات نقش و سطح دسترسی‌ها' : 'ایجاد نقش سیستمی جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
</PageHeader>
<div class="grid">
<!-- Basic Info -->
<div class="col-12">
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات نقش</h2>
<div class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('roles.roleName') }} *</label>
<InputText
v-model.trim="form.name"
class="w-full text-sm"
:disabled="isSystemRole"
:placeholder="isSystemRole ? 'نقش‌های سیستمی قابل تغییر نام نیستند' : 'مثلا: مشاور، حسابدار'"
/>
<small v-if="isSystemRole" class="text-orange-500 text-xs flex align-items-center gap-1">
<i class="pi pi-lock text-xs"></i> نقش سیستمی نام قابل تغییر نیست
</small>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">توضیحات نقش</label>
<InputText
v-model.trim="form.description"
class="w-full text-sm"
:disabled="isSystemRole"
placeholder="شرح وظایف و حوزه دسترسی این نقش..."
/>
</div>
</div>
</div>
</div>
<!-- Permissions -->
<div class="col-12">
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-4 pb-2 border-bottom-1 border-color">
<h2 class="text-base font-bold text-color m-0">{{ $t('roles.permissionsTitle') }}</h2>
<div class="flex align-items-center gap-2">
<span class="text-muted text-xs">{{ selectedCount }} از {{ totalCount }} مجوز انتخاب شده</span>
<Button
:label="isAllSelected ? 'لغو انتخاب همه' : 'انتخاب همه مجوزها'"
:icon="isAllSelected ? 'pi pi-times-circle' : 'pi pi-check-circle'"
size="small"
:severity="isAllSelected ? 'secondary' : 'success'"
text
:disabled="isSystemRole"
@click="toggleSelectAll"
/>
</div>
</div>
<div v-if="isSystemRole" class="surface-ground p-3 border-round border-1 border-color mb-4 text-sm flex align-items-center gap-2">
<i class="pi pi-info-circle text-primary"></i>
<span>مجوزهای نقشهای سیستمی قابل تغییر نیستند. برای دیدن مجوزهای فعال این نقش، لیست زیر را مشاهده کنید.</span>
</div>
<div class="grid">
<div
v-for="group in permissionGroups"
:key="group.key"
class="col-12 md:col-6 xl:col-4 mb-3"
>
<div class="permission-group border-round border-1 border-color overflow-hidden">
<!-- Group Header -->
<div
class="group-header p-3 flex align-items-center justify-content-between cursor-pointer"
:class="getGroupHeaderClass(group)"
@click="!isSystemRole && toggleGroup(group)"
>
<div class="flex align-items-center gap-2">
<i :class="group.icon" class="text-base"></i>
<span class="font-semibold text-sm">{{ group.label }}</span>
</div>
<div class="flex align-items-center gap-2">
<Badge
:value="`${getGroupSelectedCount(group)}/${group.permissions.length}`"
:severity="getGroupSelectedCount(group) === group.permissions.length ? 'success' : getGroupSelectedCount(group) > 0 ? 'warning' : 'secondary'"
/>
<Checkbox
:modelValue="isGroupFullySelected(group)"
:indeterminate="isGroupPartiallySelected(group)"
:disabled="isSystemRole"
binary
@click.stop="!isSystemRole && toggleGroup(group)"
/>
</div>
</div>
<!-- Permission Items -->
<div class="group-items">
<div
v-for="perm in group.permissions"
:key="perm.key"
class="perm-item flex align-items-center gap-2 px-3 py-2 border-top-1 border-color"
:class="{ 'perm-active': form.permissions.includes(perm.key) }"
>
<Checkbox
v-model="form.permissions"
:value="perm.key"
:disabled="isSystemRole"
/>
<label
:for="`perm-${perm.key}`"
class="text-xs flex-grow-1"
:class="{ 'font-semibold text-primary': form.permissions.includes(perm.key), 'cursor-pointer': !isSystemRole }"
@click="!isSystemRole && togglePermission(perm.key)"
>
{{ perm.label }}
</label>
<code class="text-xs text-muted" style="font-size: 10px;">{{ perm.key }}</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Submit -->
<div class="col-12">
<div class="flex justify-content-end gap-2">
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
<Button
:label="$t('app.save')"
icon="pi pi-check"
:loading="isSubmitting"
:disabled="isSystemRole"
@click="handleSubmit"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { roleApi } from '@/api/roleApi';
import { useToast } from '@/composables/useToast';
import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/constants/permissions';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Checkbox from 'primevue/checkbox';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const roleId = route.params.id;
const isEditMode = computed(() => !!roleId);
const isSubmitting = ref(false);
const isSystemRole = ref(false);
const permissionGroups = PERMISSION_GROUPS;
const totalCount = ALL_PERMISSIONS.length;
const form = reactive({
name: '',
description: '',
permissions: []
});
// ── Computed helpers ──────────────────────────────────────────────────────────
const selectedCount = computed(() => form.permissions.length);
const isAllSelected = computed(
() => form.permissions.length === ALL_PERMISSIONS.length
);
const isGroupFullySelected = (group) =>
group.permissions.every(p => form.permissions.includes(p.key));
const isGroupPartiallySelected = (group) => {
const count = group.permissions.filter(p => form.permissions.includes(p.key)).length;
return count > 0 && count < group.permissions.length;
};
const getGroupSelectedCount = (group) =>
group.permissions.filter(p => form.permissions.includes(p.key)).length;
const getGroupHeaderClass = (group) => {
if (isGroupFullySelected(group)) return 'bg-green-50';
if (isGroupPartiallySelected(group)) return 'bg-orange-50';
return 'surface-ground';
};
// ── Actions ───────────────────────────────────────────────────────────────────
const toggleSelectAll = () => {
if (isAllSelected.value) {
form.permissions = [];
} else {
form.permissions = [...ALL_PERMISSIONS];
}
};
const toggleGroup = (group) => {
const groupKeys = group.permissions.map(p => p.key);
if (isGroupFullySelected(group)) {
form.permissions = form.permissions.filter(p => !groupKeys.includes(p));
} else {
const toAdd = groupKeys.filter(k => !form.permissions.includes(k));
form.permissions = [...form.permissions, ...toAdd];
}
};
const togglePermission = (key) => {
const idx = form.permissions.indexOf(key);
if (idx === -1) {
form.permissions.push(key);
} else {
form.permissions.splice(idx, 1);
}
};
// ── API ───────────────────────────────────────────────────────────────────────
const fetchRole = async () => {
if (!roleId) return;
try {
const res = await roleApi.getOne(roleId);
const data = res.data || res;
isSystemRole.value = !!data.isSystem;
Object.assign(form, {
name: data.name || '',
description: data.description || '',
permissions: data.permissions || []
});
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.name) { showError('نام نقش الزامی است'); return; }
if (isSystemRole.value) { showError('نقش‌های سیستمی قابل ویرایش نیستند'); return; }
isSubmitting.value = true;
try {
if (isEditMode.value) {
await roleApi.update(roleId, form);
showSuccess('نقش با موفقیت ویرایش شد');
} else {
await roleApi.create(form);
showSuccess('نقش جدید با موفقیت ایجاد شد');
}
router.push('/roles');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchRole();
});
</script>
<style scoped lang="scss">
.permission-group {
background: var(--surface-card);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
.group-header {
transition: background-color 0.15s ease;
&:hover {
background-color: var(--surface-hover) !important;
}
}
.perm-item {
transition: background-color 0.12s ease;
&:hover {
background-color: var(--surface-hover);
}
&.perm-active {
background-color: var(--primary-color-light);
}
}
.bg-green-50 {
background-color: rgba(16, 185, 129, 0.08) !important;
}
.bg-orange-50 {
background-color: rgba(249, 115, 22, 0.06) !important;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<!-- /src/views/roles/RoleListView.vue -->
<template>
<div class="role-list-view">
<PageHeader :title="$t('roles.title')" :subtitle="$t('roles.subtitle')">
<PermissionGate permission="roles:create">
<Button :label="$t('roles.addRole')" icon="pi pi-shield" @click="$router.push('/roles/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام نقش" sortable>
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }}</span>
<Tag v-if="data.isSystem" value="سیستمی" severity="danger" class="mr-2 text-xs" />
</template>
</Column>
<Column field="description" header="توضیحات">
<template #body="{ data }">
{{ data.description || '-' }}
</template>
</Column>
<Column field="permissionCount" header="تعداد مجوزها">
<template #body="{ data }">
<Badge :value="toPersianDigits(data.permissions?.length || 0)" severity="info" />
</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="roles:update">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
:disabled="data.isSystem"
v-tooltip.top="data.isSystem ? $t('roles.systemRoleWarning') : 'ویرایش'"
@click="$router.push(`/roles/edit/${data._id || data.id}`)"
/>
</PermissionGate>
<PermissionGate permission="roles:delete">
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
:disabled="data.isSystem"
v-tooltip.top="data.isSystem ? $t('roles.systemRoleWarning') : 'حذف'"
@click="confirmDelete(data)"
/>
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { roleApi } from '@/api/roleApi';
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 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();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(roleApi.getAll);
const deleteDialogVisible = ref(false);
const selectedRole = ref(null);
const isDeleting = ref(false);
const confirmDelete = (role) => {
if (role.isSystem) return;
selectedRole.value = role;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedRole.value) return;
isDeleting.value = true;
try {
await roleApi.delete(selectedRole.value._id || selectedRole.value.id);
showSuccess('نقش با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+247
View File
@@ -0,0 +1,247 @@
<!-- /src/views/sessions/SessionFormView.vue -->
<template>
<div class="session-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? $t('sessions.editSession') : $t('sessions.addSession')"
:subtitle="isEditMode ? 'ویرایش اطلاعات جلسه آموزشی' : 'تعریف جلسه جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب دوره *</label>
<Dropdown
v-model="form.course"
:options="courses"
optionLabel="title"
optionValue="_id"
placeholder="دوره را انتخاب کنید"
class="w-full text-sm"
filter
@change="onCourseChange"
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">کلاس *</label>
<Dropdown
v-model="form.class"
:options="classes"
optionLabel="name"
optionValue="_id"
placeholder="کلاس را انتخاب کنید"
class="w-full text-sm"
:disabled="!form.course"
filter
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">استاد مدرس *</label>
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="استاد را انتخاب کنید" class="w-full text-sm" filter />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('sessions.date') }} *</label>
<DatePicker v-model="form.day" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.startTime') }} *</label>
<InputText v-model="form.startTime" class="w-full text-sm" dir="ltr" placeholder="19:00" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.endTime') }} *</label>
<InputText v-model="form.endTime" class="w-full text-sm" dir="ltr" placeholder="20:30" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">وضعیت جلسه</label>
<Dropdown v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">مکان برگزاری</label>
<InputText v-model="form.place" class="w-full text-sm" placeholder="کلاس / آنلاین / آدرس" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('sessions.topic') }}</label>
<InputText v-model="form.topic" class="w-full text-sm" placeholder="عنوان یا موضوع این جلسه" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">یادداشت جلسه</label>
<Textarea v-model="form.note" rows="3" class="w-full text-sm" placeholder="یادداشت داخلی درباره این جلسه…" />
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import moment from 'jalali-moment';
import { sessionApi } from '@/api/sessionApi';
import { courseApi } from '@/api/courseApi';
import { classApi } from '@/api/classApi';
import { professorApi } from '@/api/professorApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Dropdown from 'primevue/select';
import DatePicker from 'vue3-persian-datetime-picker';
import Button from 'primevue/button';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const { toLatinDigits } = usePersianDate();
const sessionId = route.params.id;
const isEditMode = computed(() => !!sessionId);
const isSubmitting = ref(false);
const courses = ref([]);
const classes = ref([]);
const professors = ref([]);
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزارشده', value: 'held' },
{ label: 'لغوشده', value: 'cancelled' }
];
const form = reactive({
course: null,
class: null,
professor: null,
day: moment().locale('fa').format('jYYYY/jMM/jDD'),
startTime: '19:00',
endTime: '20:30',
status: 'scheduled',
topic: '',
place: '',
note: ''
});
const toGregorianIso = (jalaliValue) => {
if (!jalaliValue) return null;
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
const latin = toLatinDigits(String(jalaliValue));
const m = moment(latin, 'jYYYY/jMM/jDD');
return m.isValid() ? m.toDate().toISOString() : null;
};
const toJalaliDisplay = (value) => {
if (!value) return '';
return moment(value).locale('fa').format('jYYYY/jMM/jDD');
};
const loadClasses = async (courseId) => {
if (!courseId) {
classes.value = [];
return;
}
const res = await classApi.getAll({ limit: 100, courseId });
const data = res.data || res;
classes.value = Array.isArray(data) ? data : (data.items || data.data || []);
};
const onCourseChange = async () => {
form.class = null;
await loadClasses(form.course);
};
const fetchData = async () => {
try {
const [cRes, pRes] = await Promise.all([
courseApi.getAll({ limit: 100 }),
professorApi.getAll({ limit: 100 })
]);
const cData = cRes.data || cRes;
courses.value = Array.isArray(cData) ? cData : (cData.items || cData.courses || cData.data || []);
const pData = pRes.data || pRes;
professors.value = (Array.isArray(pData) ? pData : (pData.items || pData.professors || pData.data || [])).map((p) => ({
...p,
name: `${p.name || ''} ${p.surname || ''}`.trim()
}));
if (sessionId) {
const res = await sessionApi.getOne(sessionId);
const data = res.data || res;
const courseId = data.course?._id || data.course || null;
await loadClasses(courseId);
Object.assign(form, {
course: courseId,
class: data.class?._id || data.class || null,
professor: data.professor?._id || data.professor || null,
day: toJalaliDisplay(data.day || data.date),
startTime: data.startTime || '19:00',
endTime: data.endTime || '20:30',
status: data.status || 'scheduled',
topic: data.topic || '',
place: data.place || '',
note: data.note || ''
});
}
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.course) { showError('لطفا دوره را انتخاب کنید'); return; }
if (!form.class) { showError('لطفا کلاس را انتخاب کنید'); return; }
if (!form.professor) { showError('لطفا استاد را انتخاب کنید'); return; }
if (!form.day) { showError('لطفا تاریخ جلسه را وارد کنید'); return; }
if (!form.startTime || !form.endTime) { showError('ساعت شروع و پایان الزامی است'); return; }
const dayIso = toGregorianIso(form.day);
if (!dayIso) { showError('تاریخ جلسه نامعتبر است'); return; }
isSubmitting.value = true;
try {
const payload = {
course: form.course,
class: form.class,
professor: form.professor,
day: dayIso,
startTime: form.startTime,
endTime: form.endTime,
status: form.status,
topic: form.topic,
place: form.place,
note: form.note
};
if (isEditMode.value) {
await sessionApi.update(sessionId, payload);
showSuccess('جلسه با موفقیت ویرایش شد');
} else {
await sessionApi.create(payload);
showSuccess('جلسه جدید با موفقیت ایجاد شد');
}
router.push('/sessions');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(fetchData);
</script>
+255
View File
@@ -0,0 +1,255 @@
<!-- /src/views/sessions/SessionListView.vue -->
<template>
<div class="session-list-view">
<PageHeader :title="$t('sessions.title')" :subtitle="$t('sessions.subtitle')">
<PermissionGate permission="sessions:create">
<Button :label="$t('sessions.addSession')" icon="pi pi-plus" @click="$router.push('/sessions/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
selectable
v-model:selection="selectedSessions"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<template #toolbar>
<div v-if="selectedSessions.length" class="flex align-items-center gap-2 flex-wrap justify-content-end">
<Tag :value="`${toPersianDigits(selectedSessions.length)} انتخاب‌شده`" severity="info" />
<PermissionGate permission="sessions:update">
<Select
v-model="bulkStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="تغییر وضعیت"
class="text-sm"
style="min-width: 10rem"
/>
<Button
label="اعمال وضعیت"
icon="pi pi-check"
size="small"
:loading="isBulkUpdating"
:disabled="!bulkStatus"
@click="applyBulkStatus"
/>
</PermissionGate>
<PermissionGate permission="sessions:delete">
<Button
label="حذف انتخاب‌شده‌ها"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="confirmBulkDelete"
/>
</PermissionGate>
<Button label="لغو انتخاب" text size="small" severity="secondary" @click="selectedSessions = []" />
</div>
</template>
<Column field="topic" header="موضوع جلسه">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
</template>
</Column>
<Column field="course" header="عنوان دوره">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.course?.title || data.courseTitle || '-' }}</span>
</template>
</Column>
<Column field="class" header="کلاس">
<template #body="{ data }">
{{ data.class?.name || '—' }}
</template>
</Column>
<Column field="professor" header="استاد مدرس">
<template #body="{ data }">
{{
data.professor
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
: (data.professorName || '—')
}}
</template>
</Column>
<Column field="date" header="تاریخ برگزاری" sortable sortField="day">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="timeRange" header="زمان برگزاری">
<template #body="{ data }">
{{ data.startTime || '—' }} - {{ data.endTime || '—' }}
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
<Column header="عملیات" style="width: 170px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="sessions:attendance">
<Button
icon="pi pi-check-square"
text
rounded
size="small"
severity="success"
v-tooltip.top="$t('sessions.enterAttendance')"
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
</PermissionGate>
<PermissionGate permission="sessions:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/sessions/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="sessions:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
:title="bulkDeleteMode ? 'حذف گروهی جلسات' : ''"
:message="bulkDeleteMessage"
@confirm="handleDeleteConfirm"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { sessionApi } from '@/api/sessionApi';
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 StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Select from 'primevue/select';
const { formatJalali, toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(sessionApi.getAll, {
sortBy: 'day',
sortOrder: 'asc'
});
const selectedSessions = ref([]);
const bulkStatus = ref(null);
const isBulkUpdating = ref(false);
const deleteDialogVisible = ref(false);
const selectedSession = ref(null);
const bulkDeleteMode = ref(false);
const isDeleting = ref(false);
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزار شده', value: 'held' },
{ label: 'لغو شده', value: 'cancelled' }
];
const selectedIds = computed(() =>
selectedSessions.value.map((s) => s._id || s.id).filter(Boolean)
);
const bulkDeleteMessage = computed(() => {
if (bulkDeleteMode.value) {
return `آیا از حذف ${toPersianDigits(selectedSessions.value.length)} جلسه انتخاب‌شده اطمینان دارید؟ این عملیات قابل بازگشت نیست.`;
}
return 'آیا از حذف این جلسه اطمینان دارید؟ این عملیات قابل بازگشت نیست.';
});
const confirmDelete = (session) => {
bulkDeleteMode.value = false;
selectedSession.value = session;
deleteDialogVisible.value = true;
};
const confirmBulkDelete = () => {
if (!selectedSessions.value.length) return;
bulkDeleteMode.value = true;
selectedSession.value = null;
deleteDialogVisible.value = true;
};
const handleDeleteConfirm = async () => {
isDeleting.value = true;
try {
if (bulkDeleteMode.value) {
const res = await sessionApi.bulkDelete(selectedIds.value);
const deletedCount = res?.data?.deletedCount ?? selectedIds.value.length;
showSuccess(`${toPersianDigits(deletedCount)} جلسه حذف شد`);
selectedSessions.value = [];
} else if (selectedSession.value) {
await sessionApi.delete(selectedSession.value._id || selectedSession.value.id);
showSuccess('جلسه با موفقیت حذف شد');
}
deleteDialogVisible.value = false;
await loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
const applyBulkStatus = async () => {
if (!selectedIds.value.length || !bulkStatus.value) return;
isBulkUpdating.value = true;
try {
const res = await sessionApi.bulkUpdateStatus(selectedIds.value, bulkStatus.value);
const updatedCount = res?.data?.updatedCount ?? selectedIds.value.length;
showSuccess(`وضعیت ${toPersianDigits(updatedCount)} جلسه به‌روز شد`);
bulkStatus.value = null;
selectedSessions.value = [];
await loadData();
} catch (err) {
showError(err);
} finally {
isBulkUpdating.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+267
View File
@@ -0,0 +1,267 @@
<!-- /src/views/users/UserDetailView.vue -->
<template>
<div class="user-detail-view" v-if="user">
<PageHeader :title="`${user.name || ''} ${user.surname || ''}`" :subtitle="`کد ملی: ${toPersianDigits(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>
<!-- Main Profile Tabs -->
<Tabs value="0" class="surface-card border-round 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">{{ $t('users.tabCertificates') }}</Tab>
</TabList>
<TabPanels>
<!-- Tab 1: User Info -->
<TabPanel value="0">
<div class="grid p-3">
<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 }} {{ user.surname }}</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.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">{{ 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>
<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>
<Tag :value="user.role?.name || 'دانشجو'" severity="secondary" />
</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>
</TabPanel>
<!-- Tab 2: Enrolled Courses -->
<TabPanel value="1">
<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="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="نوع">
<template #body="{ data }">
<Tag :value="data.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="data.type === 'Private' ? 'warning' : 'info'" />
</template>
</Column>
<Column field="price" header="شهریه">
<template #body="{ data }">
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="enrollDate" header="تاریخ ثبت‌نام">
<template #body="{ data }">
{{ formatJalali(data.enrollDate || data.createdAt) }}
</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 -->
<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">
<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">
<Column field="amount" header="مبلغ کل">
<template #body="{ data }">
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="paidAmount" header="پرداختی">
<template #body="{ data }">
{{ toPersianDigits(data.paidAmount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status" type="payment" />
</template>
</Column>
<Column field="dueDate" header="تاریخ سررسید">
<template #body="{ data }">
{{ formatJalali(data.dueDate) }}
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 5: Certificates -->
<TabPanel value="4">
<div class="p-3">
<h3 class="text-lg font-bold mb-3">گواهینامههای صادر شده دانشجو</h3>
<CertificateUploader v-model="userCertificates" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Enroll 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
v-model="selectedCourseId"
:options="allCourses"
optionLabel="title"
optionValue="_id"
placeholder="دوره را انتخاب کنید"
class="w-full text-sm"
/>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showEnrollModal = false" />
<Button label="تایید ثبت‌نام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, 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 PageHeader from '@/components/common/PageHeader.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import CertificateUploader from '@/components/uploader/CertificateUploader.vue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
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 userCertificates = ref([]);
const showEnrollModal = ref(false);
const allCourses = ref([]);
const selectedCourseId = ref(null);
const isEnrolling = ref(false);
const getAttendanceSeverity = (status) => {
if (status === 'حاضر' || status === 'present') return 'success';
if (status === 'غایب' || status === 'absent') return 'danger';
if (status === 'تاخیر' || status === 'late') return 'warning';
return 'info';
};
const fetchUserDetail = 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 || [];
userCertificates.value = user.value.certificates || [];
} catch (err) {
showError(err);
}
};
const fetchAllCourses = async () => {
try {
const res = await courseApi.getAll({ limit: 100 });
const data = res.data || res;
allCourses.value = data.items || data.courses || data || [];
} catch (e) {
console.warn('Courses fetch error:', e);
}
};
const handleEnroll = async () => {
if (!selectedCourseId.value) return;
isEnrolling.value = true;
try {
await userApi.enroll(userId, { courseId: selectedCourseId.value });
showSuccess('دانشجو با موفقیت در دوره ثبت‌نام شد');
showEnrollModal.value = false;
fetchUserDetail();
} catch (err) {
showError(err);
} finally {
isEnrolling.value = false;
}
};
onMounted(() => {
fetchUserDetail();
fetchAllCourses();
});
</script>
+276
View File
@@ -0,0 +1,276 @@
<!-- /src/views/users/UserFormView.vue -->
<template>
<div class="user-form-view w-full max-w-4xl mx-auto">
<PageHeader
:title="isEditMode ? $t('users.editUser') : $t('users.addUser')"
:subtitle="isEditMode ? 'ویرایش اطلاعات حساب کاربر' : 'ایجاد حساب کاربری جدید — نام کاربری و رمز عبور به‌صورت خودکار ساخته و پیامک می‌شود'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
<InputText v-model.trim="form.surname" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.nationalId') }} *</label>
<InputText v-model.trim="form.nationalId" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.phone') }} *</label>
<InputText v-model.trim="form.phone" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.email') }}</label>
<InputText v-model.trim="form.email" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.role') }} *</label>
<Dropdown v-model="form.role" :options="roles" optionLabel="name" optionValue="_id" placeholder="انتخاب نقش" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.preferredMessenger') }}</label>
<MultiSelect
v-model="form.preferredMessenger"
:options="messengerOptions"
optionLabel="label"
optionValue="value"
display="chip"
placeholder="اختیاری — چند مورد قابل انتخاب"
class="w-full text-sm preferred-messenger-select"
:showClear="false"
:maxSelectedLabels="3"
selectedItemsLabel="{0} مورد انتخاب شده"
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
<label class="font-semibold text-sm">{{ $t('auth.username') }}</label>
<InputText
v-model.trim="form.username"
class="w-full text-sm"
dir="ltr"
disabled
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
<label class="font-semibold text-sm">رمز عبور جدید (اختیاری)</label>
<InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" />
</div>
<div v-if="!isEditMode" class="col-12">
<p class="text-sm text-color-secondary m-0 line-height-3">
نام کاربری و رمز عبور ساده بهصورت خودکار ساخته میشود و از طریق پیامک برای کاربر ارسال میگردد. بعداً میتوان رمز را تغییر داد.
</p>
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
<Dialog
v-model:visible="credentialsVisible"
modal
header="حساب ساخته شد"
:style="{ width: '28rem' }"
:closable="false"
>
<p class="text-sm mb-3 line-height-3">
اطلاعات ورود ساخته شد و در صورت فعال بودن پیامک، برای کاربر ارسال میشود. این رمز فقط یکبار نمایش داده میشود:
</p>
<div class="flex flex-column gap-2 surface-ground p-3 border-round">
<div class="flex justify-content-between gap-2">
<span class="text-color-secondary text-sm">نام کاربری</span>
<code class="font-semibold" dir="ltr">{{ createdCredentials.username }}</code>
</div>
<div class="flex justify-content-between gap-2">
<span class="text-color-secondary text-sm">رمز عبور</span>
<code class="font-semibold" dir="ltr">{{ createdCredentials.password }}</code>
</div>
</div>
<template #footer>
<Button label="متوجه شدم" icon="pi pi-check" @click="finishAfterCreate" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { userApi } from '@/api/userApi';
import { roleApi } from '@/api/roleApi';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const userId = route.params.id;
const isEditMode = computed(() => !!userId);
const isSubmitting = ref(false);
const roles = ref([]);
const credentialsVisible = ref(false);
const createdCredentials = reactive({ username: '', password: '' });
const messengerOptions = [
{ label: 'پیامک (SMS)', value: 'SMS' },
{ label: 'بله', value: 'Bale' },
{ label: 'واتساپ', value: 'WhatsApp' },
{ label: 'تلگرام', value: 'Telegram' },
{ label: 'ایمیل', value: 'Email' }
];
const toMessengerList = (value) => {
if (!value) return [];
return Array.isArray(value) ? [...value] : [value];
};
const form = reactive({
name: '',
surname: '',
nationalId: '',
phone: '',
email: '',
role: null,
preferredMessenger: [],
username: '',
password: ''
});
const buildPayload = () => {
const payload = {
name: form.name,
surname: form.surname,
nationalIdCode: form.nationalId,
phoneNumber: form.phone,
email: form.email || undefined,
role: form.role || undefined
};
if (form.preferredMessenger?.length) {
payload.preferredMessenger = form.preferredMessenger;
} else if (isEditMode.value) {
payload.preferredMessenger = [];
}
if (isEditMode.value && form.password) {
payload.password = form.password;
}
return payload;
};
const fetchRoles = async () => {
try {
const res = await roleApi.getAll({ limit: 100 });
const data = res.data || res;
roles.value = Array.isArray(data)
? data
: (data.items || data.roles || data.data || []);
} catch (e) {
console.warn('Fetch roles error:', e);
}
};
const fetchUser = async () => {
if (!userId) return;
try {
const res = await userApi.getOne(userId);
const data = res.data || res;
Object.assign(form, {
name: data.name || '',
surname: data.surname || '',
nationalId: data.nationalIdCode || data.nationalId || '',
phone: data.phoneNumber || data.phone || '',
email: data.email || '',
role: data.role?._id || data.role || null,
preferredMessenger: toMessengerList(data.preferredMessenger),
username: data.username || '',
password: ''
});
} catch (err) {
showError(err);
}
};
const finishAfterCreate = () => {
credentialsVisible.value = false;
router.push('/users');
};
const handleSubmit = async () => {
if (!form.name || !form.surname) {
showError('نام و نام خانوادگی الزامی است');
return;
}
if (!form.nationalId || !form.phone) {
showError('کد ملی و شماره همراه الزامی است');
return;
}
isSubmitting.value = true;
try {
const payload = buildPayload();
if (isEditMode.value) {
await userApi.update(userId, payload);
showSuccess('اطلاعات کاربر با موفقیت ویرایش شد');
router.push('/users');
} else {
const res = await userApi.create(payload);
const data = res.data || res;
const creds = data.generatedCredentials || {};
createdCredentials.username = creds.username || data.username || '';
createdCredentials.password = creds.password || '';
showSuccess('کاربر جدید با موفقیت ایجاد شد');
if (createdCredentials.username && createdCredentials.password) {
credentialsVisible.value = true;
} else {
router.push('/users');
}
}
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchRoles();
fetchUser();
});
</script>
<style scoped>
.preferred-messenger-select :deep(.p-multiselect-label) {
padding-inline-end: 2.5rem;
}
.preferred-messenger-select :deep(.p-chip),
.preferred-messenger-select :deep(.p-multiselect-token) {
margin-inline-end: 0.35rem;
}
</style>
+150
View File
@@ -0,0 +1,150 @@
<!-- /src/views/users/UserListView.vue -->
<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')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام و نام خانوادگی" sortable>
<template #body="{ data }">
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.name }} {{ data.surname }}
</router-link>
</template>
</Column>
<Column field="nationalId" header="کد ملی">
<template #body="{ data }">
{{ toPersianDigits(data.nationalId) || '-' }}
</template>
</Column>
<Column field="phoneNumber" header="شماره همراه">
<template #body="{ data }">
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
</template>
</Column>
<Column field="preferredMessenger" header="پیام‌رسان ترجیحی">
<template #body="{ data }">
<div class="flex flex-wrap gap-1 justify-content-end">
<Tag
v-for="messenger in (Array.isArray(data.preferredMessenger) ? data.preferredMessenger : (data.preferredMessenger ? [data.preferredMessenger] : ['SMS']))"
:key="messenger"
:value="messenger"
severity="info"
/>
</div>
</template>
</Column>
<Column field="activeCoursesCount" header="دوره‌های فعال">
<template #body="{ data }">
{{ toPersianDigits(data.activeCoursesCount || data.enrolledCourses?.length || 0) }}
</template>
</Column>
<Column field="isActive" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.isActive !== false" />
</template>
</Column>
<Column header="عمولیات" style="width: 130px">
<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}`)" />
</PermissionGate>
<PermissionGate permission="users:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/users/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="users:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
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 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';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(userApi.getAll);
const deleteDialogVisible = ref(false);
const selectedUser = ref(null);
const isDeleting = ref(false);
const confirmDelete = (user) => {
selectedUser.value = user;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedUser.value) return;
isDeleting.value = true;
try {
await userApi.delete(selectedUser.value._id || selectedUser.value.id);
showSuccess('کاربر با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>