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 @@ + + +جلسات اخیر که نیاز به ثبت حضور دارند
++ حالت پیشفرض رنگبندی برنامه را برای این حساب انتخاب کنید. با «سیستم»، تنظیمات دستگاه شما اعمال میشود. +
++ اندازه متن کل برنامه را برای خوانایی بهتر تنظیم کنید. +
+