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:
@@ -10,13 +10,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { useThemeStore } from '@/stores/themeStore';
|
import { useThemeStore } from '@/stores/themeStore';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
import ConfirmDialog from 'primevue/confirmdialog';
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
|
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
themeStore.initTheme();
|
themeStore.initTheme();
|
||||||
|
if (authStore.user) {
|
||||||
|
themeStore.loadFromUser(authStore.user);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -6,5 +6,6 @@ export const authApi = {
|
|||||||
refresh: (refreshToken) => axiosInstance.post('/auth/refresh', { refreshToken }),
|
refresh: (refreshToken) => axiosInstance.post('/auth/refresh', { refreshToken }),
|
||||||
logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }),
|
logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }),
|
||||||
changePassword: (data) => axiosInstance.put('/auth/change-password', data),
|
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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
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);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-list flex-grow-1 overflow-y-auto">
|
<div class="menu-list flex-grow-1 overflow-y-auto">
|
||||||
|
<div v-for="group in menuGroups" :key="group.key" class="menu-group mb-3">
|
||||||
|
<div class="menu-group-label px-3 py-2 text-xs font-bold text-muted uppercase">
|
||||||
|
{{ group.label }}
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in menuItems"
|
v-for="item in group.items"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="menu-item flex align-items-center gap-3 px-3 py-2 border-round text-color mb-1 transition-colors"
|
class="menu-item flex align-items-center gap-3 px-3 py-2 border-round text-color mb-1 transition-colors"
|
||||||
@@ -19,42 +23,93 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { usePermissionStore } from '@/stores/permissionStore';
|
import { usePermissionStore } from '@/stores/permissionStore';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const permissionStore = usePermissionStore();
|
const permissionStore = usePermissionStore();
|
||||||
|
|
||||||
const baseMenuItems = [
|
const menuGroups = computed(() => {
|
||||||
{ label: 'داشبورد اصلی', icon: 'pi pi-home', to: '/' },
|
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-users', to: '/users' },
|
||||||
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
|
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
|
||||||
{ label: 'دورههای آموزشی', icon: 'pi pi-book', to: '/courses' },
|
{ label: 'دورههای آموزشی', icon: 'pi pi-book', to: '/courses' },
|
||||||
{ label: 'کلاسها', icon: 'pi pi-desktop', to: '/classes' },
|
{ label: 'کلاسها', icon: 'pi pi-desktop', to: '/classes' },
|
||||||
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' },
|
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance',
|
||||||
|
label: 'مالی و ارتباطات',
|
||||||
|
items: [
|
||||||
{ label: 'امور مالی و پرداختها', icon: 'pi pi-wallet', to: '/payments' },
|
{ label: 'امور مالی و پرداختها', icon: 'pi pi-wallet', to: '/payments' },
|
||||||
{ label: 'اطلاعیهها', icon: 'pi pi-bell', to: '/notifications' },
|
{ label: 'اطلاعیهها', icon: 'pi pi-bell', to: '/notifications' },
|
||||||
{ label: 'درخواستهای تماس', icon: 'pi pi-comments', to: '/contact-inquiries' },
|
{ label: 'درخواستهای تماس', icon: 'pi pi-comments', to: '/contact-inquiries' }
|
||||||
{ label: 'گزارش فعالیتها', icon: 'pi pi-history', to: '/logs' },
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
label: 'مدیریت سیستم',
|
||||||
|
items: [
|
||||||
|
{ label: 'گزارش فعالیتها', icon: 'pi pi-history', to: '/logs', permission: PERMISSIONS.LOGS_READ },
|
||||||
{ label: 'نقشها و دسترسیها', icon: 'pi pi-shield', to: '/roles' }
|
{ label: 'نقشها و دسترسیها', icon: 'pi pi-shield', to: '/roles' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'account',
|
||||||
|
label: 'حساب کاربری',
|
||||||
|
items: [
|
||||||
|
{ label: 'رابط کاربری', icon: 'pi pi-palette', to: '/interface' }
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
if (permissionStore.hasPermission(PERMISSIONS.SESSIONS_ATTENDANCE)) {
|
||||||
const items = [...baseMenuItems];
|
const educationGroup = groups.find((g) => g.key === 'education');
|
||||||
if (permissionStore.roleName === 'SuperAdmin') {
|
educationGroup.items.push({
|
||||||
items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' });
|
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) {
|
function isMenuActive(to) {
|
||||||
if (to === '/') {
|
if (to === '/') {
|
||||||
return route.path === '/';
|
return route.path === '/';
|
||||||
}
|
}
|
||||||
|
if (to === '/attendance') {
|
||||||
|
return route.path === '/attendance' || route.path.startsWith('/sessions/attendance/');
|
||||||
|
}
|
||||||
return route.path === to || route.path.startsWith(`${to}/`);
|
return route.path === to || route.path.startsWith(`${to}/`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -67,20 +122,29 @@ function isMenuActive(to) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-group-label {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active-item {
|
&.active-item {
|
||||||
background-color: var(--primary-color-light);
|
background-color: var(--primary-color-light);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ const menuItems = ref([
|
|||||||
router.push('/profile');
|
router.push('/profile');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'رابط کاربری',
|
||||||
|
icon: 'pi pi-palette',
|
||||||
|
command: () => {
|
||||||
|
router.push('/interface');
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('app.logout'),
|
label: t('app.logout'),
|
||||||
icon: 'pi pi-sign-out',
|
icon: 'pi pi-sign-out',
|
||||||
|
|||||||
@@ -136,6 +136,19 @@ export const routes = [
|
|||||||
name: 'AttendanceEntry',
|
name: 'AttendanceEntry',
|
||||||
component: () => import('@/views/attendances/AttendanceEntryView.vue')
|
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
|
// Payments
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ref, computed } from 'vue';
|
|||||||
import { authApi } from '@/api/authApi';
|
import { authApi } from '@/api/authApi';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { usePermissionStore } from './permissionStore';
|
import { usePermissionStore } from './permissionStore';
|
||||||
|
import { useThemeStore } from './themeStore';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessToken = ref(localStorage.getItem('accessToken') || Cookies.get('accessToken') || '');
|
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 || '';
|
const roleName = data.user?.role?.name || data.role || '';
|
||||||
permissionStore.setPermissions(perms, roleName);
|
permissionStore.setPermissions(perms, roleName);
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
themeStore.loadFromUser(data.user);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { authApi } from '@/api/authApi';
|
import { authApi } from '@/api/authApi';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { useThemeStore } from './themeStore';
|
||||||
|
|
||||||
export const usePermissionStore = defineStore('permission', () => {
|
export const usePermissionStore = defineStore('permission', () => {
|
||||||
const permissions = ref(JSON.parse(localStorage.getItem('permissions') || '[]'));
|
const permissions = ref(JSON.parse(localStorage.getItem('permissions') || '[]'));
|
||||||
@@ -31,6 +33,13 @@ export const usePermissionStore = defineStore('permission', () => {
|
|||||||
const perms = userData.role?.permissions || [];
|
const perms = userData.role?.permissions || [];
|
||||||
const role = userData.role?.name || '';
|
const role = userData.role?.name || '';
|
||||||
setPermissions(perms, role);
|
setPermissions(perms, role);
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
authStore.setUser(userData);
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
themeStore.loadFromUser(userData);
|
||||||
|
|
||||||
return permissions.value;
|
return permissions.value;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch user permissions:', err);
|
console.error('Failed to fetch user permissions:', err);
|
||||||
|
|||||||
+127
-9
@@ -1,35 +1,153 @@
|
|||||||
// /src/stores/themeStore.js
|
// /src/stores/themeStore.js
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
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', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const stored = readStoredPreferences();
|
||||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const themePreference = ref(stored?.theme || 'system');
|
||||||
const isDarkMode = ref(savedTheme ? savedTheme === 'dark' : prefersDark);
|
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() {
|
function applyTheme() {
|
||||||
|
isDarkMode.value = resolveDarkMode();
|
||||||
const htmlEl = document.documentElement;
|
const htmlEl = document.documentElement;
|
||||||
if (isDarkMode.value) {
|
if (isDarkMode.value) {
|
||||||
htmlEl.classList.add('dark-mode', 'dark');
|
htmlEl.classList.add('dark-mode', 'dark');
|
||||||
localStorage.setItem('theme', 'dark');
|
|
||||||
} else {
|
} else {
|
||||||
htmlEl.classList.remove('dark-mode', 'dark');
|
htmlEl.classList.remove('dark-mode', 'dark');
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function toggleTheme() {
|
||||||
isDarkMode.value = !isDarkMode.value;
|
themePreference.value = isDarkMode.value ? 'light' : 'dark';
|
||||||
applyTheme();
|
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() {
|
function initTheme() {
|
||||||
applyTheme();
|
applyPreferences();
|
||||||
|
setupSystemThemeListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDarkMode,
|
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,
|
toggleTheme,
|
||||||
initTheme
|
setThemePreference,
|
||||||
|
setFontSize,
|
||||||
|
loadFromUser,
|
||||||
|
saveToServer,
|
||||||
|
initTheme,
|
||||||
|
applyPreferences
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
</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 ─────────────────────────────────────────────────── -->
|
<!-- ── Recent Sessions ─────────────────────────────────────────────────── -->
|
||||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
|
<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">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
@@ -167,8 +219,12 @@
|
|||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { dashboardApi } from '@/api/dashboardApi';
|
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 PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Tag from 'primevue/tag';
|
import Tag from 'primevue/tag';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
@@ -176,9 +232,12 @@ import Column from 'primevue/column';
|
|||||||
import ProgressSpinner from 'primevue/progressspinner';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
|
const permissionStore = usePermissionStore();
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const isAttendanceLoading = ref(false);
|
||||||
|
const attendanceSessions = ref([]);
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
|
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
|
||||||
recentSessions: [],
|
recentSessions: [],
|
||||||
@@ -321,6 +380,16 @@ const quickLinks = computed(() => [
|
|||||||
badge: stats.value.totals.sessions,
|
badge: stats.value.totals.sessions,
|
||||||
badgeSeverity: 'warning'
|
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',
|
to: '/payments',
|
||||||
label: 'امور مالی',
|
label: 'امور مالی',
|
||||||
@@ -339,7 +408,31 @@ const quickLinks = computed(() => [
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const hasAttendance = (session) => Array.isArray(session.attendanceList) && session.attendanceList.length > 0;
|
||||||
|
|
||||||
// ── Load Data ─────────────────────────────────────────────────────────────────
|
// ── 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 () => {
|
const loadStats = async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -357,6 +450,7 @@ const loadStats = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats();
|
loadStats();
|
||||||
|
loadAttendanceSessions();
|
||||||
});
|
});
|
||||||
</script>
|
</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