Add attendance hub, categorized sidebar, and UI preferences page.

Improve dashboard and navigation access to attendance, group the right-side menu by category, and let each account save default theme and font size.
This commit is contained in:
2026-08-16 07:28:58 +03:30
parent 9c4182ea5a
commit ba509734ae
12 changed files with 731 additions and 39 deletions
@@ -0,0 +1,193 @@
<!-- /src/views/attendances/AttendanceListView.vue -->
<template>
<div class="attendance-list-view">
<PageHeader
title="حضور و غیاب"
subtitle="ثبت و پیگیری حضور و غیاب جلسات آموزشی"
>
<Button
label="مشاهده جلسات"
icon="pi pi-calendar"
text
@click="$router.push('/sessions')"
/>
</PageHeader>
<div class="grid mb-4">
<div class="col-12 sm:col-4">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<span class="text-muted text-xs block mb-1">جلسات بدون ثبت حضور</span>
<span class="text-3xl font-bold text-orange-500">{{ toPersianDigits(summary.pending) }}</span>
</div>
</div>
<div class="col-12 sm:col-4">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<span class="text-muted text-xs block mb-1">جلسات ثبتشده</span>
<span class="text-3xl font-bold text-green-500">{{ toPersianDigits(summary.recorded) }}</span>
</div>
</div>
<div class="col-12 sm:col-4">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<span class="text-muted text-xs block mb-1">کل جلسات</span>
<span class="text-3xl font-bold text-color">{{ toPersianDigits(summary.total) }}</span>
</div>
</div>
</div>
<DataTableWrapper
:items="displayItems"
:totalCount="displayTotal"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<template #toolbar>
<SelectButton
v-model="attendanceFilter"
:options="filterOptions"
optionLabel="label"
optionValue="value"
class="text-sm"
/>
</template>
<Column field="topic" header="موضوع جلسه">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
</template>
</Column>
<Column field="course" header="دوره">
<template #body="{ data }">
{{ data.course?.title || data.courseTitle || '—' }}
</template>
</Column>
<Column field="class" header="کلاس">
<template #body="{ data }">
{{ data.class?.name || '—' }}
</template>
</Column>
<Column field="day" header="تاریخ" sortable sortField="day">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="timeRange" header="ساعت">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column field="attendanceStatus" header="وضعیت حضور">
<template #body="{ data }">
<Tag
:value="hasAttendance(data) ? 'ثبت شده' : 'در انتظار ثبت'"
:severity="hasAttendance(data) ? 'success' : 'warn'"
/>
</template>
</Column>
<Column field="status" header="وضعیت جلسه">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<Button
icon="pi pi-check-square"
:label="hasAttendance(data) ? 'ویرایش' : 'ثبت'"
size="small"
:severity="hasAttendance(data) ? 'secondary' : 'success'"
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { sessionApi } from '@/api/sessionApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import SelectButton from 'primevue/selectbutton';
const { formatJalali, toPersianDigits } = usePersianDate();
const attendanceFilter = ref('all');
const filterOptions = [
{ label: 'همه', value: 'all' },
{ label: 'در انتظار ثبت', value: 'pending' },
{ label: 'ثبت شده', value: 'recorded' }
];
const summary = ref({ pending: 0, recorded: 0, total: 0 });
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(sessionApi.getAll, {
sortBy: 'day',
sortOrder: 'desc'
});
const hasAttendance = (session) => Array.isArray(session.attendanceList) && session.attendanceList.length > 0;
const displayItems = computed(() => {
if (attendanceFilter.value === 'pending') {
return items.value.filter((s) => !hasAttendance(s));
}
if (attendanceFilter.value === 'recorded') {
return items.value.filter((s) => hasAttendance(s));
}
return items.value;
});
const displayTotal = computed(() => {
if (attendanceFilter.value === 'all') return totalCount.value;
return displayItems.value.length;
});
const loadSummary = async () => {
try {
const res = await sessionApi.getAll({ page: 1, limit: 500, sortBy: 'day', sortOrder: 'desc' });
const data = res.data || res;
const sessions = Array.isArray(data) ? data : (data.data || []);
const total = res.meta?.totalCount ?? sessions.length;
summary.value = {
total,
recorded: sessions.filter((s) => hasAttendance(s)).length,
pending: sessions.filter((s) => !hasAttendance(s)).length
};
} catch {
summary.value = { pending: 0, recorded: 0, total: 0 };
}
};
onMounted(async () => {
await Promise.all([loadData(), loadSummary()]);
});
</script>