Files
gameno-dashboard/src/components/classes/ClassSessionsSection.vue
T

635 lines
24 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.
<!-- Sessions management for a class: list existing, bulk create when empty, manual add at bottom -->
<template>
<div class="class-sessions-section flex flex-column gap-4">
<!-- Always show sessions list below students -->
<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 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>
<div class="flex align-items-center gap-2 flex-wrap">
<Tag :value="`${toPersianDigits(sessions.length)} جلسه`" severity="info" />
<template v-if="selectedSessions.length">
<Tag :value="`${toPersianDigits(selectedSessions.length)} انتخاب‌شده`" />
<Select
v-model="bulkStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="تغییر وضعیت"
class="text-sm"
style="min-width: 9rem"
/>
<Button
label="اعمال"
icon="pi pi-check"
size="small"
:loading="isBulkUpdating"
:disabled="!bulkStatus"
@click="applyBulkStatus"
/>
<Button
label="حذف"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="confirmBulkDelete"
/>
<Button label="لغو" text size="small" severity="secondary" @click="selectedSessions = []" />
</template>
</div>
</div>
<div v-if="loadingSessions" class="mb-2">
<TableSkeleton :rows="6" :columns="6" />
</div>
<DataTable
v-else
v-model:selection="selectedSessions"
:value="sessions"
dataKey="_id"
class="p-datatable-sm text-sm"
emptyMessage="جلسه‌ای ثبت نشده است"
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column header="#" style="width: 50px">
<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="وضعیت" style="width: 100px">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
<Column header="عملیات" style="width: 170px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-check-square"
text
rounded
size="small"
severity="success"
v-tooltip.top="'حضور و غیاب'"
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
<Button
icon="pi pi-send"
text
rounded
size="small"
severity="info"
v-tooltip.top="'اطلاع‌رسانی برگزاری جلسه'"
:loading="notifyingSessionId === (data._id || data.id)"
@click="openNotifyHoldingDialog(data)"
/>
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
v-tooltip.top="'ویرایش'"
@click="$router.push(`/sessions/edit/${data._id || data.id}`)"
/>
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'حذف'"
@click="confirmDeleteOne(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
:title="bulkDeleteMode ? 'حذف گروهی جلسات' : 'حذف جلسه'"
:message="deleteMessage"
@confirm="handleDeleteConfirm"
/>
<Dialog
v-model:visible="notifyDialogVisible"
header="اطلاع‌رسانی برگزاری جلسه"
modal
:style="{ width: '450px' }"
>
<div class="flex flex-column gap-3 py-2">
<p class="m-0 line-height-3 text-sm">
آیا از ارسال پیامک برگزاری جلسه طبق برنامه به تمامی دانشجویان این کلاس اطمینان دارید؟
</p>
<div v-if="selectedSessionForNotify" class="surface-100 p-3 border-round text-xs flex flex-column gap-1">
<div><strong>موضوع جلسه:</strong> {{ selectedSessionForNotify.topic || 'طبق برنامه' }}</div>
<div><strong>تاریخ:</strong> {{ formatJalali(selectedSessionForNotify.day || selectedSessionForNotify.date) }}</div>
<div><strong>ساعت:</strong> {{ selectedSessionForNotify.startTime || '—' }} تا {{ selectedSessionForNotify.endTime || '—' }}</div>
</div>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="notifyDialogVisible = false" />
<Button
label="ارسال پیامک"
icon="pi pi-send"
severity="info"
:loading="isNotifying"
@click="handleSendSessionHolding"
/>
</template>
</Dialog>
<!-- Bulk auto-create: only when no sessions yet -->
<div v-if="!hasSessions" class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold text-color mb-1">ایجاد جلسات کلاس</h2>
<p class="text-muted text-sm mb-3">جلسات را بهصورت خودکار بر اساس روزهای هفته بسازید، یا از بخش دستی پایین صفحه استفاده کنید.</p>
<div class="grid">
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تعداد جلسات</label>
<InputNumber v-model="autoForm.sessionCount" :min="1" class="w-full text-sm" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label>
<DatePicker v-model="autoForm.startDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت شروع</label>
<InputText v-model="autoForm.startTime" class="w-full text-sm" placeholder="19:00" dir="ltr" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت پایان</label>
<InputText v-model="autoForm.endTime" class="w-full text-sm" placeholder="20:30" dir="ltr" />
</div>
<div class="col-12 flex flex-column gap-2 my-2">
<label class="font-semibold text-sm">روزهای هفته</label>
<SelectButton v-model="autoForm.selectedDays" :options="weekdays" optionLabel="label" optionValue="value" multiple class="text-sm" />
</div>
<div class="col-12">
<Button label="پیش‌نمایش و ساخت جلسات" icon="pi pi-cog" severity="help" @click="generateSessionsPreview" />
</div>
</div>
<div v-if="generatedSessions.length > 0" class="mt-4 pt-3 border-top-1 border-color">
<h3 class="text-md font-bold mb-3">پیشنمایش جلسات (قابل ویرایش)</h3>
<DataTable :value="generatedSessions" class="p-datatable-sm text-sm">
<Column field="sessionNumber" header="جلسه #" style="width: 70px">
<template #body="{ data }">{{ toPersianDigits(data.sessionNumber) }}</template>
</Column>
<Column field="date" header="تاریخ">
<template #body="{ data }">
<InputText v-model="data.date" class="w-full p-inputtext-sm text-sm" />
</template>
</Column>
<Column field="startTime" header="ساعت شروع">
<template #body="{ data }">
<InputText v-model="data.startTime" class="w-full p-inputtext-sm text-sm" dir="ltr" />
</template>
</Column>
<Column field="endTime" header="ساعت پایان">
<template #body="{ data }">
<InputText v-model="data.endTime" class="w-full p-inputtext-sm text-sm" dir="ltr" />
</template>
</Column>
<Column field="topic" header="موضوع جلسه">
<template #body="{ data }">
<InputText v-model="data.topic" class="w-full p-inputtext-sm text-sm" />
</template>
</Column>
</DataTable>
<div class="flex justify-content-end mt-3">
<Button label="ثبت نهایی جلسات" icon="pi pi-save" severity="success" :loading="isSaving" @click="submitGeneratedSessions" />
</div>
</div>
</div>
<!-- Manual create: always at the bottom -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold text-color mb-1">
{{ hasSessions ? 'افزودن جلسه دستی' : 'تعریف دستی جلسات' }}
</h2>
<p class="text-muted text-sm mb-3">
{{ hasSessions ? 'در صورت نیاز می‌توانید یک یا چند جلسه اضافه کنید.' : 'جلسات را یکی‌یکی تعریف کنید.' }}
</p>
<div v-for="(session, index) in manualSessions" :key="index" class="grid align-items-center mb-2 p-2 border-round surface-ground">
<div class="col-12 sm:col-1 text-center font-bold">#{{ toPersianDigits(index + 1) }}</div>
<div class="col-12 sm:col-3">
<InputText v-model="session.date" :placeholder="`تاریخ (${todayJalali()})`" class="w-full text-sm" />
</div>
<div class="col-6 sm:col-2">
<InputText v-model="session.startTime" placeholder="ساعت شروع" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-6 sm:col-2">
<InputText v-model="session.endTime" placeholder="ساعت پایان" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 sm:col-3">
<InputText v-model="session.topic" placeholder="موضوع جلسه" class="w-full text-sm" />
</div>
<div class="col-12 sm:col-1 text-center">
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
:disabled="manualSessions.length === 1"
@click="manualSessions.splice(index, 1)"
/>
</div>
</div>
<div class="flex align-items-center justify-content-between mt-3">
<Button
label="افزودن ردیف"
icon="pi pi-plus"
text
@click="manualSessions.push({ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' })"
/>
<Button label="ثبت جلسات" icon="pi pi-save" severity="success" :loading="isSaving" @click="submitManualSessions" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue';
import moment from 'jalali-moment';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import StatusTag from '@/components/common/StatusTag.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import Select from 'primevue/select';
import SelectButton from 'primevue/selectbutton';
import DatePicker from 'vue3-persian-datetime-picker';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
const props = defineProps({
classId: { type: [String, Object], required: true },
courseId: { type: [String, Object], required: true },
professorId: { type: [String, Object], default: null },
defaultSessionCount: { type: Number, default: 12 },
days: { type: Array, default: () => [] },
startTime: { type: String, default: '' },
endTime: { type: String, default: '' }
});
const { toPersianDigits, toLatinDigits, toGregorianIso, formatJalali, getTodayJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const sessions = ref([]);
const selectedSessions = ref([]);
const loadingSessions = ref(false);
const isSaving = ref(false);
const isDeleting = ref(false);
const isBulkUpdating = ref(false);
const deleteDialogVisible = ref(false);
const bulkDeleteMode = ref(false);
const sessionToDelete = ref(null);
const bulkStatus = ref(null);
const generatedSessions = ref([]);
const manualSessions = ref([{ date: getTodayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }]);
const notifyDialogVisible = ref(false);
const selectedSessionForNotify = ref(null);
const notifyingSessionId = ref(null);
const isNotifying = ref(false);
const openNotifyHoldingDialog = (session) => {
selectedSessionForNotify.value = session;
notifyDialogVisible.value = true;
};
const handleSendSessionHolding = async () => {
if (!selectedSessionForNotify.value) return;
const id = selectedSessionForNotify.value._id || selectedSessionForNotify.value.id;
notifyingSessionId.value = id;
isNotifying.value = true;
try {
const res = await sessionApi.notifyHolding(id);
const count = res.data?.sentCount || res.data?.totalStudents || '';
showSuccess(`پیامک برگزاری جلسه با موفقیت به ${count ? toPersianDigits(count) + ' نفر از ' : ''}دانشجویان ارسال شد`);
notifyDialogVisible.value = false;
selectedSessionForNotify.value = null;
} catch (err) {
showError(err);
} finally {
notifyingSessionId.value = null;
isNotifying.value = false;
}
};
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزار شده', value: 'held' },
{ label: 'لغو شده', value: 'cancelled' }
];
const selectedIds = computed(() =>
selectedSessions.value.map((s) => s._id || s.id).filter(Boolean)
);
const deleteMessage = computed(() => {
if (bulkDeleteMode.value) {
return `آیا از حذف ${toPersianDigits(selectedSessions.value.length)} جلسه انتخاب‌شده اطمینان دارید؟ این عملیات قابل بازگشت نیست.`;
}
return 'آیا از حذف این جلسه اطمینان دارید؟ این عملیات قابل بازگشت نیست.';
});
const resolvedClassId = computed(() => {
const value = props.classId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const resolvedCourseId = computed(() => {
const value = props.courseId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const resolvedProfessorId = computed(() => {
const value = props.professorId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const hasSessions = computed(() => sessions.value.length > 0);
const normalizeList = (response) => {
if (!response) return [];
if (Array.isArray(response)) return response;
if (Array.isArray(response.data)) return response.data;
if (Array.isArray(response.items)) return response.items;
if (Array.isArray(response.sessions)) return response.sessions;
if (response.data && typeof response.data === 'object') {
if (Array.isArray(response.data.data)) return response.data.data;
if (Array.isArray(response.data.items)) return response.data.items;
if (Array.isArray(response.data.sessions)) return response.data.sessions;
}
return [];
};
const weekdays = [
{ label: 'شنبه', value: 6 },
{ label: 'یکشنبه', value: 0 },
{ label: 'دوشنبه', value: 1 },
{ label: 'سه‌شنبه', value: 2 },
{ label: 'چهارشنبه', value: 3 },
{ label: 'پنجشنبه', value: 4 },
{ label: 'جمعه', value: 5 }
];
const autoForm = reactive({
sessionCount: props.defaultSessionCount || 12,
startDate: getTodayJalali(),
startTime: props.startTime || '19:00',
endTime: props.endTime || '20:30',
selectedDays: props.days?.length ? [...props.days] : [6, 2]
});
watch(
() => [props.days, props.startTime, props.endTime, props.defaultSessionCount],
() => {
if (props.days?.length) autoForm.selectedDays = [...props.days];
if (props.startTime) autoForm.startTime = props.startTime;
if (props.endTime) autoForm.endTime = props.endTime;
if (props.defaultSessionCount) autoForm.sessionCount = props.defaultSessionCount;
},
{ deep: true }
);
const toSessionDayIso = (value) => {
if (!value) return null;
return toGregorianIso(value) || null;
};
const fetchSessions = async () => {
const classId = resolvedClassId.value;
if (!classId) return;
loadingSessions.value = true;
try {
// Use schema field `class` only. Sending classId alongside it makes older
// API builds add `{ classId: ... }` to the Mongo filter and return 0 rows.
const res = await sessionApi.getAll({
limit: 100,
class: classId,
sortBy: 'day',
sortOrder: 'asc'
});
sessions.value = normalizeList(res);
selectedSessions.value = [];
} catch (err) {
showError(err);
sessions.value = [];
} finally {
loadingSessions.value = false;
}
};
const confirmDeleteOne = (session) => {
bulkDeleteMode.value = false;
sessionToDelete.value = session;
deleteDialogVisible.value = true;
};
const confirmBulkDelete = () => {
if (!selectedSessions.value.length) return;
bulkDeleteMode.value = true;
sessionToDelete.value = null;
deleteDialogVisible.value = true;
};
const handleDeleteConfirm = async () => {
isDeleting.value = true;
try {
if (bulkDeleteMode.value) {
const res = await sessionApi.bulkDelete(selectedIds.value);
const deletedCount = res?.data?.deletedCount ?? selectedIds.value.length;
showSuccess(`${toPersianDigits(deletedCount)} جلسه حذف شد`);
selectedSessions.value = [];
} else if (sessionToDelete.value) {
await sessionApi.delete(sessionToDelete.value._id || sessionToDelete.value.id);
showSuccess('جلسه با موفقیت حذف شد');
}
deleteDialogVisible.value = false;
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
const applyBulkStatus = async () => {
if (!selectedIds.value.length || !bulkStatus.value) return;
isBulkUpdating.value = true;
try {
const res = await sessionApi.bulkUpdateStatus(selectedIds.value, bulkStatus.value);
const updatedCount = res?.data?.updatedCount ?? selectedIds.value.length;
showSuccess(`وضعیت ${toPersianDigits(updatedCount)} جلسه به‌روز شد`);
bulkStatus.value = null;
selectedSessions.value = [];
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isBulkUpdating.value = false;
}
};
const resetCreationForms = () => {
generatedSessions.value = [];
manualSessions.value = [{ date: getTodayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }];
};
const generateSessionsPreview = () => {
if (!autoForm.startDate) {
showError('لطفا تاریخ شروع را وارد کنید');
return;
}
if (!autoForm.selectedDays?.length) {
showError('لطفا حداقل یک روز از هفته را انتخاب کنید');
return;
}
const list = [];
const latinStartDate = toLatinDigits(autoForm.startDate);
let currentMoment = moment(latinStartDate, ['jYYYY/jMM/jDD', 'YYYY-MM-DD', 'YYYY/MM/DD', moment.ISO_8601]);
if (!currentMoment.isValid()) {
showError('تاریخ شروع نامعتبر است');
return;
}
let count = 0;
let safetyLimit = 365 * 5;
const targetCount = Math.max(1, parseInt(autoForm.sessionCount, 10) || 12);
while (count < targetCount && safetyLimit > 0) {
safetyLimit--;
if (autoForm.selectedDays.includes(currentMoment.day())) {
count++;
list.push({
sessionNumber: count,
date: toLatinDigits(currentMoment.locale('fa').format('jYYYY/jMM/jDD')),
startTime: autoForm.startTime,
endTime: autoForm.endTime,
topic: `جلسه ${count}`
});
}
if (count >= targetCount) break;
currentMoment.add(1, 'day');
}
generatedSessions.value = list;
};
const ensureIds = () => {
if (!resolvedClassId.value) {
showError('ابتدا کلاس را ذخیره کنید');
return false;
}
if (!resolvedCourseId.value) {
showError('دوره مرتبط با کلاس مشخص نیست');
return false;
}
if (!resolvedProfessorId.value) {
showError('لطفا استاد کلاس را انتخاب و ذخیره کنید');
return false;
}
return true;
};
const submitGeneratedSessions = async () => {
if (!ensureIds()) return;
isSaving.value = true;
try {
await sessionApi.create({
courseId: resolvedCourseId.value,
classId: resolvedClassId.value,
professorId: resolvedProfessorId.value,
sessions: generatedSessions.value.map((s) => ({
topic: s.topic,
startTime: s.startTime,
endTime: s.endTime,
day: toSessionDayIso(s.date)
}))
});
showSuccess('جلسات کلاس با موفقیت ایجاد شدند');
resetCreationForms();
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
const submitManualSessions = async () => {
if (!ensureIds()) return;
const invalid = manualSessions.value.some((s) => !s.date || !s.startTime || !s.endTime);
if (invalid) {
showError('تاریخ و ساعت همه جلسات الزامی است');
return;
}
isSaving.value = true;
try {
await sessionApi.create({
courseId: resolvedCourseId.value,
classId: resolvedClassId.value,
professorId: resolvedProfessorId.value,
sessions: manualSessions.value.map((s) => ({
topic: s.topic,
startTime: s.startTime,
endTime: s.endTime,
day: toSessionDayIso(s.date)
}))
});
showSuccess('جلسات با موفقیت ثبت شدند');
resetCreationForms();
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
watch(
() => props.defaultSessionCount,
(val) => {
if (val && !hasSessions.value) autoForm.sessionCount = val;
}
);
watch(resolvedClassId, (id) => {
if (id) fetchSessions();
}, { immediate: true });
defineExpose({ fetchSessions });
</script>