diff --git a/src/App.vue b/src/App.vue index 434fd38..5e78423 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,13 +10,18 @@ diff --git a/src/api/authApi.js b/src/api/authApi.js index 4e88027..82f9c4c 100644 --- a/src/api/authApi.js +++ b/src/api/authApi.js @@ -6,5 +6,6 @@ export const authApi = { refresh: (refreshToken) => axiosInstance.post('/auth/refresh', { refreshToken }), logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }), changePassword: (data) => axiosInstance.put('/auth/change-password', data), - getSelf: () => axiosInstance.get('/users/user/get-self') + getSelf: () => axiosInstance.get('/users/user/get-self'), + updateSelf: (data) => axiosInstance.put('/users/user/update-self', data) }; diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss index 31f728c..9958f84 100644 --- a/src/assets/styles/main.scss +++ b/src/assets/styles/main.scss @@ -18,7 +18,7 @@ html, body { font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; + font-size: var(--app-font-size, 14px); background-color: var(--bg-primary); color: var(--text-primary); min-height: 100vh; diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 8014089..061488c 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -7,16 +7,21 @@ @@ -25,36 +30,86 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; import { usePermissionStore } from '@/stores/permissionStore'; +import { PERMISSIONS } from '@/constants/permissions'; const route = useRoute(); const permissionStore = usePermissionStore(); -const baseMenuItems = [ - { label: 'داشبورد اصلی', icon: 'pi pi-home', to: '/' }, - { label: 'مدیریت کاربران', icon: 'pi pi-users', to: '/users' }, - { label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' }, - { label: 'دوره‌های آموزشی', icon: 'pi pi-book', to: '/courses' }, - { label: 'کلاس‌ها', icon: 'pi pi-desktop', to: '/classes' }, - { label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' }, - { label: 'امور مالی و پرداخت‌ها', icon: 'pi pi-wallet', to: '/payments' }, - { label: 'اطلاعیه‌ها', icon: 'pi pi-bell', to: '/notifications' }, - { label: 'درخواست‌های تماس', icon: 'pi pi-comments', to: '/contact-inquiries' }, - { label: 'گزارش فعالیت‌ها', icon: 'pi pi-history', to: '/logs' }, - { label: 'نقش‌ها و دسترسی‌ها', icon: 'pi pi-shield', to: '/roles' } -]; +const menuGroups = computed(() => { + const groups = [ + { + key: 'main', + label: 'اصلی', + items: [ + { label: 'داشبورد اصلی', icon: 'pi pi-home', to: '/' } + ] + }, + { + key: 'education', + label: 'آموزش', + items: [ + { label: 'مدیریت کاربران', icon: 'pi pi-users', to: '/users' }, + { label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' }, + { label: 'دوره‌های آموزشی', icon: 'pi pi-book', to: '/courses' }, + { label: 'کلاس‌ها', icon: 'pi pi-desktop', to: '/classes' }, + { label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' } + ] + }, + { + key: 'finance', + label: 'مالی و ارتباطات', + items: [ + { label: 'امور مالی و پرداخت‌ها', icon: 'pi pi-wallet', to: '/payments' }, + { label: 'اطلاعیه‌ها', icon: 'pi pi-bell', to: '/notifications' }, + { label: 'درخواست‌های تماس', icon: 'pi pi-comments', to: '/contact-inquiries' } + ] + }, + { + key: 'system', + label: 'مدیریت سیستم', + items: [ + { label: 'گزارش فعالیت‌ها', icon: 'pi pi-history', to: '/logs', permission: PERMISSIONS.LOGS_READ }, + { label: 'نقش‌ها و دسترسی‌ها', icon: 'pi pi-shield', to: '/roles' } + ] + }, + { + key: 'account', + label: 'حساب کاربری', + items: [ + { label: 'رابط کاربری', icon: 'pi pi-palette', to: '/interface' } + ] + } + ]; -const menuItems = computed(() => { - const items = [...baseMenuItems]; - if (permissionStore.roleName === 'SuperAdmin') { - items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' }); + if (permissionStore.hasPermission(PERMISSIONS.SESSIONS_ATTENDANCE)) { + const educationGroup = groups.find((g) => g.key === 'education'); + educationGroup.items.push({ + label: 'حضور و غیاب', + icon: 'pi pi-check-square', + to: '/attendance' + }); } - return items; + + if (permissionStore.roleName === 'SuperAdmin') { + const systemGroup = groups.find((g) => g.key === 'system'); + systemGroup.items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' }); + } + + return groups + .map((group) => ({ + ...group, + items: group.items.filter((item) => !item.permission || permissionStore.hasPermission(item.permission)) + })) + .filter((group) => group.items.length > 0); }); function isMenuActive(to) { if (to === '/') { return route.path === '/'; } + if (to === '/attendance') { + return route.path === '/attendance' || route.path.startsWith('/sessions/attendance/'); + } return route.path === to || route.path.startsWith(`${to}/`); } @@ -67,20 +122,29 @@ function isMenuActive(to) { flex-shrink: 0; min-height: 100vh; } + +.menu-group-label { + letter-spacing: 0.04em; + opacity: 0.85; +} + .menu-item { color: var(--text-secondary); text-decoration: none; white-space: nowrap; flex-shrink: 0; + &:hover { background-color: var(--surface-hover); color: var(--text-primary); } + &.active-item { background-color: var(--primary-color-light); color: var(--primary-color); font-weight: 700; } + span { white-space: nowrap; } diff --git a/src/components/layout/AppTopbar.vue b/src/components/layout/AppTopbar.vue index 3f017b3..44d3269 100644 --- a/src/components/layout/AppTopbar.vue +++ b/src/components/layout/AppTopbar.vue @@ -125,6 +125,13 @@ const menuItems = ref([ router.push('/profile'); } }, + { + label: 'رابط کاربری', + icon: 'pi pi-palette', + command: () => { + router.push('/interface'); + } + }, { label: t('app.logout'), icon: 'pi pi-sign-out', diff --git a/src/router/routes.js b/src/router/routes.js index eba8cbd..750856b 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -136,6 +136,19 @@ export const routes = [ name: 'AttendanceEntry', component: () => import('@/views/attendances/AttendanceEntryView.vue') }, + { + path: 'attendance', + name: 'AttendanceList', + component: () => import('@/views/attendances/AttendanceListView.vue'), + meta: { title: 'حضور و غیاب', permission: 'sessions:attendance' } + }, + + { + path: 'interface', + name: 'InterfaceSettings', + component: () => import('@/views/interface/InterfaceSettingsView.vue'), + meta: { title: 'رابط کاربری' } + }, // Payments { diff --git a/src/stores/authStore.js b/src/stores/authStore.js index 71f30b5..77f986c 100644 --- a/src/stores/authStore.js +++ b/src/stores/authStore.js @@ -4,6 +4,7 @@ import { ref, computed } from 'vue'; import { authApi } from '@/api/authApi'; import Cookies from 'js-cookie'; import { usePermissionStore } from './permissionStore'; +import { useThemeStore } from './themeStore'; export const useAuthStore = defineStore('auth', () => { const accessToken = ref(localStorage.getItem('accessToken') || Cookies.get('accessToken') || ''); @@ -53,6 +54,9 @@ export const useAuthStore = defineStore('auth', () => { const roleName = data.user?.role?.name || data.role || ''; permissionStore.setPermissions(perms, roleName); + const themeStore = useThemeStore(); + themeStore.loadFromUser(data.user); + return data; } diff --git a/src/stores/permissionStore.js b/src/stores/permissionStore.js index ca630d4..e5b8c7f 100644 --- a/src/stores/permissionStore.js +++ b/src/stores/permissionStore.js @@ -2,6 +2,8 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; import { authApi } from '@/api/authApi'; +import { useAuthStore } from './authStore'; +import { useThemeStore } from './themeStore'; export const usePermissionStore = defineStore('permission', () => { const permissions = ref(JSON.parse(localStorage.getItem('permissions') || '[]')); @@ -31,6 +33,13 @@ export const usePermissionStore = defineStore('permission', () => { const perms = userData.role?.permissions || []; const role = userData.role?.name || ''; setPermissions(perms, role); + + const authStore = useAuthStore(); + authStore.setUser(userData); + + const themeStore = useThemeStore(); + themeStore.loadFromUser(userData); + return permissions.value; } catch (err) { console.error('Failed to fetch user permissions:', err); diff --git a/src/stores/themeStore.js b/src/stores/themeStore.js index 1bef0ba..26803fb 100644 --- a/src/stores/themeStore.js +++ b/src/stores/themeStore.js @@ -1,35 +1,153 @@ // /src/stores/themeStore.js import { defineStore } from 'pinia'; import { ref } from 'vue'; +import { authApi } from '@/api/authApi'; + +const FONT_SIZE_VALUES = { + small: '12px', + medium: '14px', + large: '16px' +}; + +const STORAGE_KEY = 'uiPreferences'; + +function readStoredPreferences() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writeStoredPreferences(preferences) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); +} export const useThemeStore = defineStore('theme', () => { - const savedTheme = localStorage.getItem('theme'); - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - const isDarkMode = ref(savedTheme ? savedTheme === 'dark' : prefersDark); + const stored = readStoredPreferences(); + const themePreference = ref(stored?.theme || 'system'); + const fontSize = ref(stored?.fontSize || 'medium'); + const isDarkMode = ref(false); + let systemThemeListener = null; + + function resolveDarkMode() { + if (themePreference.value === 'dark') return true; + if (themePreference.value === 'light') return false; + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; + } function applyTheme() { + isDarkMode.value = resolveDarkMode(); const htmlEl = document.documentElement; if (isDarkMode.value) { htmlEl.classList.add('dark-mode', 'dark'); - localStorage.setItem('theme', 'dark'); } else { htmlEl.classList.remove('dark-mode', 'dark'); - localStorage.setItem('theme', 'light'); } } - function toggleTheme() { - isDarkMode.value = !isDarkMode.value; + function applyFontSize() { + const size = FONT_SIZE_VALUES[fontSize.value] || FONT_SIZE_VALUES.medium; + document.documentElement.style.setProperty('--app-font-size', size); + document.documentElement.dataset.fontSize = fontSize.value; + } + + function persistLocal() { + writeStoredPreferences({ + theme: themePreference.value, + fontSize: fontSize.value + }); + } + + function applyPreferences() { applyTheme(); + applyFontSize(); + persistLocal(); + } + + function setupSystemThemeListener() { + if (!window.matchMedia) return; + if (systemThemeListener) { + window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', systemThemeListener); + } + systemThemeListener = () => { + if (themePreference.value === 'system') { + applyTheme(); + } + }; + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', systemThemeListener); + } + + function loadFromUser(user) { + if (!user?.preferences) return; + if (user.preferences.theme) { + themePreference.value = user.preferences.theme; + } + if (user.preferences.fontSize) { + fontSize.value = user.preferences.fontSize; + } + applyPreferences(); + } + + function toggleTheme() { + themePreference.value = isDarkMode.value ? 'light' : 'dark'; + applyPreferences(); + saveToServer(); + } + + function setThemePreference(value) { + themePreference.value = value; + applyPreferences(); + } + + function setFontSize(value) { + fontSize.value = value; + applyFontSize(); + persistLocal(); + } + + async function saveToServer() { + try { + const res = await authApi.updateSelf({ + preferences: { + theme: themePreference.value, + fontSize: fontSize.value + } + }); + const user = res.data || res; + return user; + } catch (err) { + console.warn('Failed to save UI preferences:', err); + throw err; + } } function initTheme() { - applyTheme(); + applyPreferences(); + setupSystemThemeListener(); } return { isDarkMode, + themePreference, + fontSize, + fontSizeOptions: [ + { label: 'کوچک', value: 'small', sample: '۱۲px' }, + { label: 'متوسط', value: 'medium', sample: '۱۴px' }, + { label: 'بزرگ', value: 'large', sample: '۱۶px' } + ], + themeOptions: [ + { label: 'روشن', value: 'light', icon: 'pi pi-sun' }, + { label: 'تیره', value: 'dark', icon: 'pi pi-moon' }, + { label: 'سیستم', value: 'system', icon: 'pi pi-desktop' } + ], toggleTheme, - initTheme + setThemePreference, + setFontSize, + loadFromUser, + saveToServer, + initTheme, + applyPreferences }; }); diff --git a/src/views/attendances/AttendanceListView.vue b/src/views/attendances/AttendanceListView.vue new file mode 100644 index 0000000..8baf35f --- /dev/null +++ b/src/views/attendances/AttendanceListView.vue @@ -0,0 +1,193 @@ + + + + diff --git a/src/views/dashboard/DashboardHomeView.vue b/src/views/dashboard/DashboardHomeView.vue index 5965af9..29d978b 100644 --- a/src/views/dashboard/DashboardHomeView.vue +++ b/src/views/dashboard/DashboardHomeView.vue @@ -109,6 +109,58 @@ + + +
+
+
+

حضور و غیاب

+

جلسات اخیر که نیاز به ثبت حضور دارند

+
+
+ + + + + + + + + + + + + + + + + + +
+
+
@@ -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(); }); diff --git a/src/views/interface/InterfaceSettingsView.vue b/src/views/interface/InterfaceSettingsView.vue new file mode 100644 index 0000000..f9a39d6 --- /dev/null +++ b/src/views/interface/InterfaceSettingsView.vue @@ -0,0 +1,184 @@ + + + + + +