Files
gameno-dashboard/src/views/classes/ClassDetailView.vue
T

475 lines
18 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>