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,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="مثال: مناسب مبتدیان همراه با گواهینامه تمرین عملی"
|
||||
/>
|
||||
</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>
|
||||
Reference in New Issue
Block a user