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