Initial commit: admin dashboard for GameNo.

Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit 1a34c3b133
94 changed files with 12016 additions and 0 deletions
@@ -0,0 +1,548 @@
<!-- 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: 140px">
<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-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"
/>
<!-- 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="1403/01/01" />
</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="تاریخ (1403/01/01)" 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';
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 }
});
const { toPersianDigits, toLatinDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const todayJalali = () => moment().locale('fa').format('jYYYY/jMM/jDD');
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: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }]);
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: todayJalali(),
startTime: '19:00',
endTime: '20:30',
selectedDays: [6, 2]
});
const toSessionDayIso = (value) => {
if (!value) return null;
const latin = toLatinDigits(String(value));
const m = moment(latin, ['jYYYY/jMM/jDD', 'YYYY/MM/DD', moment.ISO_8601], true);
return m.isValid() ? m.toDate().toISOString() : 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: todayJalali(), 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');
let count = 0;
const targetCount = autoForm.sessionCount || 12;
while (count < targetCount) {
if (autoForm.selectedDays.includes(currentMoment.day())) {
count++;
list.push({
sessionNumber: count,
date: currentMoment.locale('fa').format('YYYY/MM/DD'),
startTime: autoForm.startTime,
endTime: autoForm.endTime,
topic: `جلسه ${count}`
});
}
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>