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:
@@ -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>
|
||||
@@ -109,6 +109,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Attendance ───────────────────────────────────────────────────────── -->
|
||||
<PermissionGate :permission="PERMISSIONS.SESSIONS_ATTENDANCE">
|
||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-color m-0">حضور و غیاب</h3>
|
||||
<p class="text-xs text-muted m-0 mt-1">جلسات اخیر که نیاز به ثبت حضور دارند</p>
|
||||
</div>
|
||||
<Button label="مشاهده همه" icon="pi pi-arrow-left" text size="small" @click="$router.push('/attendance')" />
|
||||
</div>
|
||||
<DataTable :value="attendanceSessions" class="p-datatable-sm text-sm" :loading="isAttendanceLoading">
|
||||
<template #empty>
|
||||
<div class="text-center text-muted p-4">جلسهای برای ثبت حضور وجود ندارد</div>
|
||||
</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="day" header="تاریخ">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.day || data.date) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="attendanceStatus" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="hasAttendance(data) ? 'ثبت شده' : 'در انتظار'"
|
||||
:severity="hasAttendance(data) ? 'success' : 'warn'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="عملیات" style="width: 100px">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-check-square"
|
||||
:label="hasAttendance(data) ? 'ویرایش' : 'ثبت'"
|
||||
size="small"
|
||||
text
|
||||
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</PermissionGate>
|
||||
|
||||
<!-- ── Recent Sessions ─────────────────────────────────────────────────── -->
|
||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
@@ -167,8 +219,12 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { dashboardApi } from '@/api/dashboardApi';
|
||||
import { sessionApi } from '@/api/sessionApi';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import DataTable from 'primevue/datatable';
|
||||
@@ -176,9 +232,12 @@ import Column from 'primevue/column';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
const isLoading = ref(false);
|
||||
const isAttendanceLoading = ref(false);
|
||||
const attendanceSessions = ref([]);
|
||||
const stats = ref({
|
||||
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
|
||||
recentSessions: [],
|
||||
@@ -321,6 +380,16 @@ const quickLinks = computed(() => [
|
||||
badge: stats.value.totals.sessions,
|
||||
badgeSeverity: 'warning'
|
||||
},
|
||||
...(permissionStore.hasPermission(PERMISSIONS.SESSIONS_ATTENDANCE)
|
||||
? [{
|
||||
to: '/attendance',
|
||||
label: 'حضور و غیاب',
|
||||
icon: 'pi pi-check-square',
|
||||
iconBg: 'bg-amber-100',
|
||||
iconColor: 'text-amber-600',
|
||||
cardClass: 'quick-link-amber'
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
to: '/payments',
|
||||
label: 'امور مالی',
|
||||
@@ -339,7 +408,31 @@ const quickLinks = computed(() => [
|
||||
}
|
||||
]);
|
||||
|
||||
const hasAttendance = (session) => Array.isArray(session.attendanceList) && session.attendanceList.length > 0;
|
||||
|
||||
// ── Load Data ─────────────────────────────────────────────────────────────────
|
||||
const loadAttendanceSessions = async () => {
|
||||
if (!permissionStore.hasPermission(PERMISSIONS.SESSIONS_ATTENDANCE)) return;
|
||||
isAttendanceLoading.value = true;
|
||||
try {
|
||||
const res = await sessionApi.getAll({ page: 1, limit: 8, sortBy: 'day', sortOrder: 'desc' });
|
||||
const data = res.data || res;
|
||||
const sessions = Array.isArray(data) ? data : (data.data || []);
|
||||
attendanceSessions.value = sessions
|
||||
.filter((s) => !hasAttendance(s))
|
||||
.slice(0, 5);
|
||||
if (attendanceSessions.value.length < 5) {
|
||||
const recorded = sessions.filter((s) => hasAttendance(s)).slice(0, 5 - attendanceSessions.value.length);
|
||||
attendanceSessions.value = [...attendanceSessions.value, ...recorded];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Attendance sessions not available:', err.message);
|
||||
attendanceSessions.value = [];
|
||||
} finally {
|
||||
isAttendanceLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
@@ -357,6 +450,7 @@ const loadStats = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
loadAttendanceSessions();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<!-- /src/views/interface/InterfaceSettingsView.vue -->
|
||||
<template>
|
||||
<div class="interface-settings-view w-full max-w-4xl mx-auto">
|
||||
<PageHeader
|
||||
title="رابط کاربری"
|
||||
subtitle="اندازه متن و حالت پیشفرض روشن/تیره برای حساب شما"
|
||||
/>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||
<div class="flex align-items-start gap-3 mb-4">
|
||||
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
||||
<i class="pi pi-palette text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h2 class="text-lg font-bold text-color m-0 mb-1">حالت نمایش</h2>
|
||||
<p class="text-sm text-muted m-0 line-height-3">
|
||||
حالت پیشفرض رنگبندی برنامه را برای این حساب انتخاب کنید. با «سیستم»، تنظیمات دستگاه شما اعمال میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div
|
||||
v-for="option in themeStore.themeOptions"
|
||||
:key="option.value"
|
||||
class="col-12 sm:col-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-option w-full p-4 border-round-xl border-1 border-color text-center cursor-pointer transition-all"
|
||||
:class="{ 'theme-option--active': themeStore.themePreference === option.value }"
|
||||
@click="selectTheme(option.value)"
|
||||
>
|
||||
<i :class="[option.icon, 'text-2xl mb-2 block']"></i>
|
||||
<span class="font-semibold text-sm text-color">{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||
<div class="flex align-items-start gap-3 mb-4">
|
||||
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
||||
<i class="pi pi-text-height text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h2 class="text-lg font-bold text-color m-0 mb-1">اندازه فونت</h2>
|
||||
<p class="text-sm text-muted m-0 line-height-3">
|
||||
اندازه متن کل برنامه را برای خوانایی بهتر تنظیم کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-3">
|
||||
<div
|
||||
v-for="option in themeStore.fontSizeOptions"
|
||||
:key="option.value"
|
||||
class="font-size-option p-3 border-round-lg border-1 border-color flex align-items-center justify-content-between gap-3 cursor-pointer transition-all"
|
||||
:class="{ 'font-size-option--active': themeStore.fontSize === option.value }"
|
||||
@click="selectFontSize(option.value)"
|
||||
>
|
||||
<div class="flex align-items-center gap-3">
|
||||
<RadioButton
|
||||
:inputId="`font-${option.value}`"
|
||||
name="fontSize"
|
||||
:value="option.value"
|
||||
v-model="themeStore.fontSize"
|
||||
@change="selectFontSize(option.value)"
|
||||
/>
|
||||
<label :for="`font-${option.value}`" class="font-semibold text-color cursor-pointer">
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
<span
|
||||
class="font-size-preview text-color"
|
||||
:style="{ fontSize: previewSize(option.value) }"
|
||||
>
|
||||
نمونه متن — {{ toPersianDigits(option.sample) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-content-end gap-2">
|
||||
<Button
|
||||
label="ذخیره تنظیمات"
|
||||
icon="pi pi-save"
|
||||
:loading="isSaving"
|
||||
@click="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import Button from 'primevue/button';
|
||||
import RadioButton from 'primevue/radiobutton';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const authStore = useAuthStore();
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const isSaving = ref(false);
|
||||
|
||||
const previewSizes = {
|
||||
small: '12px',
|
||||
medium: '14px',
|
||||
large: '16px'
|
||||
};
|
||||
|
||||
const previewSize = (value) => previewSizes[value] || previewSizes.medium;
|
||||
|
||||
const selectTheme = (value) => {
|
||||
themeStore.setThemePreference(value);
|
||||
};
|
||||
|
||||
const selectFontSize = (value) => {
|
||||
themeStore.setFontSize(value);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const user = await themeStore.saveToServer();
|
||||
if (user) {
|
||||
authStore.setUser({
|
||||
...(authStore.user || {}),
|
||||
...user
|
||||
});
|
||||
}
|
||||
showSuccess('تنظیمات رابط کاربری ذخیره شد');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-option {
|
||||
background: var(--surface-ground);
|
||||
border-color: var(--border-color) !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color) !important;
|
||||
background: var(--primary-50, rgba(99, 102, 241, 0.04));
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--primary-color) !important;
|
||||
background: var(--primary-color-light);
|
||||
color: var(--primary-color);
|
||||
|
||||
i, span {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.font-size-option {
|
||||
background: var(--surface-ground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--primary-color) !important;
|
||||
background: var(--primary-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.font-size-preview {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user