Add session class filter, distinct partial payment tag, richer notifications list, class discount/frontend toggles, and remove unused course discount column.
507 lines
19 KiB
Vue
507 lines
19 KiB
Vue
<!-- /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-3 flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">ظرفیت آزاد (نمایش در وب)</label>
|
||
<InputNumber v-model="form.freeSpots" :min="0" class="w-full text-sm" placeholder="دستی" />
|
||
</div>
|
||
|
||
<div class="col-12">
|
||
<div class="flex flex-column gap-2 p-3 border-1 border-color border-round">
|
||
<div class="flex align-items-center gap-2">
|
||
<Checkbox v-model="form.hasDiscount" binary inputId="class-has-discount" />
|
||
<label for="class-has-discount" class="font-semibold text-sm cursor-pointer">تخفیف روی شهریه</label>
|
||
</div>
|
||
<div v-if="form.hasDiscount" class="grid mt-1">
|
||
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">مبلغ تخفیف (تومان)</label>
|
||
<InputNumber v-model="form.discount" :min="0" :max="form.tuitionFee || 0" class="w-full text-sm" suffix=" تومان" />
|
||
</div>
|
||
<div class="col-12 md:col-8 flex align-items-end">
|
||
<span class="font-semibold text-color text-sm">
|
||
شهریه نهایی: {{ toPersianDigits(finalTuitionFee.toLocaleString()) }} تومان
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 flex-column gap-2">
|
||
<div class="flex align-items-center gap-2 mt-4">
|
||
<InputSwitch v-model="form.isActive" />
|
||
<label class="font-semibold text-sm">کلاس فعال است</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-12 md:col-8 flex flex-column gap-1 mt-4">
|
||
<div class="flex align-items-center gap-2">
|
||
<InputSwitch v-model="form.showOnFrontend" />
|
||
<label class="font-semibold text-sm">نمایش در وبسایت</label>
|
||
</div>
|
||
<small class="text-muted text-xs">مستقل از «فعال بودن» — کلاس غیرفعال میتواند در سایت پنهان یا نمایش داده شود.</small>
|
||
</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,
|
||
hasDiscount: false,
|
||
discount: 0,
|
||
freeSpots: null,
|
||
showOnFrontend: true,
|
||
startDate: '',
|
||
endDate: '',
|
||
days: [],
|
||
startTime: '',
|
||
endTime: '',
|
||
isActive: true,
|
||
adminNotes: []
|
||
});
|
||
const weekdays = WEEKDAYS;
|
||
|
||
const finalTuitionFee = computed(() => {
|
||
const tuition = Number(form.tuitionFee) || 0;
|
||
if (!form.hasDiscount) return tuition;
|
||
return Math.max(0, tuition - (Number(form.discount) || 0));
|
||
});
|
||
|
||
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,
|
||
hasDiscount: Boolean(data.hasDiscount),
|
||
discount: data.discount || 0,
|
||
freeSpots: data.freeSpots ?? null,
|
||
showOnFrontend: data.showOnFrontend !== false,
|
||
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,
|
||
hasDiscount: form.hasDiscount,
|
||
discount: form.hasDiscount ? form.discount : 0,
|
||
freeSpots: form.freeSpots,
|
||
showOnFrontend: form.showOnFrontend,
|
||
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>
|