475 lines
18 KiB
Vue
475 lines
18 KiB
Vue
<template>
|
||
<div class="class-detail-view relative">
|
||
<LoadingOverlay :loading="isFetching || !classData" message="در حال دریافت مشخصات کلاس..." />
|
||
<div v-if="classData">
|
||
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
|
||
<PermissionGate permission="financial_reports:read">
|
||
<Button
|
||
label="گزارش مالی کلاس"
|
||
icon="pi pi-chart-line"
|
||
text
|
||
severity="info"
|
||
@click="$router.push(`/financial-reports?classId=${classId}`)"
|
||
/>
|
||
</PermissionGate>
|
||
<PermissionGate permission="payments:create">
|
||
<Button
|
||
label="صدور صورتحساب گروهی"
|
||
icon="pi pi-wallet"
|
||
severity="success"
|
||
class="ml-2"
|
||
@click="openBulkModal"
|
||
/>
|
||
</PermissionGate>
|
||
<Button
|
||
label="حذف کلاس"
|
||
icon="pi pi-trash"
|
||
severity="danger"
|
||
text
|
||
class="ml-2"
|
||
@click="confirmDeleteClass"
|
||
/>
|
||
<Button label="ویرایش" icon="pi pi-pencil" class="ml-2" @click="$router.push(`/classes/edit/${classId}`)" />
|
||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" />
|
||
</PageHeader>
|
||
|
||
<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-3">مشخصات کلاس</h2>
|
||
<div class="grid">
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">استاد مدرس</span>
|
||
<span class="font-bold text-color text-sm">
|
||
{{ classData.professor ? `${classData.professor.name || ''} ${classData.professor.surname || ''}`.trim() : '—' }}
|
||
</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">ظرفیت کل</span>
|
||
<span class="font-bold text-color text-sm">{{ toPersianDigits(classData.capacity || 0) }} نفر</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">تعداد ثبتنامیها</span>
|
||
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">شهریه</span>
|
||
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.tuitionFee || 0).toLocaleString('en-US')) }} تومان</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">بازه زمانی</span>
|
||
<span class="font-bold text-color text-sm">
|
||
{{ formatJalali(classData.startDate) }} — {{ formatJalali(classData.endDate) }}
|
||
</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">روزهای برگزاری</span>
|
||
<span class="font-bold text-color text-sm">{{ formatClassDays(classData.days) || '—' }}</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">ساعت کلاس</span>
|
||
<span class="font-bold text-color text-sm" dir="ltr">{{ formatClassTime(classData.startTime, classData.endTime) || '—' }}</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4" v-if="classData.serviceFeePerPerson">
|
||
<span class="text-muted text-xs block mb-1">هزینه پذیرایی به ازای هر نفر</span>
|
||
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.serviceFeePerPerson || 0).toLocaleString('en-US')) }} تومان</span>
|
||
</div>
|
||
<div class="col-12 sm:col-4">
|
||
<span class="text-muted text-xs block mb-1">وضعیت</span>
|
||
<StatusTag :status="classData.isActive !== false" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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 mb-3">دانشجویان</h2>
|
||
<DataTable :value="classData.students || []" class="p-datatable-sm text-sm" emptyMessage="دانشجویی ثبت نشده">
|
||
<Column header="#">
|
||
<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>
|
||
|
||
<div 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">
|
||
<h2 class="text-lg font-bold m-0">جلسات کلاس</h2>
|
||
<Button
|
||
label="مدیریت جلسات"
|
||
icon="pi pi-calendar"
|
||
text
|
||
size="small"
|
||
@click="$router.push(`/classes/edit/${classId}`)"
|
||
/>
|
||
</div>
|
||
<TableSkeleton v-if="loadingSessions" :rows="5" :columns="5" />
|
||
<DataTable
|
||
v-else
|
||
:value="sessions"
|
||
class="p-datatable-sm text-sm"
|
||
emptyMessage="جلسهای برای این کلاس ثبت نشده است"
|
||
>
|
||
<Column header="#">
|
||
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
||
</Column>
|
||
<Column header="موضوع">
|
||
<template #body="{ data }">{{ data.topic || '—' }}</template>
|
||
</Column>
|
||
<Column header="تاریخ">
|
||
<template #body="{ data }">{{ formatJalali(data.day || data.date) }}</template>
|
||
</Column>
|
||
<Column header="زمان">
|
||
<template #body="{ data }">
|
||
<span dir="ltr">{{ data.startTime || '—' }} – {{ data.endTime || '—' }}</span>
|
||
</template>
|
||
</Column>
|
||
<Column header="وضعیت">
|
||
<template #body="{ data }">
|
||
<StatusTag :status="data.status || 'scheduled'" type="session" />
|
||
</template>
|
||
</Column>
|
||
</DataTable>
|
||
</div>
|
||
|
||
<ConfirmDeleteDialog
|
||
v-model="removeDialogVisible"
|
||
title="حذف از کلاس"
|
||
:message="removeDialogMessage"
|
||
:loading="!!removingUserId"
|
||
@confirm="handleRemoveStudent"
|
||
/>
|
||
|
||
<ConfirmDeleteDialog
|
||
v-model="deleteClassDialogVisible"
|
||
title="حذف کلاس"
|
||
:message="deleteClassDialogMessage"
|
||
:loading="isDeletingClass"
|
||
@confirm="handleDeleteClass"
|
||
/>
|
||
|
||
<!-- Bulk Class Payment Modal -->
|
||
<Dialog v-model:visible="bulkModalVisible" header="صدور صورتحساب گروهی برای این کلاس" modal :style="{ width: '540px' }">
|
||
<div class="flex flex-column gap-3 py-2" v-if="classData">
|
||
<div class="p-3 bg-blue-50 border-round border-1 border-blue-200 text-blue-900 text-sm flex align-items-center justify-content-between">
|
||
<span>تعداد دانشجویان ثبتنامشده:</span>
|
||
<span class="font-bold">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
|
||
</div>
|
||
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm" for="class-bulk-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
|
||
<InputGroup>
|
||
<InputNumber inputId="class-bulk-amount" v-model="bulkForm.amount" class="w-full text-sm" :min="0" />
|
||
<InputGroupAddon>تومان</InputGroupAddon>
|
||
</InputGroup>
|
||
</div>
|
||
|
||
<div class="flex align-items-center gap-2">
|
||
<Checkbox v-model="hasBulkDiscount" binary inputId="class-bulk-discount" />
|
||
<label for="class-bulk-discount" class="font-semibold text-sm cursor-pointer">تخفیف همگانی ؟</label>
|
||
</div>
|
||
|
||
<div v-if="hasBulkDiscount" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm" for="class-bulk-discount-amount">مبلغ تخفیف (تومان)</label>
|
||
<InputGroup>
|
||
<InputNumber
|
||
inputId="class-bulk-discount-amount"
|
||
v-model="bulkForm.discount"
|
||
class="w-full text-sm"
|
||
:min="0"
|
||
:max="bulkForm.amount || 0"
|
||
/>
|
||
<InputGroupAddon>تومان</InputGroupAddon>
|
||
</InputGroup>
|
||
<small class="font-semibold text-color">
|
||
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
|
||
</small>
|
||
</div>
|
||
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||
<DatePicker v-model="bulkForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||
</div>
|
||
|
||
<div class="flex align-items-center gap-2">
|
||
<Checkbox v-model="bulkForm.skipExisting" binary inputId="class-bulk-skip-existing" />
|
||
<label for="class-bulk-skip-existing" class="text-sm cursor-pointer font-medium">
|
||
عدم صدور مجدد برای دانشجویانی که قبلاً در این کلاس صورتحساب دارند
|
||
</label>
|
||
</div>
|
||
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm" for="class-bulk-notes">یادداشت</label>
|
||
<Textarea
|
||
id="class-bulk-notes"
|
||
v-model="bulkForm.notes"
|
||
rows="2"
|
||
class="w-full text-sm"
|
||
maxlength="5000"
|
||
placeholder="یادداشت برای تمام صورتحسابهای صادره…"
|
||
/>
|
||
</div>
|
||
|
||
<NotifyChannelsField :notify="bulkNotify" />
|
||
</div>
|
||
<template #footer>
|
||
<Button label="انصراف" text severity="secondary" @click="bulkModalVisible = false" />
|
||
<Button
|
||
label="صدور صورتحسابها"
|
||
icon="pi pi-check"
|
||
severity="success"
|
||
:loading="isBulkCreating"
|
||
:disabled="!(classData?.students?.length)"
|
||
@click="handleBulkCreatePayment"
|
||
/>
|
||
</template>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { classApi } from '@/api/classApi';
|
||
import { paymentApi } from '@/api/paymentApi';
|
||
import { formatClassDays, formatClassTime, calculateClassMidDate } from '@/utils/classSchedule';
|
||
import { sessionApi } from '@/api/sessionApi';
|
||
import { usePersianDate } from '@/composables/usePersianDate';
|
||
import { useToast } from '@/composables/useToast';
|
||
import { usePermission } from '@/composables/usePermission';
|
||
import { getPayableAmount } from '@/utils/paymentAmount';
|
||
import PageHeader from '@/components/common/PageHeader.vue';
|
||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue';
|
||
import StatusTag from '@/components/common/StatusTag.vue';
|
||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||
import Button from 'primevue/button';
|
||
import DataTable from 'primevue/datatable';
|
||
import Column from 'primevue/column';
|
||
import Dialog from 'primevue/dialog';
|
||
import InputNumber from 'primevue/inputnumber';
|
||
import InputGroup from 'primevue/inputgroup';
|
||
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||
import Checkbox from 'primevue/checkbox';
|
||
import Textarea from 'primevue/textarea';
|
||
import DatePicker from 'vue3-persian-datetime-picker';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const classId = route.params.id;
|
||
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
||
const { showError, showSuccess } = useToast();
|
||
const { hasPermission } = usePermission();
|
||
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
||
|
||
const classData = ref(null);
|
||
const sessions = ref([]);
|
||
const loadingSessions = ref(false);
|
||
const removeDialogVisible = ref(false);
|
||
const removingUserId = ref('');
|
||
const studentToRemove = ref(null);
|
||
|
||
const deleteClassDialogVisible = ref(false);
|
||
const isDeletingClass = ref(false);
|
||
|
||
// Bulk Payment State
|
||
const bulkModalVisible = ref(false);
|
||
const isBulkCreating = ref(false);
|
||
const hasBulkDiscount = ref(false);
|
||
const bulkNotify = reactive({ sms: true, email: true, bot: true });
|
||
const bulkForm = reactive({
|
||
amount: 0,
|
||
discount: 0,
|
||
notes: '',
|
||
dueDate: getTodayJalali(),
|
||
skipExisting: true
|
||
});
|
||
|
||
const bulkPayableAmount = computed(() => getPayableAmount({
|
||
amount: bulkForm.amount,
|
||
discount: hasBulkDiscount.value ? bulkForm.discount : 0
|
||
}));
|
||
|
||
const openBulkModal = () => {
|
||
if (!classData.value) return;
|
||
bulkForm.amount = classData.value.tuitionFee || classData.value.course?.price || 0;
|
||
if (classData.value.hasDiscount && classData.value.discount) {
|
||
hasBulkDiscount.value = true;
|
||
bulkForm.discount = classData.value.discount;
|
||
} else {
|
||
hasBulkDiscount.value = false;
|
||
bulkForm.discount = 0;
|
||
}
|
||
bulkForm.notes = '';
|
||
bulkForm.skipExisting = true;
|
||
bulkNotify.sms = true;
|
||
bulkNotify.email = true;
|
||
bulkNotify.bot = true;
|
||
bulkForm.dueDate = calculateClassMidDate(classData.value, sessions.value);
|
||
bulkModalVisible.value = true;
|
||
};
|
||
|
||
const handleBulkCreatePayment = async () => {
|
||
if (!classData.value?.students?.length) {
|
||
showError('هیچ دانشجویی در این کلاس ثبتنام نشده است');
|
||
return;
|
||
}
|
||
if (!bulkForm.amount) { showError('لطفا مبلغ شهریه را وارد کنید'); return; }
|
||
if (!bulkForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||
const dueDate = toGregorianIso(bulkForm.dueDate);
|
||
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
||
|
||
if (hasBulkDiscount.value && (bulkForm.discount || 0) > bulkForm.amount) {
|
||
showError('مبلغ تخفیف نمیتواند بیشتر از مبلغ کل باشد');
|
||
return;
|
||
}
|
||
|
||
isBulkCreating.value = true;
|
||
try {
|
||
const res = await paymentApi.createBulkClass({
|
||
classId,
|
||
amount: bulkForm.amount,
|
||
discount: hasBulkDiscount.value ? (bulkForm.discount || 0) : 0,
|
||
dueDate,
|
||
notes: bulkForm.notes,
|
||
skipExisting: bulkForm.skipExisting,
|
||
notify: { ...bulkNotify }
|
||
});
|
||
const result = res.data?.data || res.data || res;
|
||
const createdCount = result.createdCount ?? 0;
|
||
const skippedCount = result.skippedCount ?? 0;
|
||
|
||
let msg = `صورتحساب برای ${toPersianDigits(createdCount)} دانشجو با موفقیت ایجاد شد`;
|
||
if (skippedCount > 0) {
|
||
msg += ` (${toPersianDigits(skippedCount)} دانشجو به دلیل داشتن صورتحساب قبلی رد شدند)`;
|
||
}
|
||
showSuccess(msg);
|
||
bulkModalVisible.value = false;
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isBulkCreating.value = false;
|
||
}
|
||
};
|
||
|
||
const deleteClassDialogMessage = computed(() => {
|
||
const name = classData.value?.name;
|
||
return name
|
||
? `آیا از حذف کلاس «${name}» اطمینان دارید؟ تمام جلسات و پیوندهای مرتبط با این کلاس نیز به سطل زباله منتقل میشوند.`
|
||
: 'آیا از حذف این کلاس اطمینان دارید؟ تمام جلسات و پیوندهای مرتبط با این کلاس نیز به سطل زباله منتقل میشوند.';
|
||
});
|
||
|
||
const confirmDeleteClass = () => {
|
||
deleteClassDialogVisible.value = true;
|
||
};
|
||
|
||
const handleDeleteClass = async () => {
|
||
isDeletingClass.value = true;
|
||
try {
|
||
await classApi.delete(classId);
|
||
showSuccess('کلاس و جلسات مرتبط با موفقیت حذف شدند');
|
||
deleteClassDialogVisible.value = false;
|
||
router.push('/classes');
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isDeletingClass.value = false;
|
||
}
|
||
};
|
||
|
||
const removeDialogMessage = computed(() => {
|
||
const name = studentToRemove.value?.name;
|
||
return name
|
||
? `آیا «${name}» از این کلاس حذف شود؟`
|
||
: 'آیا این دانشجو از کلاس حذف شود؟';
|
||
});
|
||
|
||
const isFetching = ref(true);
|
||
|
||
const fetchClass = async () => {
|
||
isFetching.value = true;
|
||
try {
|
||
const res = await classApi.getOne(classId);
|
||
classData.value = res.data || res;
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isFetching.value = false;
|
||
}
|
||
};
|
||
|
||
const fetchSessions = async () => {
|
||
loadingSessions.value = true;
|
||
try {
|
||
const res = await sessionApi.getAll({
|
||
limit: 100,
|
||
class: classId,
|
||
sortBy: 'day',
|
||
sortOrder: 'asc'
|
||
});
|
||
if (Array.isArray(res)) {
|
||
sessions.value = res;
|
||
} else if (Array.isArray(res?.data)) {
|
||
sessions.value = res.data;
|
||
} else if (Array.isArray(res?.items)) {
|
||
sessions.value = res.items;
|
||
} else {
|
||
sessions.value = [];
|
||
}
|
||
} catch (err) {
|
||
showError(err);
|
||
sessions.value = [];
|
||
} finally {
|
||
loadingSessions.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
fetchClass();
|
||
fetchSessions();
|
||
});
|
||
|
||
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);
|
||
classData.value = res.data || res;
|
||
removeDialogVisible.value = false;
|
||
studentToRemove.value = null;
|
||
showSuccess('دانشجو از کلاس حذف شد');
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
removingUserId.value = '';
|
||
}
|
||
};
|
||
</script>
|