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
+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>