Require explicit attendance status selection before submit.

Do not prefill present for unsaved students, highlight pending rows, and show recorded counts on the attendance list.
This commit is contained in:
2026-08-16 09:22:41 +03:30
parent 8d29a5d858
commit 26c53d57e4
2 changed files with 96 additions and 16 deletions
+84 -12
View File
@@ -8,6 +8,33 @@
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/attendance')" /> <Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/attendance')" />
</PageHeader> </PageHeader>
<div
v-if="pendingStudents.length"
class="surface-card p-3 sm:p-4 border-round border-1 border-orange-300 bg-orange-50 mb-3 text-sm text-orange-900"
>
<strong>{{ toPersianDigits(pendingStudents.length) }} دانشجو هنوز وضعیت حضور ندارند</strong>
<span class="block mt-1">برای هر دانشجوی زیر یکی از گزینههای حضور، غیبت، تاخیر یا موجه را انتخاب کنید.</span>
<ul class="mt-2 mb-0 pr-3">
<li v-for="student in pendingStudents" :key="student.userId">
{{ student.name || '' }}
<span v-if="student.nationalIdCode" class="text-xs">({{ toPersianDigits(student.nationalIdCode) }})</span>
</li>
</ul>
</div>
<div
v-else-if="!attendanceIsComplete"
class="surface-card p-3 sm:p-4 border-round border-1 border-blue-300 bg-blue-50 mb-3 text-sm text-blue-900"
>
<strong>وضعیت همه دانشجویان انتخاب شده است.</strong>
<span class="block mt-1">
برای ذخیره نهایی، دکمه «{{ $t('attendances.submitAttendance') }}» را بزنید.
</span>
<span class="block mt-2 font-semibold">
{{ toPersianDigits(recordedCount) }} از {{ toPersianDigits(studentList.length) }} دانشجو در سیستم ثبت شده
</span>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-3" v-if="session.note"> <div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-3" v-if="session.note">
<span class="text-muted text-xs block mb-1">یادداشت جلسه</span> <span class="text-muted text-xs block mb-1">یادداشت جلسه</span>
<p class="m-0 white-space-pre-wrap">{{ session.note }}</p> <p class="m-0 white-space-pre-wrap">{{ session.note }}</p>
@@ -24,7 +51,12 @@
</div> </div>
</div> </div>
<DataTable :value="studentList" class="p-datatable-sm text-sm" emptyMessage="دانشجویی در این کلاس ثبت‌نام نشده است"> <DataTable
:value="studentList"
:rowClass="getRowClass"
class="p-datatable-sm text-sm"
emptyMessage="دانشجویی در این کلاس ثبت‌نام نشده است"
>
<Column header="#" style="width: 50px"> <Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template> <template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column> </Column>
@@ -33,6 +65,18 @@
<template #body="{ data }"> <template #body="{ data }">
<span class="font-bold text-color">{{ data.name }}</span> <span class="font-bold text-color">{{ data.name }}</span>
<span class="text-xs text-muted block">کد ملی: {{ toPersianDigits(data.nationalIdCode || '—') }}</span> <span class="text-xs text-muted block">کد ملی: {{ toPersianDigits(data.nationalIdCode || '—') }}</span>
<Tag
v-if="!data.status"
value="نیاز به ثبت وضعیت"
severity="warn"
class="mt-1 text-xs"
/>
<Tag
v-else-if="!data.isRecorded"
value="ذخیره نشده"
severity="info"
class="mt-1 text-xs"
/>
</template> </template>
</Column> </Column>
@@ -43,8 +87,9 @@
:options="statusOptions" :options="statusOptions"
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
class="text-xs" :class="['text-xs', { 'attendance-select-pending': !data.status }]"
/> />
<small v-if="!data.status" class="text-orange-600 block mt-1">یک وضعیت انتخاب کنید</small>
</template> </template>
</Column> </Column>
@@ -62,7 +107,7 @@
icon="pi pi-save" icon="pi pi-save"
severity="success" severity="success"
:loading="isSubmitting" :loading="isSubmitting"
:disabled="!studentList.length" :disabled="!canSubmit"
@click="handleSubmit" @click="handleSubmit"
/> />
</div> </div>
@@ -71,7 +116,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { sessionApi } from '@/api/sessionApi'; import { sessionApi } from '@/api/sessionApi';
import { attendanceApi } from '@/api/attendanceApi'; import { attendanceApi } from '@/api/attendanceApi';
@@ -83,6 +128,7 @@ import DataTable from 'primevue/datatable';
import Column from 'primevue/column'; import Column from 'primevue/column';
import SelectButton from 'primevue/selectbutton'; import SelectButton from 'primevue/selectbutton';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import Tag from 'primevue/tag';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -102,6 +148,15 @@ const statusOptions = [
{ label: 'موجه', value: 'excused' } { label: 'موجه', value: 'excused' }
]; ];
const recordedCount = computed(() => studentList.value.filter((student) => student.isRecorded).length);
const pendingStudents = computed(() => studentList.value.filter((student) => !student.status));
const canSubmit = computed(() => studentList.value.length > 0 && pendingStudents.value.length === 0);
const attendanceIsComplete = computed(() =>
studentList.value.length > 0 && recordedCount.value === studentList.value.length
);
const getRowClass = (data) => (!data.status ? 'attendance-row-pending' : '');
const fetchSessionAttendance = async () => { const fetchSessionAttendance = async () => {
try { try {
const res = await sessionApi.getOne(sessionId); const res = await sessionApi.getOne(sessionId);
@@ -121,11 +176,11 @@ const fetchSessionAttendance = async () => {
return { return {
userId: user._id, userId: user._id,
name: user.name || '', name: user.name || '',
name: user.name || '',
gender: user.gender || null, gender: user.gender || null,
nationalIdCode: user.nationalIdCode || '', nationalIdCode: user.nationalIdCode || '',
status: existing?.status || 'present', status: existing?.status ?? null,
note: existing?.note || '' note: existing?.note || '',
isRecorded: Boolean(existing)
}; };
}); });
} catch (err) { } catch (err) {
@@ -134,16 +189,23 @@ const fetchSessionAttendance = async () => {
}; };
const markAllPresent = () => { const markAllPresent = () => {
studentList.value.forEach((s) => { s.status = 'present'; }); studentList.value.forEach((student) => {
student.status = 'present';
});
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!canSubmit.value) {
showError('برای همه دانشجویان باید وضعیت حضور انتخاب شود');
return;
}
isSubmitting.value = true; isSubmitting.value = true;
try { try {
const payloadList = studentList.value.map((s) => ({ const payloadList = studentList.value.map((student) => ({
user: s.userId, user: student.userId,
status: s.status, status: student.status,
note: s.note note: student.note
})); }));
await attendanceApi.recordSessionAttendance(sessionId, payloadList); await attendanceApi.recordSessionAttendance(sessionId, payloadList);
showSuccess('حضور و غیاب جلسه با موفقیت ثبت شد'); showSuccess('حضور و غیاب جلسه با موفقیت ثبت شد');
@@ -157,3 +219,13 @@ const handleSubmit = async () => {
onMounted(fetchSessionAttendance); onMounted(fetchSessionAttendance);
</script> </script>
<style scoped>
:deep(.attendance-row-pending) {
background: rgba(249, 115, 22, 0.08);
}
:deep(.attendance-select-pending .p-selectbutton .p-button) {
border-color: rgba(249, 115, 22, 0.45);
}
</style>
@@ -89,10 +89,18 @@
<Column field="attendanceStatus" header="وضعیت حضور"> <Column field="attendanceStatus" header="وضعیت حضور">
<template #body="{ data }"> <template #body="{ data }">
<div class="flex flex-column gap-1">
<Tag <Tag
:value="getAttendanceStatus(data).label" :value="getAttendanceStatus(data).label"
:severity="getAttendanceStatus(data).severity" :severity="getAttendanceStatus(data).severity"
/> />
<span
v-if="data.attendanceTotalCount"
class="text-xs text-muted"
>
{{ toPersianDigits(data.attendanceRecordedCount || 0) }} از {{ toPersianDigits(data.attendanceTotalCount) }} دانشجو
</span>
</div>
</template> </template>
</Column> </Column>