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