Initial commit: admin dashboard for GameNo.
Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user