Files
gameno-dashboard/src/views/classes/ClassFormView.vue
T
kavehhn 5fe7eb090e fix(classes): make weekly schedule editable on the class form
Replace the PrimeVue SelectButton weekday control with a visible checkbox group so days and times stay on the edit page.
2026-08-16 00:04:13 +03:30

454 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- /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">
<div class="class-schedule-fields p-3 border-1 border-color border-round">
<h3 class="text-base font-bold text-color m-0 mb-3">برنامه هفتگی کلاس</h3>
<div class="flex flex-column gap-2 mb-3">
<span class="font-semibold text-sm">روزهای برگزاری</span>
<div class="flex flex-wrap gap-3">
<label
v-for="day in weekdays"
:key="day.value"
class="flex align-items-center gap-2 cursor-pointer"
:for="`class-day-${day.value}`"
>
<Checkbox
:inputId="`class-day-${day.value}`"
v-model="form.days"
:value="day.value"
/>
<span class="text-sm">{{ day.label }}</span>
</label>
</div>
</div>
<div class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm" for="class-start-time">ساعت شروع</label>
<InputText
id="class-start-time"
v-model.trim="form.startTime"
class="w-full text-sm"
dir="ltr"
placeholder="19:00"
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm" for="class-end-time">ساعت پایان</label>
<InputText
id="class-end-time"
v-model.trim="form.endTime"
class="w-full text-sm"
dir="ltr"
placeholder="21:00"
/>
</div>
</div>
</div>
</div>
<div class="col-12">
<AdminNotesField v-model="form.adminNotes" />
</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>
<div class="mb-3">
<NotifyChannelsField :notify="registerNotify" />
</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 }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }">
<span dir="ltr">{{ data.phoneNumber || '—' }}</span>
</template>
</Column>
<Column v-if="canManageStudents" header="عملیات" style="width: 90px">
<template #body="{ data }">
<PermissionGate permission="classes:register_users">
<Button
icon="pi pi-user-minus"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'حذف از کلاس'"
:loading="removingUserId === String(data._id || data.id)"
@click="confirmRemoveStudent(data)"
/>
</PermissionGate>
</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"
:days="form.days"
:start-time="form.startTime"
:end-time="form.endTime"
/>
</template>
<ConfirmDeleteDialog
v-model="removeDialogVisible"
title="حذف از کلاس"
:message="removeDialogMessage"
:loading="!!removingUserId"
@confirm="handleRemoveStudent"
/>
</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 { usePermission } from '@/composables/usePermission';
import PageHeader from '@/components/common/PageHeader.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.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 Checkbox from 'primevue/checkbox';
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';
import { WEEKDAYS } from '@/utils/classSchedule';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const { hasPermission } = usePermission();
const { toPersianDigits, toLatinDigits } = usePersianDate();
const classId = route.params.id;
const queryCourseId = route.query.courseId ? String(route.query.courseId) : null;
const isEditMode = computed(() => !!classId);
const canManageStudents = computed(() => hasPermission('classes:register_users'));
const isSubmitting = ref(false);
const registering = ref(false);
const defaultSessionCount = ref(12);
const registerNotify = reactive({ sms: true, email: true, bot: true });
const removeDialogVisible = ref(false);
const removingUserId = ref('');
const studentToRemove = ref(null);
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: '',
days: [],
startTime: '',
endTime: '',
isActive: true,
adminNotes: []
});
const weekdays = WEEKDAYS;
const removeDialogMessage = computed(() => {
const name = studentToRemove.value?.name;
return name
? `آیا «${name}» از این کلاس حذف شود؟`
: 'آیا این دانشجو از کلاس حذف شود؟';
});
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.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),
days: Array.isArray(data.days) ? data.days.map(Number).filter((day) => day >= 0 && day <= 6) : [],
startTime: data.startTime || '',
endTime: data.endTime || '',
isActive: data.isActive !== false,
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
});
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),
days: form.days || [],
startTime: form.startTime || '',
endTime: form.endTime || '',
isActive: form.isActive,
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
};
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, { ...registerNotify });
const data = res.data || res;
students.value = data.students || [];
selectedUserIds.value = [];
showSuccess('دانشجویان با موفقیت به کلاس اضافه شدند');
} catch (err) {
showError(err);
} finally {
registering.value = false;
}
};
const confirmRemoveStudent = (student) => {
studentToRemove.value = student;
removeDialogVisible.value = true;
};
const handleRemoveStudent = async () => {
const userId = studentToRemove.value?._id || studentToRemove.value?.id;
if (!userId) return;
removingUserId.value = String(userId);
try {
const res = await classApi.removeUser(classId, userId);
const data = res.data || res;
students.value = data.students || [];
removeDialogVisible.value = false;
studentToRemove.value = null;
showSuccess('دانشجو از کلاس حذف شد');
} catch (err) {
showError(err);
} finally {
removingUserId.value = '';
}
};
onMounted(fetchData);
</script>