Initial commit: admin dashboard for GameNo.

Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit 1a34c3b133
94 changed files with 12016 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
npm-debug.log
.git
.env
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5000/api
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+13
View File
@@ -0,0 +1,13 @@
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+90
View File
@@ -0,0 +1,90 @@
# Gameno Dashboard - Persian Teaching Institution Admin Dashboard
A complete, production-ready admin dashboard built with Vue 3 (Composition API), PrimeVue 4, PrimeFlex, Pinia, Vue Router 4, vue-i18n, and Axios. Designed specifically for managing an educational institution in Persian (RTL layout) with role-based access control (RBAC).
---
## 🚀 Setup and Installation
### Prerequisites
- Node.js v18+ and npm v9+
### Commands
```bash
# 1. Install dependencies
npm install
# 2. Run local development server
npm run dev
# 3. Build production bundle
npm run build
# 4. Preview production build
npm run preview
```
---
## 🗺️ Route Permission Mapping
All routes enforce client-side permission checks via global navigation guards (`/src/router/guards.js`) and UI element visibility gates (`<PermissionGate>`).
| Route Path | View Component | Required Permission | Description |
| :--- | :--- | :--- | :--- |
| `/login` | `LoginView.vue` | Public | Admin login page |
| `/` | `DashboardHomeView.vue` | Authenticated | Dashboard home statistics & shortcuts |
| `/users` | `UserListView.vue` | `users:read` | List students & staff |
| `/users/create` | `UserFormView.vue` | `users:create` | Create new user |
| `/users/edit/:id` | `UserFormView.vue` | `users:update` | Edit user profile |
| `/users/:id` | `UserDetailView.vue` | `users:read` | Profile summary, courses, payments, certificates |
| `/professors` | `ProfessorListView.vue` | `professors:read` | List instructors |
| `/professors/create` | `ProfessorFormView.vue` | `professors:create` | Add instructor |
| `/professors/edit/:id` | `ProfessorFormView.vue` | `professors:update` | Edit instructor |
| `/courses` | `CourseListView.vue` | `courses:read` | List courses |
| `/courses/create` | `CourseFormView.vue` | `courses:create` | Create course & auto/manual session generator |
| `/courses/edit/:id` | `CourseFormView.vue` | `courses:update` | Edit course & sessions |
| `/courses/:id` | `CourseDetailView.vue` | `courses:read` | Course details & sessions overview |
| `/sessions` | `SessionListView.vue` | `sessions:read` | List scheduled sessions |
| `/sessions/create` | `SessionFormView.vue` | `sessions:create` | Schedule single session |
| `/sessions/edit/:id` | `SessionFormView.vue` | `sessions:update` | Edit session details |
| `/attendances/session/:id` | `AttendanceEntryView.vue` | `sessions:attendance` | Bulk session attendance entry |
| `/payments` | `PaymentListView.vue` | `payments:read` | Payment ledgers & invoices |
| `/payments/:id` | `PaymentDetailView.vue` | `payments:read` | Payment detail & record transactions |
| `/roles` | `RoleListView.vue` | `roles:read` | List custom roles |
| `/roles/create` | `RoleFormView.vue` | `roles:create` | Create role & permission matrix |
| `/roles/edit/:id` | `RoleFormView.vue` | `roles:update` | Edit role permissions |
| `/notifications` | `NotificationListView.vue` | `notifications:read` | Delivery log & manual retry |
| `/forbidden` | `ForbiddenView.vue` | Authenticated | 403 Forbidden page |
| `/*` | `NotFoundView.vue` | Public | 404 Not Found page |
---
## ⚙️ Automatic Session Date Generation Algorithm
In `CourseFormView.vue` (Automatic Tab):
1. **Inputs**: Admin specifies requested total sessions count (e.g. `12`), start date in Jalali Shamsi format (e.g. `1403/01/01`), start/end time, and selected weekdays (e.g. Saturday = 6, Tuesday = 2).
2. **Rolling Calendar Loop**:
- Converts the start date into a `moment` instance in Jalali locale.
- Iteratively increments day by day.
- Checks if `currentMoment.day()` matches any of the chosen weekdays.
- When matched, increments session counter and pushes a preview row object (`sessionNumber`, `date` formatted in Jalali `YYYY/MM/DD`, `startTime`, `endTime`, `topic`).
3. **Interactive Preview**: Renders an inline editable data table so the admin can review or manually adjust individual row dates or topics before final submission.
4. **Bulk Dispatch**: Sends the generated session array to `POST /sessions/admin/create` in a single API request.
---
## 📝 Key Architecture Assumptions
1. **Authentication & Token Storage**:
- JWT `accessToken` and `refreshToken` are stored in `localStorage` and synchronized with HTTP cookies via `js-cookie`.
- Axios request interceptor attaches `Authorization: Bearer <accessToken>`.
- On `401 Unauthorized` responses, Axios automatically triggers silent token renewal calling `POST /auth/refresh`.
2. **Permission Store Rehydration**:
- Permissions array is loaded once on login from `response.user.role.permissions`.
- On hard page refresh, if a valid token exists but Pinia store is unpopulated, `guards.js` triggers `GET /users/user/get-self` once to rehydrate permissions.
3. **Certificate Upload Flow**:
- Uses two-stage S3/MinIO upload strategy.
- `ImageManager.vue` uploads binary file to `POST /files/admin/upload-temp` returning a temporary filename (`tempFileName`).
- Committing a certificate form dispatches `tempFileName` to `/certificates/user/upload`, moving the file to persistent storage on the backend.
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گام نو</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+3439
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "gameno-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/vazirmatn": "^5.3.0",
"@primevue/themes": "4.0.7",
"axios": "^1.19.0",
"filepond": "^4.32.12",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-preview": "^4.6.12",
"jalali-moment": "^3.3.11",
"js-cookie": "^3.0.8",
"photoswipe": "^5.4.4",
"pinia": "^4.0.2",
"primeflex": "^4.0.0",
"primeicons": "^8.0.0",
"primevue": "4.0.7",
"sass": "^1.102.0",
"vee-validate": "^4.15.1",
"vue": "^3.5.40",
"vue-filepond": "^8.0.0",
"vue-i18n": "^11.4.8",
"vue-router": "^5.2.0",
"vue3-persian-datetime-picker": "^1.2.2",
"yup": "^1.7.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"vite": "^8.2.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+27
View File
@@ -0,0 +1,27 @@
<!-- /src/App.vue -->
<template>
<div id="app-root">
<Toast />
<ConfirmDialog />
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { useThemeStore } from '@/stores/themeStore';
import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog';
const themeStore = useThemeStore();
onMounted(() => {
themeStore.initTheme();
});
</script>
<style lang="scss">
#app-root {
min-height: 100vh;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
// /src/api/activityLogApi.js
import axiosInstance from './axiosInstance';
export const activityLogApi = {
getAll: (params) => axiosInstance.get('/activity-logs/admin/get-all', { params }),
getActions: () => axiosInstance.get('/activity-logs/admin/actions')
};
+8
View File
@@ -0,0 +1,8 @@
// /src/api/attendanceApi.js
import axiosInstance from './axiosInstance';
export const attendanceApi = {
recordSessionAttendance: (sessionId, attendanceList) =>
axiosInstance.put(`/sessions/admin/${sessionId}/attendance`, { attendanceList }),
getSessionAttendance: (sessionId) => axiosInstance.get(`/sessions/admin/get-one/${sessionId}`)
};
+9
View File
@@ -0,0 +1,9 @@
// /src/api/authApi.js
import axiosInstance from './axiosInstance';
export const authApi = {
login: (data) => axiosInstance.post('/auth/login', data),
refresh: (refreshToken) => axiosInstance.post('/auth/refresh', { refreshToken }),
logout: (refreshToken) => axiosInstance.post('/auth/logout', { refreshToken }),
getSelf: () => axiosInstance.get('/users/user/get-self')
};
+121
View File
@@ -0,0 +1,121 @@
// /src/api/axiosInstance.js
import axios from 'axios';
import Cookies from 'js-cookie';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
timeout: 15000
});
// Request interceptor: attach token
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken') || Cookies.get('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Ask backend for Persian (or stored) localized error messages
const lang = localStorage.getItem('lang') || 'fa';
config.headers['Accept-Language'] = lang;
if (!config.params) config.params = {};
if (config.params.lang == null) {
config.params.lang = lang;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: auto-refresh token on 401
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
axiosInstance.interceptors.response.use(
(response) => response.data,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.url.includes('/auth/login') || originalRequest.url.includes('/auth/refresh')) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return axiosInstance(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken') || Cookies.get('refreshToken');
if (!refreshToken) {
isRefreshing = false;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(error);
}
try {
const refreshResponse = await axios.post(`${API_BASE_URL}/auth/refresh`, { refreshToken });
const data = refreshResponse.data?.data || refreshResponse.data;
const newAccessToken = data.accessToken;
const newRefreshToken = data.refreshToken;
localStorage.setItem('accessToken', newAccessToken);
Cookies.set('accessToken', newAccessToken);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
Cookies.set('refreshToken', newRefreshToken);
}
axiosInstance.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
processQueue(null, newAccessToken);
return axiosInstance(originalRequest);
} catch (refreshErr) {
processQueue(refreshErr, null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default axiosInstance;
+16
View File
@@ -0,0 +1,16 @@
// /src/api/certificateApi.js
import axiosInstance from './axiosInstance';
export const certificateApi = {
getAll: (params) => axiosInstance.get('/certificates/admin/get-all', { params }),
search: (params) => axiosInstance.get('/certificates/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/certificates/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/certificates/admin/create', data),
update: (id, data) => axiosInstance.put(`/certificates/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/certificates/admin/delete/${id}`),
uploadTemp: (formData) => axiosInstance.post('/files/admin/upload-temp', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
userUpload: (data) => axiosInstance.post('/certificates/user/upload', data),
getUserCertificates: () => axiosInstance.get('/certificates/user/my-certificates')
};
+12
View File
@@ -0,0 +1,12 @@
// /src/api/classApi.js
import axiosInstance from './axiosInstance';
export const classApi = {
getAll: (params) => axiosInstance.get('/classes/admin/get-all', { params }),
search: (params) => axiosInstance.get('/classes/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/classes/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/classes/admin/create', data),
update: (id, data) => axiosInstance.put(`/classes/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/classes/admin/delete/${id}`),
registerUsers: (classId, userIds) => axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds })
};
+7
View File
@@ -0,0 +1,7 @@
import axiosInstance from './axiosInstance';
export const contactInquiryApi = {
getAll: (params) => axiosInstance.get('/contact-inquiries/admin/get-all', { params }),
getOne: (id) => axiosInstance.get(`/contact-inquiries/admin/get-one/${id}`),
update: (id, data) => axiosInstance.put(`/contact-inquiries/admin/update/${id}`, data)
};
+11
View File
@@ -0,0 +1,11 @@
// /src/api/courseApi.js
import axiosInstance from './axiosInstance';
export const courseApi = {
getAll: (params) => axiosInstance.get('/courses/admin/get-all', { params }),
search: (params) => axiosInstance.get('/courses/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/courses/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/courses/admin/create', data),
update: (id, data) => axiosInstance.put(`/courses/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/courses/admin/delete/${id}`)
};
+6
View File
@@ -0,0 +1,6 @@
// /src/api/dashboardApi.js
import axiosInstance from './axiosInstance';
export const dashboardApi = {
getStats: () => axiosInstance.get('/dashboard/admin/stats')
};
+12
View File
@@ -0,0 +1,12 @@
// /src/api/notificationApi.js
import axiosInstance from './axiosInstance';
export const notificationApi = {
getAll: (params) => axiosInstance.get('/notifications/admin/get-all', { params }),
search: (params) => axiosInstance.get('/notifications/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/notifications/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/notifications/admin/create', data),
update: (id, data) => axiosInstance.put(`/notifications/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/notifications/admin/delete/${id}`),
retry: (id) => axiosInstance.post(`/notifications/admin/${id}/retry`)
};
+12
View File
@@ -0,0 +1,12 @@
// /src/api/paymentApi.js
import axiosInstance from './axiosInstance';
export const paymentApi = {
getAll: (params) => axiosInstance.get('/payments/admin/get-all', { params }),
search: (params) => axiosInstance.get('/payments/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/payments/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/payments/admin/create', data),
update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`),
recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/user/pay/${paymentId}`, data)
};
+11
View File
@@ -0,0 +1,11 @@
// /src/api/professorApi.js
import axiosInstance from './axiosInstance';
export const professorApi = {
getAll: (params) => axiosInstance.get('/professors/admin/get-all', { params }),
search: (params) => axiosInstance.get('/professors/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/professors/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/professors/admin/create', data),
update: (id, data) => axiosInstance.put(`/professors/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/professors/admin/delete/${id}`)
};
+11
View File
@@ -0,0 +1,11 @@
// /src/api/roleApi.js
import axiosInstance from './axiosInstance';
export const roleApi = {
getAll: (params) => axiosInstance.get('/roles/admin/get-all', { params }),
search: (params) => axiosInstance.get('/roles/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/roles/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/roles/admin/create', data),
update: (id, data) => axiosInstance.put(`/roles/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/roles/admin/delete/${id}`)
};
+14
View File
@@ -0,0 +1,14 @@
// /src/api/sessionApi.js
import axiosInstance from './axiosInstance';
export const sessionApi = {
getAll: (params) => axiosInstance.get('/sessions/admin/get-all', { params }),
search: (params) => axiosInstance.get('/sessions/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/sessions/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/sessions/admin/create', data),
update: (id, data) => axiosInstance.put(`/sessions/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/sessions/admin/delete/${id}`),
bulkDelete: (ids) => axiosInstance.post('/sessions/admin/bulk-delete', { ids }),
bulkUpdateStatus: (ids, status) => axiosInstance.post('/sessions/admin/bulk-status', { ids, status }),
recordAttendance: (sessionId, data) => axiosInstance.put(`/sessions/admin/${sessionId}/attendance`, data)
};
+12
View File
@@ -0,0 +1,12 @@
// /src/api/userApi.js
import axiosInstance from './axiosInstance';
export const userApi = {
getAll: (params) => axiosInstance.get('/users/admin/get-all', { params }),
search: (params) => axiosInstance.get('/users/admin/search', { params }),
getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/users/admin/create', data),
update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/users/admin/delete/${id}`),
enroll: (userId, data) => axiosInstance.post(`/users/admin/${userId}/enroll`, data)
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+13
View File
@@ -0,0 +1,13 @@
// /src/assets/styles/dark-theme.scss
:root.dark-mode {
--bg-primary: #121212;
--surface-card: #1e1e1e;
--surface-hover: #2a2a2a;
--border-color: #333333;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--primary-color: #10b981;
--primary-color-hover: #059669;
--primary-color-light: rgba(16, 185, 129, 0.15);
}
+13
View File
@@ -0,0 +1,13 @@
// /src/assets/styles/light-theme.scss
:root {
--bg-primary: #f8fafc;
--surface-card: #ffffff;
--surface-hover: #f1f5f9;
--border-color: #e2e8f0;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--primary-color: #10b981;
--primary-color-hover: #059669;
--primary-color-light: rgba(16, 185, 129, 0.1);
}
+272
View File
@@ -0,0 +1,272 @@
// /src/assets/styles/main.scss
@import '@fontsource/vazirmatn/400.css';
@import '@fontsource/vazirmatn/500.css';
@import '@fontsource/vazirmatn/700.css';
@import './light-theme.scss';
@import './dark-theme.scss';
@import './rtl-overrides.scss';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
transition: background-color 0.2s ease, color 0.2s ease;
}
a {
color: var(--primary-color);
text-decoration: none;
&:hover {
color: var(--primary-color-hover);
}
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 3px;
&:hover {
background: var(--text-secondary);
}
}
/* ==========================================
Global Border Polish (Removing Harsh Outlines)
========================================== */
.border-color,
.border-1,
.border-top-1,
.border-bottom-1,
.border-left-1,
.border-right-1 {
border-color: var(--border-color) !important;
}
.surface-card,
.p-component,
.p-card,
.p-panel,
.p-datatable,
.p-datatable-header,
.p-datatable-thead > tr > th,
.p-datatable-tbody > tr > td,
.p-paginator,
.p-dialog,
.p-sidebar,
.p-menu,
.p-select,
.p-inputtext,
.p-button {
border-color: var(--border-color) !important;
}
/* ==========================================
Vue Persian Datetime Picker Styling & Theme
Theme & coloring rebuilt from scratch using CSS variables
========================================== */
.vpd-wrapper {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, sans-serif !important;
.vpd-container {
border-radius: 16px !important;
overflow: hidden !important;
box-shadow: var(--shadow-lg) !important;
border: 1px solid var(--border-color) !important;
background-color: var(--surface-card) !important;
}
.vpd-header {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-color-hover) 100%) !important;
color: #ffffff !important;
.vpd-year-label,
.vpd-year-label span {
color: rgba(255, 255, 255, 0.85) !important;
}
.vpd-date,
.vpd-date span {
color: #ffffff !important;
font-weight: 700 !important;
}
}
.vpd-content {
background-color: var(--surface-card) !important;
color: var(--text-primary) !important;
}
.vpd-controls {
color: var(--text-primary) !important;
button, span, i, svg, .vpd-month-label {
color: var(--text-primary) !important;
fill: var(--text-primary) !important;
}
button:hover {
background-color: var(--surface-hover) !important;
border-radius: 50%;
}
.vpd-month-label span {
border-color: var(--border-color) !important;
&:hover {
background-color: var(--surface-hover) !important;
}
}
}
.vpd-week {
.vpd-weekday {
color: var(--text-muted) !important;
font-weight: 600 !important;
}
}
.vpd-days {
.vpd-day {
color: var(--text-primary) !important;
.vpd-day-text {
color: var(--text-primary) !important;
font-weight: 500;
}
&:not([disabled="true"]):hover:not(.vpd-selected):not(.vpd-empty) {
.vpd-day-effect {
background-color: var(--surface-hover) !important;
opacity: 0.9 !important;
}
}
&.vpd-selected {
.vpd-day-effect {
background-color: var(--primary-color) !important;
opacity: 1 !important;
}
.vpd-day-text {
color: #ffffff !important;
font-weight: 700 !important;
}
}
&.vpd-range-first,
&.vpd-range-last,
&.vpd-range-between,
&.vpd-range-hover {
.vpd-day-effect {
background-color: var(--primary-color-light) !important;
opacity: 1 !important;
}
.vpd-day-text {
color: var(--primary-color) !important;
}
}
&[disabled="true"] {
.vpd-day-text {
color: var(--text-muted) !important;
opacity: 0.4 !important;
}
}
&.vpd-empty {
background: transparent !important;
}
}
}
.vpd-actions {
border-top: 1px solid var(--border-color) !important;
background-color: var(--surface-card) !important;
button {
color: var(--primary-color) !important;
font-weight: 700 !important;
font-family: inherit !important;
border-radius: 8px !important;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background-color: var(--primary-color-light) !important;
}
}
}
.vpd-addon-list {
background-color: var(--surface-card) !important;
border-color: var(--border-color) !important;
.vpd-addon-list-item {
color: var(--text-secondary) !important;
background-color: transparent !important;
&:hover {
background-color: var(--surface-hover) !important;
color: var(--text-primary) !important;
}
&.vpd-selected {
background-color: var(--primary-color-light) !important;
color: var(--primary-color) !important;
font-weight: 700 !important;
}
&[disabled="true"] {
color: var(--text-muted) !important;
opacity: 0.4 !important;
}
}
}
.vpd-close-addon {
color: var(--text-primary) !important;
background-color: var(--surface-hover) !important;
}
}
/* ==========================================
Global High-Contrast Hover & Selection States
========================================== */
.p-datatable-tbody > tr {
transition: background-color 0.15s ease;
&:hover {
background-color: var(--surface-hover) !important;
}
}
.p-select-option,
.p-dropdown-item {
transition: all 0.15s ease;
&:hover {
background-color: var(--primary-color-light) !important;
color: var(--primary-color) !important;
}
&.p-select-option-selected,
&.p-highlight {
background-color: var(--primary-color) !important;
color: #ffffff !important;
}
}
+158
View File
@@ -0,0 +1,158 @@
// /src/assets/styles/rtl-overrides.scss
html[dir="rtl"] {
body {
direction: rtl;
text-align: right;
}
/* PrimeVue DataTable RTL Fixes */
.p-datatable {
direction: rtl;
.p-datatable-header {
direction: rtl;
text-align: right;
}
.p-datatable-thead > tr > th {
text-align: right !important;
direction: rtl !important;
font-weight: 600;
.p-column-header-content {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.p-sortable-column-icon {
margin-left: 0;
margin-right: 0.5rem;
}
}
.p-datatable-tbody > tr > td {
text-align: right !important;
direction: rtl !important;
}
}
/* PrimeVue RTL Icon and Layout Fixes */
.p-iconfield {
direction: rtl;
.p-inputicon {
right: 0.85rem;
left: auto;
}
}
.p-paginator {
direction: rtl;
justify-content: center;
gap: 0.25rem;
.p-paginator-first,
.p-paginator-prev,
.p-paginator-next,
.p-paginator-last {
transform: scaleX(-1);
}
}
.p-breadcrumb ul {
direction: rtl;
li .p-breadcrumb-chevron {
transform: scaleX(-1);
}
}
.p-sidebar,
.p-dialog,
.p-menu,
.p-popover,
.p-select-option,
.p-select-overlay,
.p-toast {
direction: rtl;
text-align: right;
}
/* PrimeVue SelectButton RTL Fixes */
.p-selectbutton {
direction: rtl;
display: inline-flex;
max-width: 100%;
overflow-x: auto;
.p-togglebutton {
border-radius: 0 !important;
border-width: 1px 0 1px 1px !important;
text-align: center;
justify-content: center;
&:first-child {
border-right-width: 1px !important;
border-top-right-radius: var(--p-selectbutton-border-radius, 6px) !important;
border-bottom-right-radius: var(--p-selectbutton-border-radius, 6px) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
&:last-child {
border-left-width: 1px !important;
border-top-left-radius: var(--p-selectbutton-border-radius, 6px) !important;
border-bottom-left-radius: var(--p-selectbutton-border-radius, 6px) !important;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
&:focus-visible,
&.p-togglebutton-checked,
&.p-highlight {
position: relative;
z-index: 1;
}
}
}
/* PrimeVue ButtonGroup RTL Fixes */
.p-button-group {
direction: rtl;
display: inline-flex;
.p-button {
border-radius: 0 !important;
border-width: 1px 0 1px 1px !important;
&:first-child {
border-right-width: 1px !important;
border-top-right-radius: var(--p-button-border-radius, 6px) !important;
border-bottom-right-radius: var(--p-button-border-radius, 6px) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
&:last-child {
border-left-width: 1px !important;
border-top-left-radius: var(--p-button-border-radius, 6px) !important;
border-bottom-left-radius: var(--p-button-border-radius, 6px) !important;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
&:focus-visible,
&:active {
position: relative;
z-index: 1;
}
}
}
/* Vue FilePond RTL adjustment */
.filepond--root {
direction: rtl;
font-family: inherit;
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
@@ -0,0 +1,548 @@
<!-- Sessions management for a class: list existing, bulk create when empty, manual add at bottom -->
<template>
<div class="class-sessions-section flex flex-column gap-4">
<!-- Always show sessions list below students -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div>
<h2 class="text-lg font-bold m-0">جلسات کلاس</h2>
<p class="text-muted text-sm m-0 mt-1">جلسات برنامهریزیشده برای این کلاس</p>
</div>
<div class="flex align-items-center gap-2 flex-wrap">
<Tag :value="`${toPersianDigits(sessions.length)} جلسه`" severity="info" />
<template v-if="selectedSessions.length">
<Tag :value="`${toPersianDigits(selectedSessions.length)} انتخاب‌شده`" />
<Select
v-model="bulkStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="تغییر وضعیت"
class="text-sm"
style="min-width: 9rem"
/>
<Button
label="اعمال"
icon="pi pi-check"
size="small"
:loading="isBulkUpdating"
:disabled="!bulkStatus"
@click="applyBulkStatus"
/>
<Button
label="حذف"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="confirmBulkDelete"
/>
<Button label="لغو" text size="small" severity="secondary" @click="selectedSessions = []" />
</template>
</div>
</div>
<div v-if="loadingSessions" class="mb-2">
<TableSkeleton :rows="6" :columns="6" />
</div>
<DataTable
v-else
v-model:selection="selectedSessions"
:value="sessions"
dataKey="_id"
class="p-datatable-sm text-sm"
emptyMessage="جلسه‌ای ثبت نشده است"
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="موضوع">
<template #body="{ data }">{{ data.topic || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatJalali(data.day || data.date) }}</template>
</Column>
<Column header="زمان">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column header="وضعیت" style="width: 100px">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
<Column header="عملیات" style="width: 140px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-check-square"
text
rounded
size="small"
severity="success"
v-tooltip.top="'حضور و غیاب'"
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
v-tooltip.top="'ویرایش'"
@click="$router.push(`/sessions/edit/${data._id || data.id}`)"
/>
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'حذف'"
@click="confirmDeleteOne(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
:title="bulkDeleteMode ? 'حذف گروهی جلسات' : 'حذف جلسه'"
:message="deleteMessage"
@confirm="handleDeleteConfirm"
/>
<!-- Bulk auto-create: only when no sessions yet -->
<div v-if="!hasSessions" class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold text-color mb-1">ایجاد جلسات کلاس</h2>
<p class="text-muted text-sm mb-3">جلسات را بهصورت خودکار بر اساس روزهای هفته بسازید، یا از بخش دستی پایین صفحه استفاده کنید.</p>
<div class="grid">
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تعداد جلسات</label>
<InputNumber v-model="autoForm.sessionCount" :min="1" class="w-full text-sm" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label>
<DatePicker v-model="autoForm.startDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت شروع</label>
<InputText v-model="autoForm.startTime" class="w-full text-sm" placeholder="19:00" dir="ltr" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت پایان</label>
<InputText v-model="autoForm.endTime" class="w-full text-sm" placeholder="20:30" dir="ltr" />
</div>
<div class="col-12 flex flex-column gap-2 my-2">
<label class="font-semibold text-sm">روزهای هفته</label>
<SelectButton v-model="autoForm.selectedDays" :options="weekdays" optionLabel="label" optionValue="value" multiple class="text-sm" />
</div>
<div class="col-12">
<Button label="پیش‌نمایش و ساخت جلسات" icon="pi pi-cog" severity="help" @click="generateSessionsPreview" />
</div>
</div>
<div v-if="generatedSessions.length > 0" class="mt-4 pt-3 border-top-1 border-color">
<h3 class="text-md font-bold mb-3">پیشنمایش جلسات (قابل ویرایش)</h3>
<DataTable :value="generatedSessions" class="p-datatable-sm text-sm">
<Column field="sessionNumber" header="جلسه #" style="width: 70px">
<template #body="{ data }">{{ toPersianDigits(data.sessionNumber) }}</template>
</Column>
<Column field="date" header="تاریخ">
<template #body="{ data }">
<InputText v-model="data.date" class="w-full p-inputtext-sm text-sm" />
</template>
</Column>
<Column field="startTime" header="ساعت شروع">
<template #body="{ data }">
<InputText v-model="data.startTime" class="w-full p-inputtext-sm text-sm" dir="ltr" />
</template>
</Column>
<Column field="endTime" header="ساعت پایان">
<template #body="{ data }">
<InputText v-model="data.endTime" class="w-full p-inputtext-sm text-sm" dir="ltr" />
</template>
</Column>
<Column field="topic" header="موضوع جلسه">
<template #body="{ data }">
<InputText v-model="data.topic" class="w-full p-inputtext-sm text-sm" />
</template>
</Column>
</DataTable>
<div class="flex justify-content-end mt-3">
<Button label="ثبت نهایی جلسات" icon="pi pi-save" severity="success" :loading="isSaving" @click="submitGeneratedSessions" />
</div>
</div>
</div>
<!-- Manual create: always at the bottom -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold text-color mb-1">
{{ hasSessions ? 'افزودن جلسه دستی' : 'تعریف دستی جلسات' }}
</h2>
<p class="text-muted text-sm mb-3">
{{ hasSessions ? 'در صورت نیاز می‌توانید یک یا چند جلسه اضافه کنید.' : 'جلسات را یکی‌یکی تعریف کنید.' }}
</p>
<div v-for="(session, index) in manualSessions" :key="index" class="grid align-items-center mb-2 p-2 border-round surface-ground">
<div class="col-12 sm:col-1 text-center font-bold">#{{ toPersianDigits(index + 1) }}</div>
<div class="col-12 sm:col-3">
<InputText v-model="session.date" placeholder="تاریخ (1403/01/01)" class="w-full text-sm" />
</div>
<div class="col-6 sm:col-2">
<InputText v-model="session.startTime" placeholder="ساعت شروع" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-6 sm:col-2">
<InputText v-model="session.endTime" placeholder="ساعت پایان" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 sm:col-3">
<InputText v-model="session.topic" placeholder="موضوع جلسه" class="w-full text-sm" />
</div>
<div class="col-12 sm:col-1 text-center">
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
:disabled="manualSessions.length === 1"
@click="manualSessions.splice(index, 1)"
/>
</div>
</div>
<div class="flex align-items-center justify-content-between mt-3">
<Button
label="افزودن ردیف"
icon="pi pi-plus"
text
@click="manualSessions.push({ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' })"
/>
<Button label="ثبت جلسات" icon="pi pi-save" severity="success" :loading="isSaving" @click="submitManualSessions" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue';
import moment from 'jalali-moment';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import StatusTag from '@/components/common/StatusTag.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import Select from 'primevue/select';
import SelectButton from 'primevue/selectbutton';
import DatePicker from 'vue3-persian-datetime-picker';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const props = defineProps({
classId: { type: [String, Object], required: true },
courseId: { type: [String, Object], required: true },
professorId: { type: [String, Object], default: null },
defaultSessionCount: { type: Number, default: 12 }
});
const { toPersianDigits, toLatinDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const todayJalali = () => moment().locale('fa').format('jYYYY/jMM/jDD');
const sessions = ref([]);
const selectedSessions = ref([]);
const loadingSessions = ref(false);
const isSaving = ref(false);
const isDeleting = ref(false);
const isBulkUpdating = ref(false);
const deleteDialogVisible = ref(false);
const bulkDeleteMode = ref(false);
const sessionToDelete = ref(null);
const bulkStatus = ref(null);
const generatedSessions = ref([]);
const manualSessions = ref([{ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }]);
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزار شده', value: 'held' },
{ label: 'لغو شده', value: 'cancelled' }
];
const selectedIds = computed(() =>
selectedSessions.value.map((s) => s._id || s.id).filter(Boolean)
);
const deleteMessage = computed(() => {
if (bulkDeleteMode.value) {
return `آیا از حذف ${toPersianDigits(selectedSessions.value.length)} جلسه انتخاب‌شده اطمینان دارید؟ این عملیات قابل بازگشت نیست.`;
}
return 'آیا از حذف این جلسه اطمینان دارید؟ این عملیات قابل بازگشت نیست.';
});
const resolvedClassId = computed(() => {
const value = props.classId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const resolvedCourseId = computed(() => {
const value = props.courseId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const resolvedProfessorId = computed(() => {
const value = props.professorId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const hasSessions = computed(() => sessions.value.length > 0);
const normalizeList = (response) => {
if (!response) return [];
if (Array.isArray(response)) return response;
if (Array.isArray(response.data)) return response.data;
if (Array.isArray(response.items)) return response.items;
if (Array.isArray(response.sessions)) return response.sessions;
if (response.data && typeof response.data === 'object') {
if (Array.isArray(response.data.data)) return response.data.data;
if (Array.isArray(response.data.items)) return response.data.items;
if (Array.isArray(response.data.sessions)) return response.data.sessions;
}
return [];
};
const weekdays = [
{ label: 'شنبه', value: 6 },
{ label: 'یکشنبه', value: 0 },
{ label: 'دوشنبه', value: 1 },
{ label: 'سه‌شنبه', value: 2 },
{ label: 'چهارشنبه', value: 3 },
{ label: 'پنجشنبه', value: 4 },
{ label: 'جمعه', value: 5 }
];
const autoForm = reactive({
sessionCount: props.defaultSessionCount || 12,
startDate: todayJalali(),
startTime: '19:00',
endTime: '20:30',
selectedDays: [6, 2]
});
const toSessionDayIso = (value) => {
if (!value) return null;
const latin = toLatinDigits(String(value));
const m = moment(latin, ['jYYYY/jMM/jDD', 'YYYY/MM/DD', moment.ISO_8601], true);
return m.isValid() ? m.toDate().toISOString() : null;
};
const fetchSessions = async () => {
const classId = resolvedClassId.value;
if (!classId) return;
loadingSessions.value = true;
try {
// Use schema field `class` only. Sending classId alongside it makes older
// API builds add `{ classId: ... }` to the Mongo filter and return 0 rows.
const res = await sessionApi.getAll({
limit: 100,
class: classId,
sortBy: 'day',
sortOrder: 'asc'
});
sessions.value = normalizeList(res);
selectedSessions.value = [];
} catch (err) {
showError(err);
sessions.value = [];
} finally {
loadingSessions.value = false;
}
};
const confirmDeleteOne = (session) => {
bulkDeleteMode.value = false;
sessionToDelete.value = session;
deleteDialogVisible.value = true;
};
const confirmBulkDelete = () => {
if (!selectedSessions.value.length) return;
bulkDeleteMode.value = true;
sessionToDelete.value = null;
deleteDialogVisible.value = true;
};
const handleDeleteConfirm = async () => {
isDeleting.value = true;
try {
if (bulkDeleteMode.value) {
const res = await sessionApi.bulkDelete(selectedIds.value);
const deletedCount = res?.data?.deletedCount ?? selectedIds.value.length;
showSuccess(`${toPersianDigits(deletedCount)} جلسه حذف شد`);
selectedSessions.value = [];
} else if (sessionToDelete.value) {
await sessionApi.delete(sessionToDelete.value._id || sessionToDelete.value.id);
showSuccess('جلسه با موفقیت حذف شد');
}
deleteDialogVisible.value = false;
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
const applyBulkStatus = async () => {
if (!selectedIds.value.length || !bulkStatus.value) return;
isBulkUpdating.value = true;
try {
const res = await sessionApi.bulkUpdateStatus(selectedIds.value, bulkStatus.value);
const updatedCount = res?.data?.updatedCount ?? selectedIds.value.length;
showSuccess(`وضعیت ${toPersianDigits(updatedCount)} جلسه به‌روز شد`);
bulkStatus.value = null;
selectedSessions.value = [];
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isBulkUpdating.value = false;
}
};
const resetCreationForms = () => {
generatedSessions.value = [];
manualSessions.value = [{ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }];
};
const generateSessionsPreview = () => {
if (!autoForm.startDate) {
showError('لطفا تاریخ شروع را وارد کنید');
return;
}
if (!autoForm.selectedDays?.length) {
showError('لطفا حداقل یک روز از هفته را انتخاب کنید');
return;
}
const list = [];
const latinStartDate = toLatinDigits(autoForm.startDate);
let currentMoment = moment(latinStartDate, 'jYYYY/jMM/jDD');
let count = 0;
const targetCount = autoForm.sessionCount || 12;
while (count < targetCount) {
if (autoForm.selectedDays.includes(currentMoment.day())) {
count++;
list.push({
sessionNumber: count,
date: currentMoment.locale('fa').format('YYYY/MM/DD'),
startTime: autoForm.startTime,
endTime: autoForm.endTime,
topic: `جلسه ${count}`
});
}
currentMoment.add(1, 'day');
}
generatedSessions.value = list;
};
const ensureIds = () => {
if (!resolvedClassId.value) {
showError('ابتدا کلاس را ذخیره کنید');
return false;
}
if (!resolvedCourseId.value) {
showError('دوره مرتبط با کلاس مشخص نیست');
return false;
}
if (!resolvedProfessorId.value) {
showError('لطفا استاد کلاس را انتخاب و ذخیره کنید');
return false;
}
return true;
};
const submitGeneratedSessions = async () => {
if (!ensureIds()) return;
isSaving.value = true;
try {
await sessionApi.create({
courseId: resolvedCourseId.value,
classId: resolvedClassId.value,
professorId: resolvedProfessorId.value,
sessions: generatedSessions.value.map((s) => ({
topic: s.topic,
startTime: s.startTime,
endTime: s.endTime,
day: toSessionDayIso(s.date)
}))
});
showSuccess('جلسات کلاس با موفقیت ایجاد شدند');
resetCreationForms();
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
const submitManualSessions = async () => {
if (!ensureIds()) return;
const invalid = manualSessions.value.some((s) => !s.date || !s.startTime || !s.endTime);
if (invalid) {
showError('تاریخ و ساعت همه جلسات الزامی است');
return;
}
isSaving.value = true;
try {
await sessionApi.create({
courseId: resolvedCourseId.value,
classId: resolvedClassId.value,
professorId: resolvedProfessorId.value,
sessions: manualSessions.value.map((s) => ({
topic: s.topic,
startTime: s.startTime,
endTime: s.endTime,
day: toSessionDayIso(s.date)
}))
});
showSuccess('جلسات با موفقیت ثبت شدند');
resetCreationForms();
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
watch(
() => props.defaultSessionCount,
(val) => {
if (val && !hasSessions.value) autoForm.sessionCount = val;
}
);
watch(resolvedClassId, (id) => {
if (id) fetchSessions();
}, { immediate: true });
defineExpose({ fetchSessions });
</script>
@@ -0,0 +1,57 @@
<!-- /src/components/common/ConfirmDeleteDialog.vue -->
<template>
<Dialog
v-model:visible="visible"
modal
:header="title || 'تایید حذف'"
:style="{ width: '400px' }"
:closable="!loading"
>
<div class="flex align-items-center gap-3 py-2">
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
<span class="text-color text-sm">{{ message || 'آیا از حذف این مورد اطمینان دارید؟ این عملیات قابل بازگشت نیست.' }}</span>
</div>
<template #footer>
<div class="flex justify-content-end gap-2">
<Button label="انصراف" text class="p-button-secondary" :disabled="loading" @click="visible = false" />
<Button label="حذف نهایی" icon="pi pi-trash" severity="danger" :loading="loading" @click="onConfirm" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
message: {
type: String,
default: ''
},
loading: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const onConfirm = () => {
emit('confirm');
};
</script>
+188
View File
@@ -0,0 +1,188 @@
<!-- /src/components/common/DataTableWrapper.vue -->
<template>
<div class="data-table-wrapper surface-card p-4 border-round border-1 border-color shadow-sm">
<!-- Toolbar / Search / Filter Header -->
<div class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mb-4">
<div class="p-input-icon-right w-full sm:w-20rem">
<IconField iconPosition="right">
<InputIcon class="pi pi-search" />
<InputText
v-model="searchValue"
:placeholder="$t('app.search')"
class="w-full text-sm"
@input="onSearchInput"
/>
</IconField>
</div>
<div class="flex align-items-center gap-2 w-full sm:w-auto justify-content-end">
<slot name="toolbar" />
</div>
</div>
<!-- Skeleton loading -->
<TableSkeleton v-if="loading" :rows="skeletonRowCount" :columns="skeletonColumnCount" />
<!-- Data Table -->
<DataTable
v-else
:value="items"
:lazy="true"
:sortField="sortBy"
:sortOrder="sortOrder === 'asc' ? 1 : -1"
:selection="selection"
dataKey="_id"
responsiveLayout="scroll"
class="p-datatable-sm text-sm"
@sort="onSort"
@update:selection="onSelectionChange"
>
<template #empty>
<EmptyState />
</template>
<Column v-if="selectable" selectionMode="multiple" headerStyle="width: 3rem" />
<slot />
</DataTable>
<!-- Custom Paginator -->
<div
class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mt-4 pt-3 border-top-1 border-color"
v-if="totalCount > 0 || loading"
>
<span class="text-muted text-sm">
<template v-if="loading">در حال بارگذاری</template>
<template v-else>
نمایش {{ toPersianDigits(firstItemIndex) }} تا {{ toPersianDigits(lastItemIndex) }} از {{ toPersianDigits(totalCount) }} مورد
</template>
</span>
<Paginator
:rows="limit"
:totalRecords="Math.max(totalCount, 1)"
:first="(page - 1) * limit"
:rowsPerPageOptions="[10, 20, 50, 100]"
:disabled="loading"
@page="onPage"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Paginator from 'primevue/paginator';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import EmptyState from './EmptyState.vue';
import TableSkeleton from './TableSkeleton.vue';
const props = defineProps({
items: {
type: Array,
required: true
},
totalCount: {
type: Number,
default: 0
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
sortBy: {
type: String,
default: 'createdAt'
},
sortOrder: {
type: String,
default: 'desc'
},
loading: {
type: Boolean,
default: false
},
selectable: {
type: Boolean,
default: false
},
selection: {
type: Array,
default: () => []
},
skeletonColumns: {
type: Number,
default: 5
}
});
const emit = defineEmits(['page-change', 'sort-change', 'search-change', 'update:selection']);
const { toPersianDigits } = usePersianDate();
const searchValue = ref('');
const skeletonColumnCount = computed(() => Math.max(3, props.skeletonColumns || 5));
const skeletonRowCount = computed(() => Math.min(Math.max(props.limit || 20, 5), 12));
const onSelectionChange = (value) => {
emit('update:selection', value || []);
};
let searchTimer = null;
const onSearchInput = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
emit('search-change', searchValue.value);
}, 400);
};
const onSort = (event) => {
emit('sort-change', {
sortBy: event.sortField,
sortOrder: event.sortOrder === 1 ? 'asc' : 'desc'
});
};
const onPage = (event) => {
emit('page-change', {
page: event.page + 1,
limit: event.rows
});
};
const firstItemIndex = computed(() => {
if (props.totalCount === 0) return 0;
return (props.page - 1) * props.limit + 1;
});
const lastItemIndex = computed(() => {
return Math.min(props.page * props.limit, props.totalCount);
});
watch(
() => props.items,
() => {
if (props.selectable && props.selection?.length) {
const ids = new Set(props.items.map((item) => String(item._id || item.id)));
const next = props.selection.filter((item) => ids.has(String(item._id || item.id)));
if (next.length !== props.selection.length) {
emit('update:selection', next);
}
}
}
);
</script>
<style scoped lang="scss">
.data-table-wrapper {
background-color: var(--surface-card);
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<!-- /src/components/common/EmptyState.vue -->
<template>
<div class="empty-state flex flex-column align-items-center justify-content-center p-5 text-center my-4">
<div class="icon-wrapper mb-3 text-muted">
<i :class="[icon, 'text-6xl']"></i>
</div>
<h3 class="text-xl font-bold text-color mb-2">{{ title }}</h3>
<p class="text-muted text-sm mb-4 max-w-26rem">{{ description }}</p>
<slot />
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: 'اطلاعاتی یافت نشد'
},
description: {
type: String,
default: 'موردی برای نمایش در این بخش وجود ندارد.'
},
icon: {
type: String,
default: 'pi pi-folder-open'
}
});
</script>
<style scoped lang="scss">
.empty-state {
background: var(--surface-card);
border: 1px dashed var(--border-color);
border-radius: 8px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<!-- /src/components/common/PageHeader.vue -->
<template>
<div class="page-header flex flex-column md:flex-row align-items-start md:align-items-center justify-content-between mb-4 gap-3">
<div>
<h1 class="text-2xl font-bold text-color m-0">{{ title }}</h1>
<p v-if="subtitle" class="text-muted text-sm mt-1 mb-0">{{ subtitle }}</p>
</div>
<div class="flex align-items-center gap-2">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
}
});
</script>
+32
View File
@@ -0,0 +1,32 @@
<!-- /src/components/common/PermissionGate.vue -->
<template>
<slot v-if="hasAccess" />
</template>
<script setup>
import { computed } from 'vue';
import { usePermission } from '@/composables/usePermission';
const props = defineProps({
permission: {
type: String,
default: null
},
anyPermission: {
type: Array,
default: () => []
}
});
const { hasPermission, hasAnyPermission } = usePermission();
const hasAccess = computed(() => {
if (props.permission) {
return hasPermission(props.permission);
}
if (props.anyPermission && props.anyPermission.length > 0) {
return hasAnyPermission(props.anyPermission);
}
return true;
});
</script>
+101
View File
@@ -0,0 +1,101 @@
<!-- /src/components/common/StatusTag.vue -->
<template>
<Tag :severity="severity" :value="label" class="text-xs font-semibold px-2 py-1" />
</template>
<script setup>
import { computed } from 'vue';
import Tag from 'primevue/tag';
const props = defineProps({
status: {
type: [String, Boolean],
required: true
},
type: {
type: String,
default: 'general' // general | payment | session | notification | contact
}
});
const SESSION_LABELS = {
scheduled: 'طبق برنامه',
Scheduled: 'طبق برنامه',
held: 'برگزارشده',
cancelled: 'لغوشده',
canceled: 'لغوشده'
};
const CONTACT_LABELS = {
new: 'جدید',
seen: 'مشاهده‌شده',
call_later: 'تماس بعدی',
contacted: 'تماس گرفته شد',
closed: 'بسته'
};
const NOTIFICATION_LABELS = {
pending: 'در انتظار',
sent: 'ارسال‌شده',
delivered: 'تحویل‌شده',
failed: 'ناموفق',
retrying: 'تلاش مجدد'
};
const PAYMENT_LABELS = {
paid: 'تسویه‌شده',
partial: 'پرداخت جزئی',
pending: 'در انتظار پرداخت',
overdue: 'معوق'
};
const severity = computed(() => {
const val = String(props.status).toLowerCase();
if (props.type === 'payment') {
if (val === 'paid' || val === 'تسویه شده' || val === 'تسویه‌شده') return 'success';
if (val === 'partial' || val === 'پرداخت جزئی') return 'warning';
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
}
if (props.type === 'session') {
if (val === 'scheduled' || val.includes('برنامه')) return 'info';
if (val === 'held' || val.includes('برگزار')) return 'success';
if (val === 'cancelled' || val === 'canceled' || val.includes('لغو')) return 'danger';
}
if (props.type === 'contact') {
if (val === 'new') return 'info';
if (val === 'seen') return 'secondary';
if (val === 'call_later') return 'warning';
if (val === 'contacted') return 'success';
if (val === 'closed') return 'contrast';
}
if (props.type === 'notification') {
if (val === 'success' || val === 'delivered' || val === 'sent' || val === 'تحویل شده' || val === 'ارسال‌شده') return 'success';
if (val === 'pending' || val === 'retrying' || val === 'در حال تلاش مجدد' || val === 'در انتظار') return 'warning';
if (val === 'failed' || val === 'ناموفق') return 'danger';
}
if (val === 'true' || val === 'active' || val === 'فعال') return 'success';
if (val === 'false' || val === 'inactive' || val === 'غیرفعال') return 'danger';
return 'info';
});
const label = computed(() => {
const raw = String(props.status);
const val = raw.toLowerCase();
if (props.type === 'session') return SESSION_LABELS[val] || SESSION_LABELS[raw] || raw;
if (props.type === 'contact') return CONTACT_LABELS[val] || raw;
if (props.type === 'notification') return NOTIFICATION_LABELS[val] || raw;
if (props.type === 'payment') return PAYMENT_LABELS[val] || raw;
if (val === 'true' || val === 'active') return 'فعال';
if (val === 'false' || val === 'inactive') return 'غیرفعال';
return raw;
});
</script>
+32
View File
@@ -0,0 +1,32 @@
<!-- Reusable table skeleton placeholder -->
<template>
<div class="table-skeleton" aria-busy="true" aria-live="polite">
<div class="flex gap-2 mb-3 pb-2 border-bottom-1 border-color">
<Skeleton
v-for="col in columns"
:key="`h-${col}`"
height="0.85rem"
:width="widths[(col - 1) % widths.length]"
/>
</div>
<div v-for="row in rows" :key="`r-${row}`" class="flex align-items-center gap-2 mb-2">
<Skeleton
v-for="col in columns"
:key="`c-${row}-${col}`"
height="1.35rem"
:width="widths[(col - 1) % widths.length]"
/>
</div>
</div>
</template>
<script setup>
import Skeleton from 'primevue/skeleton';
defineProps({
rows: { type: Number, default: 6 },
columns: { type: Number, default: 5 }
});
const widths = ['18%', '22%', '16%', '20%', '14%', '12%', '10%', '15%'];
</script>
+41
View File
@@ -0,0 +1,41 @@
<!-- /src/components/layout/AppBreadcrumb.vue -->
<template>
<div v-if="items && items.length > 0" class="app-breadcrumb mb-3">
<Breadcrumb :home="home" :model="items">
<template #item="{ item }">
<router-link v-if="item.to" :to="item.to" class="text-color hover:text-primary font-medium text-sm">
{{ item.label }}
</router-link>
<span v-else class="text-muted font-normal text-sm">{{ item.label }}</span>
</template>
</Breadcrumb>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Breadcrumb from 'primevue/breadcrumb';
const route = useRoute();
const home = { icon: 'pi pi-home', to: '/' };
const items = computed(() => {
const matched = route.matched.filter(m => m.meta && m.meta.title && m.path !== '/');
return matched.map(m => ({
label: m.meta.title,
to: m.path === route.path ? null : m.path
}));
});
</script>
<style scoped lang="scss">
.app-breadcrumb {
:deep(.p-breadcrumb) {
background: transparent;
border: none;
padding: 0;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
<!-- /src/components/layout/AppLayout.vue -->
<template>
<DashboardLayout />
</template>
<script setup>
import DashboardLayout from '@/layouts/DashboardLayout.vue';
</script>
+69
View File
@@ -0,0 +1,69 @@
<!-- /src/components/layout/AppSidebar.vue -->
<template>
<div class="app-sidebar p-3 surface-card border-left-1 border-color flex flex-column h-full">
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color">
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
<span class="font-bold text-xl text-color">گام نو</span>
</div>
<div class="menu-list flex-grow-1 overflow-y-auto">
<router-link
v-for="item in menuItems"
:key="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="{ 'active-item': isMenuActive(item.to) }"
>
<i :class="item.icon" class="text-lg"></i>
<span class="font-medium text-sm">{{ item.label }}</span>
</router-link>
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
const menuItems = [
{ 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' }
];
function isMenuActive(to) {
if (to === '/') {
return route.path === '/';
}
return route.path === to || route.path.startsWith(`${to}/`);
}
</script>
<style lang="scss" scoped>
.app-sidebar {
width: 240px;
min-height: 100vh;
}
.menu-item {
color: var(--text-secondary);
text-decoration: none;
&: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;
}
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<!-- /src/components/layout/AppTopbar.vue -->
<template>
<header class="app-topbar flex align-items-center justify-content-between px-4 py-2 surface-card border-bottom-1 border-color">
<div class="flex align-items-center gap-3">
<Button
icon="pi pi-bars"
text
rounded
class="lg:hidden"
@click="uiStore.toggleMobileSidebar"
/>
<Button
icon="pi pi-bars"
text
rounded
class="hidden lg:flex"
@click="uiStore.toggleSidebar"
/>
<div class="flex align-items-center gap-2 font-bold text-xl text-primary">
<i class="pi pi-building text-2xl"></i>
<span>{{ $t('app.title') }}</span>
</div>
</div>
<div class="flex align-items-center gap-3">
<!-- Dark/Light Theme Toggle -->
<Button
:icon="themeStore.isDarkMode ? 'pi pi-sun' : 'pi pi-moon'"
text
rounded
:aria-label="themeStore.isDarkMode ? $t('app.lightMode') : $t('app.darkMode')"
v-tooltip.bottom="themeStore.isDarkMode ? $t('app.lightMode') : $t('app.darkMode')"
@click="themeStore.toggleTheme"
/>
<!-- Notifications Icon -->
<button
type="button"
class="notification-trigger"
:aria-label="$t('navigation.notifications')"
v-tooltip.bottom="$t('navigation.notifications')"
@click="$router.push('/notifications')"
>
<i class="pi pi-bell"></i>
<span v-if="notificationCount > 0" class="notification-badge">{{ notificationCount }}</span>
</button>
<!-- User Menu -->
<div class="flex align-items-center gap-2">
<Avatar
icon="pi pi-user"
shape="circle"
class="bg-primary text-white cursor-pointer"
@click="toggleUserMenu"
/>
<span class="font-medium hidden sm:inline text-color cursor-pointer" @click="toggleUserMenu">
{{ authStore.user?.name || authStore.user?.username || 'کاربر سیستم' }}
</span>
<Menu ref="userMenu" :model="menuItems" :popup="true" />
</div>
</div>
</header>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';
import { useThemeStore } from '@/stores/themeStore';
import { useUiStore } from '@/stores/uiStore';
import { useI18n } from 'vue-i18n';
import Button from 'primevue/button';
import Avatar from 'primevue/avatar';
import Menu from 'primevue/menu';
const authStore = useAuthStore();
const themeStore = useThemeStore();
const uiStore = useUiStore();
const router = useRouter();
const { t } = useI18n();
const userMenu = ref(null);
const notificationCount = ref(3);
const toggleUserMenu = (event) => {
userMenu.value.toggle(event);
};
const menuItems = ref([
{
label: authStore.user?.name || authStore.user?.username || 'کاربر',
items: [
{
label: t('app.profile'),
icon: 'pi pi-user',
command: () => {
const id = authStore.user?._id || authStore.user?.id;
if (id) {
router.push(`/users/edit/${id}`);
}
}
},
{
label: t('app.logout'),
icon: 'pi pi-sign-out',
command: async () => {
await authStore.logout();
router.push('/login');
}
}
]
}
]);
</script>
<style scoped lang="scss">
.app-topbar {
height: 60px;
position: sticky;
top: 0;
z-index: 999;
}
.notification-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--primary-color);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--primary-color-light);
}
.pi-bell {
font-size: 1.15rem;
line-height: 1;
}
}
.notification-badge {
position: absolute;
top: 0.2rem;
inset-inline-end: 0.15rem;
min-width: 1.05rem;
height: 1.05rem;
padding: 0 0.25rem;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.05rem;
text-align: center;
box-shadow: 0 0 0 2px var(--surface-card, #1e293b);
pointer-events: none;
}
</style>
@@ -0,0 +1,37 @@
<!-- /src/components/uploader/CertificateUploader.vue -->
<template>
<div class="certificate-uploader">
<ImageManager
v-model="certificates"
upload-endpoint="/certificates/user/upload"
delete-endpoint="/files/admin/delete"
:max-files="1"
:allow-multiple="false"
gallery-id="certificate-gallery"
@upload-success="onCertificateUploaded"
/>
</div>
</template>
<script setup>
import { computed } from 'vue';
import ImageManager from './ImageManager.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'uploaded']);
const certificates = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const onCertificateUploaded = (fileData) => {
emit('uploaded', fileData);
};
</script>
+198
View File
@@ -0,0 +1,198 @@
<!-- /src/components/uploader/ImageManager.vue -->
<template>
<div class="image-manager">
<!-- FilePond Upload Input -->
<file-pond
ref="pond"
name="file"
:label-idle="labelIdle"
:allow-multiple="allowMultiple"
:max-files="maxFiles"
accepted-file-types="image/jpeg, image/png, image/webp, application/pdf"
:server="serverOptions"
@processfile="handleProcessFile"
@removefile="handleRemoveFile"
/>
<!-- Gallery Lightbox Preview Grid -->
<div v-if="imagesList && imagesList.length > 0" class="gallery-grid grid mt-3" :id="galleryId">
<div
v-for="(img, index) in imagesList"
:key="img.url || index"
class="col-12 sm:col-6 md:col-4 lg:col-3"
>
<div class="gallery-card surface-card border-1 border-color border-round overflow-hidden relative group">
<a
:href="img.url"
:data-pswp-width="img.width || 1200"
:data-pswp-height="img.height || 800"
target="_blank"
class="block overflow-hidden"
>
<img :src="img.url" :alt="img.name || 'تصویر'" class="w-full h-10rem object-cover block transition-transform transition-duration-200 hover:scale-105" />
</a>
<div class="p-2 flex align-items-center justify-content-between border-top-1 border-color">
<span class="text-xs text-muted truncate max-w-8rem">{{ img.name || `فایل ${index + 1}` }}</span>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
@click="removeGalleryImage(index)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import vueFilePond from 'vue-filepond';
import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import 'photoswipe/style.css';
import { useAuthStore } from '@/stores/authStore';
import Button from 'primevue/button';
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
uploadEndpoint: {
type: String,
default: '/files/admin/upload-temp'
},
deleteEndpoint: {
type: String,
default: '/files/admin/delete'
},
maxFiles: {
type: Number,
default: 5
},
allowMultiple: {
type: Boolean,
default: true
},
galleryId: {
type: String,
default: 'pswp-gallery'
}
});
const emit = defineEmits(['update:modelValue', 'upload-success']);
const authStore = useAuthStore();
const pond = ref(null);
let lightbox = null;
const labelIdle = 'کشیدن و رها کردن فایل یا <span class="filepond--label-action">مرور سیستم</span>';
const imagesList = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
const serverOptions = computed(() => ({
url: API_BASE_URL,
process: {
url: props.uploadEndpoint,
headers: {
Authorization: `Bearer ${authStore.accessToken}`
},
onload: (response) => {
try {
const res = typeof response === 'string' ? JSON.parse(response) : response;
const fileData = res.data || res;
return fileData.tempFileName || fileData.filename || fileData.url || JSON.stringify(fileData);
} catch (e) {
return response;
}
}
},
revert: {
url: props.deleteEndpoint,
headers: {
Authorization: `Bearer ${authStore.accessToken}`
}
}
}));
const handleProcessFile = (error, file) => {
if (!error) {
let serverRes = file.serverId;
let url = '';
let tempFileName = '';
try {
const parsed = JSON.parse(serverRes);
url = parsed.url || parsed.publicUrl || '';
tempFileName = parsed.tempFileName || parsed.filename || '';
} catch (e) {
tempFileName = serverRes;
url = `${API_BASE_URL}/files/admin/signed-url/${serverRes}`;
}
const newItem = {
name: file.filename,
tempFileName: tempFileName || file.filename,
url: url || `${API_BASE_URL}/files/admin/signed-url/${tempFileName}`,
width: 1200,
height: 800
};
const updated = [...imagesList.value, newItem];
emit('update:modelValue', updated);
emit('upload-success', newItem);
}
};
const handleRemoveFile = (error, file) => {
// handled via gallery card actions or filepond
};
const removeGalleryImage = (index) => {
const updated = [...imagesList.value];
updated.splice(index, 1);
emit('update:modelValue', updated);
};
onMounted(() => {
lightbox = new PhotoSwipeLightbox({
gallery: `#${props.galleryId}`,
children: 'a',
pswpModule: () => import('photoswipe')
});
lightbox.init();
});
onUnmounted(() => {
if (lightbox) {
lightbox.destroy();
lightbox = null;
}
});
</script>
<style lang="scss">
.image-manager {
.filepond--panel-root {
background-color: var(--surface-card);
border: 1px dashed var(--border-color);
}
.filepond--drop-label {
color: var(--text-secondary);
}
}
</style>
+163
View File
@@ -0,0 +1,163 @@
// /src/composables/useDataTable.js
import { ref, reactive } from 'vue';
export function useDataTable(fetchApiFn, defaultParams = {}) {
const items = ref([]);
const totalCount = ref(0);
const totalPages = ref(1);
const isLoading = ref(false);
const queryParams = reactive({
page: 1,
limit: 20,
sortBy: 'createdAt',
sortOrder: 'desc',
q: '',
...defaultParams
});
let searchTimeout = null;
let requestSeq = 0;
async function loadData() {
const seq = ++requestSeq;
isLoading.value = true;
try {
const cleanParams = {};
Object.keys(queryParams).forEach((key) => {
if (queryParams[key] !== '' && queryParams[key] !== null && queryParams[key] !== undefined) {
cleanParams[key] = queryParams[key];
}
});
const response = await fetchApiFn(cleanParams);
if (seq !== requestSeq) return;
// listResponse shape: { success, data: [], meta }
const body = response && typeof response === 'object' ? response : {};
const data = Array.isArray(body.data) ? body : (body.data || body);
if (Array.isArray(data)) {
items.value = data;
totalCount.value = data.length;
totalPages.value = 1;
} else if (Array.isArray(data.data)) {
items.value = data.data;
totalCount.value = data.meta?.totalCount ?? data.data.length;
totalPages.value = data.meta?.totalPages ?? Math.ceil(totalCount.value / queryParams.limit) ?? 1;
} else if (
data.items ||
data.docs ||
data.users ||
data.courses ||
data.sessions ||
data.payments ||
data.roles ||
data.notifications ||
data.certificates ||
data.professors ||
data.logs ||
data.activityLogs
) {
items.value =
data.items ||
data.docs ||
data.users ||
data.courses ||
data.sessions ||
data.payments ||
data.roles ||
data.notifications ||
data.certificates ||
data.professors ||
data.logs ||
data.activityLogs ||
[];
totalCount.value = data.meta?.totalCount ?? data.total ?? items.value.length;
totalPages.value = data.meta?.totalPages ?? Math.ceil(totalCount.value / queryParams.limit) ?? 1;
} else {
items.value = [];
totalCount.value = 0;
totalPages.value = 1;
}
} catch (error) {
if (seq !== requestSeq) return;
console.error('Error fetching datatable data:', error);
items.value = [];
totalCount.value = 0;
} finally {
if (seq === requestSeq) {
isLoading.value = false;
}
}
}
function onPageChange(event = {}) {
// DataTableWrapper emits { page (1-based), limit }
// Raw PrimeVue Paginator emits { page (0-based), rows }
if (event.limit != null) {
queryParams.page = Number(event.page) || 1;
queryParams.limit = Number(event.limit) || queryParams.limit;
} else if (event.rows != null) {
queryParams.page = (Number(event.page) || 0) + 1;
queryParams.limit = Number(event.rows) || queryParams.limit;
} else if (event.page != null) {
queryParams.page = Number(event.page) || 1;
}
loadData();
}
function onSort(event = {}) {
// DataTableWrapper emits { sortBy, sortOrder: 'asc'|'desc' }
// Raw PrimeVue emits { sortField, sortOrder: 1|-1 }
const field = event.sortBy || event.sortField;
if (!field) return;
queryParams.sortBy = field;
if (event.sortOrder === 'asc' || event.sortOrder === 'desc') {
queryParams.sortOrder = event.sortOrder;
} else {
queryParams.sortOrder = event.sortOrder === 1 ? 'asc' : 'desc';
}
loadData();
}
function onSearch(value) {
queryParams.q = value;
queryParams.page = 1;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadData();
}, 400);
}
function setFilter(key, value) {
queryParams[key] = value;
queryParams.page = 1;
loadData();
}
function resetFilters() {
Object.keys(defaultParams).forEach((k) => {
queryParams[k] = defaultParams[k];
});
queryParams.page = 1;
queryParams.limit = defaultParams.limit ?? 20;
queryParams.q = '';
loadData();
}
return {
items,
totalCount,
totalPages,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch,
setFilter,
resetFilters
};
}
+20
View File
@@ -0,0 +1,20 @@
// /src/composables/usePermission.js
import { usePermissionStore } from '@/stores/permissionStore';
export function usePermission() {
const permissionStore = usePermissionStore();
const hasPermission = (perm) => {
return permissionStore.hasPermission(perm);
};
const hasAnyPermission = (permsArray) => {
return permissionStore.hasAnyPermission(permsArray);
};
return {
hasPermission,
hasAnyPermission,
roleName: permissionStore.roleName
};
}
+52
View File
@@ -0,0 +1,52 @@
// /src/composables/usePersianDate.js
import moment from 'jalali-moment';
export function usePersianDate() {
const toPersianDigits = (num) => {
if (num === null || num === undefined) return '';
const str = String(num);
const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
return str.replace(/\d/g, (x) => farsiDigits[parseInt(x)]);
};
const toLatinDigits = (str) => {
if (!str) return '';
const farsiDigits = [/۰/g, /۱/g, /۲/g, /۳/g, /۴/g, /۵/g, /۶/g, /۷/g, /۸/g, /۹/g];
let result = String(str);
for (let i = 0; i < 10; i++) {
result = result.replace(farsiDigits[i], String(i));
}
return result;
};
const formatJalali = (date, formatStr = 'YYYY/MM/DD') => {
if (!date) return '-';
try {
return moment(date).locale('fa').format(formatStr);
} catch (e) {
return '-';
}
};
const formatJalaliWithTime = (date) => {
return formatJalali(date, 'YYYY/MM/DD - HH:mm');
};
const jalaliToIso = (jalaliStr) => {
if (!jalaliStr) return null;
try {
const latinStr = toLatinDigits(jalaliStr);
return moment(latinStr, 'jYYYY/jMM/jDD').toISOString();
} catch (e) {
return null;
}
};
return {
toPersianDigits,
toLatinDigits,
formatJalali,
formatJalaliWithTime,
jalaliToIso
};
}
+31
View File
@@ -0,0 +1,31 @@
// /src/composables/useToast.js
import { useToast as usePrimeToast } from 'primevue/usetoast';
import { getApiErrorMessage } from '@/utils/apiError';
export function useToast() {
const toast = usePrimeToast();
const showSuccess = (detail, summary = 'موفقیت‌آمیز') => {
toast.add({ severity: 'success', summary, detail, life: 4000 });
};
const showError = (detail, summary = 'خطا') => {
const message = getApiErrorMessage(detail);
toast.add({ severity: 'error', summary, detail: message, life: 6000 });
};
const showInfo = (detail, summary = 'اطلاعیه') => {
toast.add({ severity: 'info', summary, detail, life: 4000 });
};
const showWarn = (detail, summary = 'هشدار') => {
toast.add({ severity: 'warn', summary, detail, life: 4000 });
};
return {
showSuccess,
showError,
showInfo,
showWarn
};
}
+217
View File
@@ -0,0 +1,217 @@
// /src/constants/permissions.js
// Mirrors the backend constants/permissions.js
export const PERMISSIONS = {
USERS_CREATE: 'users:create',
USERS_READ: 'users:read',
USERS_UPDATE: 'users:update',
USERS_DELETE: 'users:delete',
USERS_SEARCH: 'users:search',
USERS_ENROLL: 'users:enroll',
PROFESSORS_CREATE: 'professors:create',
PROFESSORS_READ: 'professors:read',
PROFESSORS_UPDATE: 'professors:update',
PROFESSORS_DELETE: 'professors:delete',
PROFESSORS_SEARCH: 'professors:search',
COURSES_CREATE: 'courses:create',
COURSES_READ: 'courses:read',
COURSES_UPDATE: 'courses:update',
COURSES_DELETE: 'courses:delete',
COURSES_SEARCH: 'courses:search',
CLASSES_CREATE: 'classes:create',
CLASSES_READ: 'classes:read',
CLASSES_UPDATE: 'classes:update',
CLASSES_DELETE: 'classes:delete',
CLASSES_SEARCH: 'classes:search',
CLASSES_REGISTER_USERS: 'classes:register_users',
SESSIONS_CREATE: 'sessions:create',
SESSIONS_READ: 'sessions:read',
SESSIONS_UPDATE: 'sessions:update',
SESSIONS_DELETE: 'sessions:delete',
SESSIONS_SEARCH: 'sessions:search',
SESSIONS_ATTENDANCE: 'sessions:attendance',
PAYMENTS_CREATE: 'payments:create',
PAYMENTS_READ: 'payments:read',
PAYMENTS_UPDATE: 'payments:update',
PAYMENTS_DELETE: 'payments:delete',
PAYMENTS_SEARCH: 'payments:search',
ROLES_CREATE: 'roles:create',
ROLES_READ: 'roles:read',
ROLES_UPDATE: 'roles:update',
ROLES_DELETE: 'roles:delete',
ROLES_SEARCH: 'roles:search',
NOTIFICATIONS_CREATE: 'notifications:create',
NOTIFICATIONS_READ: 'notifications:read',
NOTIFICATIONS_UPDATE: 'notifications:update',
NOTIFICATIONS_DELETE: 'notifications:delete',
NOTIFICATIONS_SEARCH: 'notifications:search',
NOTIFICATIONS_RETRY: 'notifications:retry',
CERTIFICATES_CREATE: 'certificates:create',
CERTIFICATES_READ: 'certificates:read',
CERTIFICATES_UPDATE: 'certificates:update',
CERTIFICATES_DELETE: 'certificates:delete',
CERTIFICATES_SEARCH: 'certificates:search',
FILES_UPLOAD: 'files:upload',
FILES_READ: 'files:read',
FILES_DELETE: 'files:delete',
LOGS_READ: 'logs:read',
LOGS_SEARCH: 'logs:search',
CONTACT_INQUIRIES_READ: 'contact_inquiries:read',
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update'
};
export const PERMISSION_GROUPS = [
{
key: 'users',
label: 'مدیریت کاربران',
icon: 'pi pi-users',
permissions: [
{ key: PERMISSIONS.USERS_CREATE, label: 'ایجاد کاربر' },
{ key: PERMISSIONS.USERS_READ, label: 'مشاهده کاربران' },
{ key: PERMISSIONS.USERS_UPDATE, label: 'ویرایش کاربر' },
{ key: PERMISSIONS.USERS_DELETE, label: 'حذف کاربر' },
{ key: PERMISSIONS.USERS_SEARCH, label: 'جستجوی کاربران' },
{ key: PERMISSIONS.USERS_ENROLL, label: 'ثبت‌نام کاربر در دوره' }
]
},
{
key: 'professors',
label: 'مدیریت اساتید',
icon: 'pi pi-id-card',
permissions: [
{ key: PERMISSIONS.PROFESSORS_CREATE, label: 'ایجاد استاد' },
{ key: PERMISSIONS.PROFESSORS_READ, label: 'مشاهده اساتید' },
{ key: PERMISSIONS.PROFESSORS_UPDATE, label: 'ویرایش استاد' },
{ key: PERMISSIONS.PROFESSORS_DELETE, label: 'حذف استاد' },
{ key: PERMISSIONS.PROFESSORS_SEARCH, label: 'جستجوی اساتید' }
]
},
{
key: 'courses',
label: 'دوره‌های آموزشی',
icon: 'pi pi-book',
permissions: [
{ key: PERMISSIONS.COURSES_CREATE, label: 'ایجاد دوره' },
{ key: PERMISSIONS.COURSES_READ, label: 'مشاهده دوره‌ها' },
{ key: PERMISSIONS.COURSES_UPDATE, label: 'ویرایش دوره' },
{ key: PERMISSIONS.COURSES_DELETE, label: 'حذف دوره' },
{ key: PERMISSIONS.COURSES_SEARCH, label: 'جستجوی دوره‌ها' }
]
},
{
key: 'classes',
label: 'کلاس‌ها',
icon: 'pi pi-desktop',
permissions: [
{ key: PERMISSIONS.CLASSES_CREATE, label: 'ایجاد کلاس' },
{ key: PERMISSIONS.CLASSES_READ, label: 'مشاهده کلاس‌ها' },
{ key: PERMISSIONS.CLASSES_UPDATE, label: 'ویرایش کلاس' },
{ key: PERMISSIONS.CLASSES_DELETE, label: 'حذف کلاس' },
{ key: PERMISSIONS.CLASSES_SEARCH, label: 'جستجوی کلاس‌ها' },
{ key: PERMISSIONS.CLASSES_REGISTER_USERS, label: 'ثبت‌نام کاربران در کلاس' }
]
},
{
key: 'sessions',
label: 'جلسات آموزشی',
icon: 'pi pi-calendar',
permissions: [
{ key: PERMISSIONS.SESSIONS_CREATE, label: 'ایجاد جلسه' },
{ key: PERMISSIONS.SESSIONS_READ, label: 'مشاهده جلسات' },
{ key: PERMISSIONS.SESSIONS_UPDATE, label: 'ویرایش جلسه' },
{ key: PERMISSIONS.SESSIONS_DELETE, label: 'حذف جلسه' },
{ key: PERMISSIONS.SESSIONS_SEARCH, label: 'جستجوی جلسات' },
{ key: PERMISSIONS.SESSIONS_ATTENDANCE, label: 'ثبت حضور و غیاب' }
]
},
{
key: 'payments',
label: 'امور مالی و پرداخت‌ها',
icon: 'pi pi-wallet',
permissions: [
{ key: PERMISSIONS.PAYMENTS_CREATE, label: 'ایجاد صورتحساب' },
{ key: PERMISSIONS.PAYMENTS_READ, label: 'مشاهده پرداخت‌ها' },
{ key: PERMISSIONS.PAYMENTS_UPDATE, label: 'ویرایش / ثبت تراکنش' },
{ key: PERMISSIONS.PAYMENTS_DELETE, label: 'حذف صورتحساب' },
{ key: PERMISSIONS.PAYMENTS_SEARCH, label: 'جستجوی پرداخت‌ها' }
]
},
{
key: 'roles',
label: 'نقش‌ها و دسترسی‌ها',
icon: 'pi pi-shield',
permissions: [
{ key: PERMISSIONS.ROLES_CREATE, label: 'ایجاد نقش' },
{ key: PERMISSIONS.ROLES_READ, label: 'مشاهده نقش‌ها' },
{ key: PERMISSIONS.ROLES_UPDATE, label: 'ویرایش نقش' },
{ key: PERMISSIONS.ROLES_DELETE, label: 'حذف نقش' },
{ key: PERMISSIONS.ROLES_SEARCH, label: 'جستجوی نقش‌ها' }
]
},
{
key: 'notifications',
label: 'اطلاعیه‌ها و پیام‌رسانی',
icon: 'pi pi-bell',
permissions: [
{ key: PERMISSIONS.NOTIFICATIONS_CREATE, label: 'ارسال اطلاعیه' },
{ key: PERMISSIONS.NOTIFICATIONS_READ, label: 'مشاهده اطلاعیه‌ها' },
{ key: PERMISSIONS.NOTIFICATIONS_UPDATE, label: 'ویرایش اطلاعیه' },
{ key: PERMISSIONS.NOTIFICATIONS_DELETE, label: 'حذف اطلاعیه' },
{ key: PERMISSIONS.NOTIFICATIONS_SEARCH, label: 'جستجوی اطلاعیه‌ها' },
{ key: PERMISSIONS.NOTIFICATIONS_RETRY, label: 'ارسال مجدد اطلاعیه' }
]
},
{
key: 'certificates',
label: 'گواهینامه‌ها',
icon: 'pi pi-verified',
permissions: [
{ key: PERMISSIONS.CERTIFICATES_CREATE, label: 'صدور گواهینامه' },
{ key: PERMISSIONS.CERTIFICATES_READ, label: 'مشاهده گواهینامه‌ها' },
{ key: PERMISSIONS.CERTIFICATES_UPDATE, label: 'ویرایش گواهینامه' },
{ key: PERMISSIONS.CERTIFICATES_DELETE, label: 'حذف گواهینامه' },
{ key: PERMISSIONS.CERTIFICATES_SEARCH, label: 'جستجوی گواهینامه‌ها' }
]
},
{
key: 'files',
label: 'مدیریت فایل‌ها',
icon: 'pi pi-folder',
permissions: [
{ key: PERMISSIONS.FILES_UPLOAD, label: 'آپلود فایل' },
{ key: PERMISSIONS.FILES_READ, label: 'مشاهده فایل‌ها' },
{ key: PERMISSIONS.FILES_DELETE, label: 'حذف فایل' }
]
},
{
key: 'logs',
label: 'گزارش فعالیت‌ها',
icon: 'pi pi-history',
permissions: [
{ key: PERMISSIONS.LOGS_READ, label: 'مشاهده لاگ‌ها' },
{ key: PERMISSIONS.LOGS_SEARCH, label: 'جستجوی لاگ‌ها' }
]
},
{
key: 'contact_inquiries',
label: 'درخواست‌های تماس',
icon: 'pi pi-comments',
permissions: [
{ key: PERMISSIONS.CONTACT_INQUIRIES_READ, label: 'مشاهده درخواست‌ها' },
{ key: PERMISSIONS.CONTACT_INQUIRIES_UPDATE, label: 'ویرایش / پیگیری درخواست‌ها' }
]
}
];
export const ALL_PERMISSIONS = Object.values(PERMISSIONS);
+26
View File
@@ -0,0 +1,26 @@
<!-- /src/layouts/AuthLayout.vue -->
<template>
<div class="auth-layout-wrapper">
<router-view />
</div>
</template>
<script setup>
</script>
<style scoped lang="scss">
.auth-layout-wrapper {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
min-height: 100vh !important;
width: 100vw !important;
max-width: 100% !important;
box-sizing: border-box !important;
background: var(--bg-primary);
margin: 0 !important;
padding: 1.5rem !important;
position: relative !important;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<!-- /src/layouts/DashboardLayout.vue -->
<template>
<div class="dashboard-layout min-h-screen flex flex-column">
<AppTopbar />
<div class="layout-main-container flex flex-grow-1">
<AppSidebar />
<main class="layout-main flex-grow-1 p-4 overflow-x-hidden">
<AppBreadcrumb />
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import AppTopbar from '@/components/layout/AppTopbar.vue';
import AppSidebar from '@/components/layout/AppSidebar.vue';
import AppBreadcrumb from '@/components/layout/AppBreadcrumb.vue';
</script>
<style scoped lang="scss">
.dashboard-layout {
background-color: var(--bg-primary);
}
.layout-main {
min-height: calc(100vh - 60px);
}
</style>
+139
View File
@@ -0,0 +1,139 @@
{
"app": {
"title": "داشبورد مدیریتی موسسه آموزشی",
"dashboard": "داشبورد",
"search": "جستجو...",
"save": "ذخیره",
"cancel": "انصراف",
"delete": "حذف",
"edit": "ویرایش",
"actions": "عملیات",
"status": "وضعیت",
"created": "تاریخ ایجاد",
"updated": "تاریخ بروزرسانی",
"loading": "در حال بارگذاری...",
"noData": "اطلاعاتی یافت نشد",
"profile": "پروفایل",
"logout": "خروج"
},
"auth": {
"loginTitle": "ورود به پنل مدیریتی",
"welcome": "خوش آمدید! اطلاعات حساب خود را وارد کنید",
"login": "ورود به سیستم",
"username": "نام کاربری",
"password": "رمز عبور",
"loginButton": "ورود به داشبورد",
"submit": "ورود",
"logout": "خروج",
"loginSuccess": "ورود با موفقیت انجام شد",
"loginError": "نام کاربری یا رمز عبور اشتباه است",
"loginFailed": "نام کاربری یا رمز عبور اشتباه است"
},
"navigation": {
"dashboard": "داشبورد اصلی",
"users": "مدیریت کاربران",
"professors": "مدیریت اساتید",
"courses": "دوره های آموزشی",
"classes": "کلاس ها",
"sessions": "جلسات آموزشی",
"payments": "امور مالی و پرداخت ها",
"roles": "نقش ها و دسترسی ها",
"notifications": "اطلاعیه ها",
"contactInquiries": "درخواست‌های تماس",
"certificates": "گواهی نامه ها",
"logs": "گزارش فعالیت‌ها"
},
"users": {
"title": "مدیریت کاربران",
"subtitle": "لیست و مدیریت تمام کاربران موسسه",
"addUser": "افزودن کاربر جدید",
"editUser": "ویرایش کاربر",
"name": "نام",
"surname": "نام خانوادگی",
"nationalId": "کد ملی",
"phone": "شماره همراه",
"email": "ایمیل",
"preferredMessenger": "پیام‌رسان ترجیحی",
"role": "نقش کاربر",
"status": "وضعیت حساب",
"tabInfo": "اطلاعات شخصی",
"tabCourses": "دوره‌های ثبت‌نامی",
"tabSessions": "حضور و غیاب",
"tabPayments": "پرداخت‌ها",
"tabCertificates": "گواهینامه‌ها"
},
"professors": {
"title": "مدیریت اساتید",
"subtitle": "لیست اساتید و مربیان موسسه",
"addProfessor": "افزودن استاد جدید",
"editProfessor": "ویرایش اطلاعات استاد",
"bio": "بیوگرافی / رزومه",
"expertise": "تخصص ها"
},
"courses": {
"title": "دوره‌های آموزشی",
"subtitle": "تعریف و مدیریت دوره‌های موسسه",
"addCourse": "افزودن دوره جدید",
"editCourse": "ویرایش دوره",
"courseTitle": "عنوان دوره",
"type": "نوع دوره",
"typeGeneral": "عمومی",
"typePrivate": "خصوصی",
"official": "دارای مدرک رسمی",
"unofficial": "بدون مدرک رسمی",
"price": "شهریه (تومان)",
"capacity": "ظرفیت",
"isOfficial": "دوره رسمی",
"description": "توضیحات دوره",
"professor": "استاد مدرس",
"sessionsTabTitle": "جلسات دوره",
"autoTab": "ایجاد خودکار جلسات",
"manualTab": "تعریف دستی جلسات",
"classesSectionTitle": "کلاس‌های این دوره",
"addClass": "تعریف کلاس جدید",
"sessionCount": "تعداد جلسات",
"startDate": "تاریخ شروع",
"startTime": "ساعت شروع",
"endTime": "ساعت پایان",
"daysOfWeek": "روزهای هفته"
},
"sessions": {
"title": "جلسات آموزشی",
"subtitle": "برنامه‌ریزی و مدیریت جلسات",
"addSession": "افزودن جلسه جدید",
"editSession": "ویرایش جلسه",
"date": "تاریخ جلسه",
"topic": "موضوع جلسه",
"attendance": "حضور و غیاب",
"enterAttendance": "ثبت حضور و غیاب"
},
"attendances": {
"title": "حضور و غیاب جلسه",
"submitAttendance": "ثبت حضور و غیاب"
},
"roles": {
"title": "نقش‌ها و دسترسی‌ها",
"subtitle": "مدیریت نقش‌های سیستمی و مجوزها",
"addRole": "افزودن نقش جدید",
"editRole": "ویرایش نقش",
"roleName": "نام نقش",
"systemRoleWarning": "نقش‌های سیستمی قابل حذف یا تغییر نام نیستند",
"permissionsTitle": "مجوزهای دسترسی",
"selectAll": "انتخاب همه"
},
"notifications": {
"title": "مدیریت اطلاعیه‌ها",
"subtitle": "ارسال پیامک و ایمیل گروهی",
"addNotification": "ارسال اطلاعیه جدید"
},
"logs": {
"title": "گزارش فعالیت کاربران",
"subtitle": "مشاهده و فیلتر تمام عملیات ایجاد، ویرایش، حذف و ورود کاربران"
},
"payments": {
"title": "امور مالی و پرداخت‌ها",
"subtitle": "مدیریت تراکنش‌ها و شهریه‌ها",
"addPayment": "ثبت پرداخت جدید",
"paymentDetail": "جزئیات صورتحساب"
}
}
+14
View File
@@ -0,0 +1,14 @@
// /src/locales/i18n.js
import { createI18n } from 'vue-i18n';
import fa from './fa.json';
export default function setupI18n() {
return createI18n({
legacy: false,
locale: 'fa',
fallbackLocale: 'fa',
messages: {
fa
}
});
}
+48
View File
@@ -0,0 +1,48 @@
// /src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import createI18n from '@/locales/i18n';
import router from '@/router';
import App from '@/App.vue';
// PrimeVue Core & Theme Preset
import PrimeVue from 'primevue/config';
import Aura from '@primevue/themes/aura';
import ToastService from 'primevue/toastservice';
import ConfirmationService from 'primevue/confirmationservice';
import Tooltip from 'primevue/tooltip';
// PrimeIcons & PrimeFlex Layout
import 'primeicons/primeicons.css';
import 'primeflex/primeflex.css';
// RTL & Main Custom Styling
import '@/assets/styles/main.scss';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
const i18n = createI18n();
app.use(i18n);
app.use(router);
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.dark-mode',
cssLayer: false
}
},
ripple: true
});
app.use(ToastService);
app.use(ConfirmationService);
app.directive('tooltip', Tooltip);
app.mount('#app');
+56
View File
@@ -0,0 +1,56 @@
// /src/router/guards.js
import { useAuthStore } from '@/stores/authStore';
import { usePermissionStore } from '@/stores/permissionStore';
function isPublicRoute(to) {
if (to.meta.public === true) return true;
if (to.meta.requiresAuth === false) return true;
return to.matched.some(
(record) => record.meta.public === true || record.meta.requiresAuth === false
);
}
export function setupGuards(router) {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
// 1. Public / unauthenticated routes (e.g. Login)
if (isPublicRoute(to)) {
if (to.name === 'Login' && authStore.isAuthenticated) {
return next({ name: 'DashboardHome' });
}
return next();
}
// 2. Auth check
if (!authStore.isAuthenticated) {
if (to.name === 'Login') {
return next();
}
return next({ name: 'Login', query: { redirect: to.fullPath } });
}
// 3. Rehydrate permission store if token exists but permissions store is unpopulated
if (!permissionStore.isLoaded || permissionStore.permissions.length === 0) {
try {
await permissionStore.fetchPermissions();
} catch (err) {
console.error('Failed to rehydrate permissions, logging out:', err);
await authStore.logout();
return next({ name: 'Login' });
}
}
// 4. Permission check against stored permissions list
const requiredPermission = to.meta.permission;
if (requiredPermission) {
const hasAccess = permissionStore.hasPermission(requiredPermission);
if (!hasAccess) {
return next({ name: 'Forbidden' });
}
}
next();
});
}
+13
View File
@@ -0,0 +1,13 @@
// /src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { routes } from './routes';
import { setupGuards } from './guards';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
setupGuards(router);
export default router;
+197
View File
@@ -0,0 +1,197 @@
// /src/router/routes.js
import DashboardLayout from '@/layouts/DashboardLayout.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
export const routes = [
{
path: '/login',
component: AuthLayout,
children: [
{
path: '',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false }
}
]
},
{
path: '/',
component: DashboardLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'DashboardHome',
component: () => import('@/views/dashboard/DashboardHomeView.vue')
},
// Users
{
path: 'users',
name: 'UserList',
component: () => import('@/views/users/UserListView.vue')
},
{
path: 'users/create',
name: 'UserCreate',
component: () => import('@/views/users/UserFormView.vue')
},
{
path: 'users/edit/:id',
name: 'UserEdit',
component: () => import('@/views/users/UserFormView.vue')
},
// Professors
{
path: 'professors',
name: 'ProfessorList',
component: () => import('@/views/professors/ProfessorListView.vue')
},
{
path: 'professors/create',
name: 'ProfessorCreate',
component: () => import('@/views/professors/ProfessorFormView.vue')
},
{
path: 'professors/edit/:id',
name: 'ProfessorEdit',
component: () => import('@/views/professors/ProfessorFormView.vue')
},
// Courses
{
path: 'courses',
name: 'CourseList',
component: () => import('@/views/courses/CourseListView.vue')
},
{
path: 'courses/create',
name: 'CourseCreate',
component: () => import('@/views/courses/CourseFormView.vue')
},
{
path: 'courses/edit/:id',
name: 'CourseEdit',
component: () => import('@/views/courses/CourseFormView.vue')
},
{
path: 'courses/view/:id',
name: 'CourseDetail',
component: () => import('@/views/courses/CourseDetailView.vue')
},
// Classes
{
path: 'classes',
name: 'ClassList',
component: () => import('@/views/classes/ClassListView.vue')
},
{
path: 'classes/create',
name: 'ClassCreate',
component: () => import('@/views/classes/ClassFormView.vue')
},
{
path: 'classes/edit/:id',
name: 'ClassEdit',
component: () => import('@/views/classes/ClassFormView.vue')
},
{
path: 'classes/view/:id',
name: 'ClassDetail',
component: () => import('@/views/classes/ClassDetailView.vue')
},
// Sessions
{
path: 'sessions',
name: 'SessionList',
component: () => import('@/views/sessions/SessionListView.vue')
},
{
path: 'sessions/create',
name: 'SessionCreate',
component: () => import('@/views/sessions/SessionFormView.vue')
},
{
path: 'sessions/edit/:id',
name: 'SessionEdit',
component: () => import('@/views/sessions/SessionFormView.vue')
},
{
path: 'sessions/attendance/:id',
name: 'AttendanceEntry',
component: () => import('@/views/attendances/AttendanceEntryView.vue')
},
// Payments
{
path: 'payments',
name: 'PaymentList',
component: () => import('@/views/payments/PaymentListView.vue')
},
{
path: 'payments/view/:id',
name: 'PaymentDetail',
component: () => import('@/views/payments/PaymentDetailView.vue')
},
// Roles
{
path: 'roles',
name: 'RoleList',
component: () => import('@/views/roles/RoleListView.vue')
},
{
path: 'roles/create',
name: 'RoleCreate',
component: () => import('@/views/roles/RoleFormView.vue')
},
{
path: 'roles/edit/:id',
name: 'RoleEdit',
component: () => import('@/views/roles/RoleFormView.vue')
},
// Notifications
{
path: 'notifications',
name: 'NotificationList',
component: () => import('@/views/notifications/NotificationListView.vue')
},
// Contact inquiries
{
path: 'contact-inquiries',
name: 'ContactInquiryList',
component: () => import('@/views/contactInquiries/ContactInquiryListView.vue')
},
{
path: 'contact-inquiries/view/:id',
name: 'ContactInquiryDetail',
component: () => import('@/views/contactInquiries/ContactInquiryDetailView.vue')
},
{
path: 'logs',
name: 'LogList',
component: () => import('@/views/logs/LogListView.vue'),
meta: { permission: 'logs:read' }
},
// Errors
{
path: '403',
name: 'Forbidden',
component: () => import('@/views/errors/ForbiddenView.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/errors/NotFoundView.vue')
}
];
+84
View File
@@ -0,0 +1,84 @@
// /src/stores/authStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authApi } from '@/api/authApi';
import Cookies from 'js-cookie';
import { usePermissionStore } from './permissionStore';
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref(localStorage.getItem('accessToken') || Cookies.get('accessToken') || '');
const refreshToken = ref(localStorage.getItem('refreshToken') || Cookies.get('refreshToken') || '');
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'));
const isAuthenticated = computed(() => !!accessToken.value);
function setTokens(access, refresh) {
accessToken.value = access;
refreshToken.value = refresh;
if (access) {
localStorage.setItem('accessToken', access);
Cookies.set('accessToken', access);
} else {
localStorage.removeItem('accessToken');
Cookies.remove('accessToken');
}
if (refresh) {
localStorage.setItem('refreshToken', refresh);
Cookies.set('refreshToken', refresh);
} else {
localStorage.removeItem('refreshToken');
Cookies.remove('refreshToken');
}
}
function setUser(userData) {
user.value = userData;
if (userData) {
localStorage.setItem('user', JSON.stringify(userData));
} else {
localStorage.removeItem('user');
}
}
async function login(credentials) {
const response = await authApi.login(credentials);
const data = response.data || response;
// Server response: accessToken, refreshToken, user, permissions / role
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
const permissionStore = usePermissionStore();
const perms = data.user?.role?.permissions || data.permissions || [];
const roleName = data.user?.role?.name || data.role || '';
permissionStore.setPermissions(perms, roleName);
return data;
}
async function logout() {
try {
if (refreshToken.value) {
await authApi.logout(refreshToken.value);
}
} catch (e) {
console.warn('Logout API error:', e);
} finally {
setTokens('', '');
setUser(null);
const permissionStore = usePermissionStore();
permissionStore.clearPermissions();
}
}
return {
accessToken,
refreshToken,
user,
isAuthenticated,
setTokens,
setUser,
login,
logout
};
});
+63
View File
@@ -0,0 +1,63 @@
// /src/stores/permissionStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { authApi } from '@/api/authApi';
export const usePermissionStore = defineStore('permission', () => {
const permissions = ref(JSON.parse(localStorage.getItem('permissions') || '[]'));
const roleName = ref(localStorage.getItem('roleName') || '');
const isLoaded = ref(permissions.value.length > 0);
function setPermissions(permsArray = [], role = '') {
permissions.value = Array.isArray(permsArray) ? permsArray : [];
roleName.value = role;
isLoaded.value = true;
localStorage.setItem('permissions', JSON.stringify(permissions.value));
localStorage.setItem('roleName', roleName.value);
}
function clearPermissions() {
permissions.value = [];
roleName.value = '';
isLoaded.value = false;
localStorage.removeItem('permissions');
localStorage.removeItem('roleName');
}
async function fetchPermissions() {
try {
const response = await authApi.getSelf();
const userData = response.data || response;
const perms = userData.role?.permissions || [];
const role = userData.role?.name || '';
setPermissions(perms, role);
return permissions.value;
} catch (err) {
console.error('Failed to fetch user permissions:', err);
clearPermissions();
throw err;
}
}
function hasPermission(perm) {
if (!perm) return true;
if (permissions.value.includes('*') || permissions.value.includes('admin:all')) return true;
return permissions.value.includes(perm);
}
function hasAnyPermission(permsArray) {
if (!Array.isArray(permsArray) || permsArray.length === 0) return true;
return permsArray.some(p => hasPermission(p));
}
return {
permissions,
roleName,
isLoaded,
setPermissions,
clearPermissions,
fetchPermissions,
hasPermission,
hasAnyPermission
};
});
+35
View File
@@ -0,0 +1,35 @@
// /src/stores/themeStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
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);
function applyTheme() {
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;
applyTheme();
}
function initTheme() {
applyTheme();
}
return {
isDarkMode,
toggleTheme,
initTheme
};
});
+30
View File
@@ -0,0 +1,30 @@
// /src/stores/uiStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useUiStore = defineStore('ui', () => {
const isSidebarOpen = ref(true);
const isMobileSidebarOpen = ref(false);
const breadcrumbs = ref([]);
function toggleSidebar() {
isSidebarOpen.value = !isSidebarOpen.value;
}
function toggleMobileSidebar() {
isMobileSidebarOpen.value = !isMobileSidebarOpen.value;
}
function setBreadcrumbs(items) {
breadcrumbs.value = items;
}
return {
isSidebarOpen,
isMobileSidebarOpen,
breadcrumbs,
toggleSidebar,
toggleMobileSidebar,
setBreadcrumbs
};
});
+2
View File
@@ -0,0 +1,2 @@
/* /src/style.css */
@import './assets/styles/main.scss';
+52
View File
@@ -0,0 +1,52 @@
// /src/utils/apiError.js
/**
* Extracts the user-facing error message from an Axios/API error,
* preferring the localized message returned by the backend.
*/
export function getApiErrorMessage(error, fallback = 'خطایی رخ داده است') {
if (!error) return fallback;
if (typeof error === 'string') {
return error.trim() || fallback;
}
const payload = error?.response?.data;
const apiError = payload?.error;
if (apiError && typeof apiError === 'object') {
if (typeof apiError.message === 'string' && apiError.message.trim()) {
return apiError.message.trim();
}
}
if (typeof payload?.message === 'string' && payload.message.trim()) {
return payload.message.trim();
}
// Network / timeout / cancel — Axios message is useful here
if (!error.response && typeof error.message === 'string' && error.message.trim()) {
if (error.code === 'ECONNABORTED') {
return 'زمان پاسخ‌گویی سرور به پایان رسید.';
}
if (error.message === 'Network Error') {
return 'ارتباط با سرور برقرار نشد.';
}
return error.message;
}
// Avoid showing generic "Request failed with status code XXX"
if (
typeof error.message === 'string' &&
error.message.trim() &&
!/^Request failed with status code \d+/i.test(error.message)
) {
return error.message.trim();
}
return fallback;
}
export function getApiErrorCode(error) {
return error?.response?.data?.error?.code || null;
}
@@ -0,0 +1,158 @@
<!-- /src/views/attendances/AttendanceEntryView.vue -->
<template>
<div class="attendance-entry-view" v-if="session">
<PageHeader
:title="$t('attendances.title')"
:subtitle="`دوره: ${session.course?.title || '-'} | کلاس: ${session.class?.name || '-'} | تاریخ: ${formatJalali(session.day)} (${session.startTime || ''} تا ${session.endTime || ''})`"
>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/sessions')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-3" v-if="session.note">
<span class="text-muted text-xs block mb-1">یادداشت جلسه</span>
<p class="m-0 white-space-pre-wrap">{{ session.note }}</p>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-4 pb-3 border-bottom-1 border-color">
<div>
<h2 class="text-lg font-bold text-color m-0">لیست دانشجویان کلاس</h2>
<span class="text-muted text-xs">وضعیت حضور هر دانشجو را مشخص کنید</span>
</div>
<div class="flex align-items-center gap-2">
<Button label="علامت‌گذاری همه به عنوان حاضر" icon="pi pi-check-circle" size="small" text severity="success" @click="markAllPresent" />
</div>
</div>
<DataTable :value="studentList" class="p-datatable-sm text-sm" emptyMessage="دانشجویی در این کلاس ثبت‌نام نشده است">
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column field="name" header="نام و نام خانوادگی دانشجو">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
<span class="text-xs text-muted block">کد ملی: {{ toPersianDigits(data.nationalIdCode || '—') }}</span>
</template>
</Column>
<Column field="status" header="وضعیت حضور">
<template #body="{ data }">
<SelectButton
v-model="data.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="text-xs"
/>
</template>
</Column>
<Column field="note" header="یادداشت / توضیحات">
<template #body="{ data }">
<InputText v-model="data.note" class="w-full text-xs" placeholder="دلیل غیبت یا تاخیر..." />
</template>
</Column>
</DataTable>
<div class="flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
<Button
:label="$t('attendances.submitAttendance')"
icon="pi pi-save"
severity="success"
:loading="isSubmitting"
:disabled="!studentList.length"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { sessionApi } from '@/api/sessionApi';
import { attendanceApi } from '@/api/attendanceApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import SelectButton from 'primevue/selectbutton';
import InputText from 'primevue/inputtext';
const route = useRoute();
const router = useRouter();
const sessionId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const session = ref(null);
const studentList = ref([]);
const isSubmitting = ref(false);
const statusOptions = [
{ label: 'حاضر', value: 'present' },
{ label: 'غایب', value: 'absent' },
{ label: 'تاخیر', value: 'late' },
{ label: 'موجه', value: 'excused' }
];
const fetchSessionAttendance = async () => {
try {
const res = await sessionApi.getOne(sessionId);
const data = res.data || res;
session.value = data;
const roster = data.class?.students || [];
const attendanceByUser = new Map(
(data.attendanceList || []).map((row) => [
String(row.user?._id || row.user),
row
])
);
studentList.value = roster.map((user) => {
const existing = attendanceByUser.get(String(user._id));
return {
userId: user._id,
name: user.name || '',
surname: user.surname || '',
nationalIdCode: user.nationalIdCode || '',
status: existing?.status || 'present',
note: existing?.note || ''
};
});
} catch (err) {
showError(err);
}
};
const markAllPresent = () => {
studentList.value.forEach((s) => { s.status = 'present'; });
};
const handleSubmit = async () => {
isSubmitting.value = true;
try {
const payloadList = studentList.value.map((s) => ({
user: s.userId,
status: s.status,
note: s.note
}));
await attendanceApi.recordSessionAttendance(sessionId, payloadList);
showSuccess('حضور و غیاب جلسه با موفقیت ثبت شد');
router.push('/sessions');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(fetchSessionAttendance);
</script>
+158
View File
@@ -0,0 +1,158 @@
<!-- /src/views/auth/LoginView.vue -->
<template>
<div class="login-card-container">
<div class="login-view surface-card p-4 sm:p-5 border-round-2xl border-1 border-color shadow-lg">
<div class="text-center mb-4">
<div class="inline-flex align-items-center justify-content-center w-4rem h-4rem border-circle bg-primary-light text-primary mb-3">
<i class="pi pi-building text-3xl"></i>
</div>
<h1 class="text-2xl font-bold text-color m-0">{{ $t('auth.loginTitle') }}</h1>
<p class="text-muted text-sm mt-2 mb-0">{{ $t('auth.welcome') }}</p>
</div>
<!-- Credentials Hint Box -->
<div class="surface-ground p-3 border-round border-1 border-color mb-4 text-xs">
<div class="font-bold text-primary mb-1 flex align-items-center gap-1">
<i class="pi pi-key"></i>
<span>اطلاعات ورود مدیر سیستم:</span>
</div>
<div class="flex justify-content-between align-items-center mt-1">
<span>نام کاربری: <code class="font-bold text-color">superadmin</code></span>
<span>رمز عبور: <code class="font-bold text-color">SuperAdminSecret123!</code></span>
</div>
<Button label="جاگذاری اطلاعات حساب مدیر" size="small" text class="w-full mt-2 text-xs p-1" @click="fillDemo" />
</div>
<form @submit.prevent="handleLogin" class="flex flex-column gap-3">
<div class="flex flex-column gap-2">
<label for="username" class="font-semibold text-sm text-color">{{ $t('auth.username') }}</label>
<IconField iconPosition="right">
<InputIcon class="pi pi-user" />
<InputText
id="username"
v-model.trim="form.username"
class="w-full text-sm"
:class="{ 'p-invalid': errors.username }"
placeholder="superadmin"
autocomplete="username"
/>
</IconField>
<small v-if="errors.username" class="text-red-500 text-xs">{{ errors.username }}</small>
</div>
<div class="flex flex-column gap-2">
<label for="password" class="font-semibold text-sm text-color">{{ $t('auth.password') }}</label>
<IconField iconPosition="right">
<InputIcon class="pi pi-lock" />
<Password
id="password"
v-model="form.password"
:feedback="false"
toggleMask
class="w-full text-sm"
inputClass="w-full text-sm"
:class="{ 'p-invalid': errors.password }"
placeholder="SuperAdminSecret123!"
autocomplete="current-password"
/>
</IconField>
<small v-if="errors.password" class="text-red-500 text-xs">{{ errors.password }}</small>
</div>
<Button
type="submit"
:label="$t('auth.loginButton')"
icon="pi pi-sign-in"
class="w-full mt-2 font-bold"
:loading="loading"
/>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';
import { useToast } from '@/composables/useToast';
import { useI18n } from 'vue-i18n';
import InputText from 'primevue/inputtext';
import Password from 'primevue/password';
import Button from 'primevue/button';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const { showSuccess, showError } = useToast();
const { t } = useI18n();
const form = reactive({
username: 'superadmin',
password: 'SuperAdminSecret123!'
});
const errors = reactive({
username: '',
password: ''
});
const loading = ref(false);
const fillDemo = () => {
form.username = 'superadmin';
form.password = 'SuperAdminSecret123!';
};
const validate = () => {
let valid = true;
errors.username = '';
errors.password = '';
if (!form.username) {
errors.username = 'ورود نام کاربری الزامی است';
valid = false;
}
if (!form.password) {
errors.password = 'ورود رمز عبور الزامی است';
valid = false;
}
return valid;
};
const handleLogin = async () => {
if (!validate()) return;
loading.value = true;
try {
await authStore.login(form);
showSuccess(t('auth.loginSuccess'));
const redirectPath = route.query.redirect || '/';
router.push(redirectPath);
} catch (err) {
console.error('Login error:', err);
showError(err);
} finally {
loading.value = false;
}
};
</script>
<style scoped lang="scss">
.login-card-container {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
max-width: 28rem !important;
margin: auto !important;
}
.login-view {
width: 100% !important;
background: var(--surface-card);
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<!-- /src/views/classes/ClassDetailView.vue -->
<template>
<div class="class-detail-view" v-if="classData">
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
<Button label="ویرایش" icon="pi pi-pencil" class="ml-2" @click="$router.push(`/classes/edit/${classId}`)" />
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-lg font-bold text-color mb-3">مشخصات کلاس</h2>
<div class="grid">
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">استاد مدرس</span>
<span class="font-bold text-color text-sm">
{{ classData.professor ? `${classData.professor.name || ''} ${classData.professor.surname || ''}`.trim() : '—' }}
</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">ظرفیت کل</span>
<span class="font-bold text-color text-sm">{{ toPersianDigits(classData.capacity || 0) }} نفر</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">تعداد ثبتنامیها</span>
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">شهریه</span>
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.tuitionFee || 0).toLocaleString('en-US')) }} تومان</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">بازه زمانی</span>
<span class="font-bold text-color text-sm">
{{ formatJalali(classData.startDate) }} {{ formatJalali(classData.endDate) }}
</span>
</div>
<div class="col-12 sm:col-4">
<span class="text-muted text-xs block mb-1">وضعیت</span>
<StatusTag :status="classData.isActive !== false" />
</div>
</div>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-lg font-bold mb-3">دانشجویان</h2>
<DataTable :value="classData.students || []" class="p-datatable-sm text-sm" emptyMessage="دانشجویی ثبت نشده">
<Column header="#">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام">
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }"><span dir="ltr">{{ data.phoneNumber || '—' }}</span></template>
</Column>
</DataTable>
</div>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3">
<h2 class="text-lg font-bold m-0">جلسات کلاس</h2>
<Button
label="مدیریت جلسات"
icon="pi pi-calendar"
text
size="small"
@click="$router.push(`/classes/edit/${classId}`)"
/>
</div>
<TableSkeleton v-if="loadingSessions" :rows="5" :columns="5" />
<DataTable
v-else
:value="sessions"
class="p-datatable-sm text-sm"
emptyMessage="جلسه‌ای برای این کلاس ثبت نشده است"
>
<Column header="#">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="موضوع">
<template #body="{ data }">{{ data.topic || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatJalali(data.day || data.date) }}</template>
</Column>
<Column header="زمان">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { classApi } from '@/api/classApi';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const route = useRoute();
const classId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showError } = useToast();
const classData = ref(null);
const sessions = ref([]);
const loadingSessions = ref(false);
const fetchClass = async () => {
try {
const res = await classApi.getOne(classId);
classData.value = res.data || res;
} catch (err) {
showError(err);
}
};
const fetchSessions = async () => {
loadingSessions.value = true;
try {
const res = await sessionApi.getAll({
limit: 100,
class: classId,
sortBy: 'day',
sortOrder: 'asc'
});
if (Array.isArray(res)) {
sessions.value = res;
} else if (Array.isArray(res?.data)) {
sessions.value = res.data;
} else if (Array.isArray(res?.items)) {
sessions.value = res.items;
} else {
sessions.value = [];
}
} catch (err) {
showError(err);
sessions.value = [];
} finally {
loadingSessions.value = false;
}
};
onMounted(() => {
fetchClass();
fetchSessions();
});
</script>
+316
View File
@@ -0,0 +1,316 @@
<!-- /src/views/classes/ClassFormView.vue -->
<template>
<div class="class-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
>
<Button
label="انصراف"
text
severity="secondary"
@click="goBack"
/>
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">نام کلاس *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" placeholder="مثلا: گروه الف - تابستان" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">دوره آموزشی مرتبط *</label>
<Dropdown v-model="form.course" :options="courses" optionLabel="title" optionValue="_id" placeholder="دوره را انتخاب کنید" class="w-full text-sm" filter />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">استاد مدرس</label>
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="استاد را انتخاب کنید" class="w-full text-sm" filter />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ظرفیت کلاس</label>
<InputNumber v-model="form.capacity" :min="1" class="w-full text-sm" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">شهریه کلاس</label>
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label>
<DatePicker v-model="form.startDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ پایان</label>
<DatePicker v-model="form.endDate" class="w-full text-sm" placeholder="1403/03/01" />
</div>
<div class="col-12 md:col-4 flex align-items-center gap-2 mt-4">
<InputSwitch v-model="form.isActive" />
<label class="font-semibold text-sm">کلاس فعال است</label>
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="goBack" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
<template v-if="isEditMode">
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-3">
<div>
<h2 class="text-lg font-bold m-0">دانشجویان کلاس</h2>
<p class="text-muted text-sm m-0 mt-1">دانشجویان ثبتنامشده در این کلاس</p>
</div>
<Tag :value="`${toPersianDigits(students.length)} نفر`" severity="info" />
</div>
<div class="flex flex-column md:flex-row gap-2 mb-3">
<MultiSelect
v-model="selectedUserIds"
:options="availableUsers"
optionLabel="label"
optionValue="_id"
placeholder="افزودن دانشجو…"
filter
display="chip"
class="flex-grow-1 text-sm"
/>
<Button
label="ثبت‌نام در کلاس"
icon="pi pi-user-plus"
:loading="registering"
:disabled="!selectedUserIds.length"
@click="registerSelected"
/>
</div>
<DataTable :value="students" class="p-datatable-sm text-sm" emptyMessage="هنوز دانشجویی ثبت‌نام نشده است">
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام">
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }">
<span dir="ltr">{{ data.phoneNumber || '—' }}</span>
</template>
</Column>
</DataTable>
</div>
<ClassSessionsSection
v-if="classId"
:class-id="String(classId)"
:course-id="form.course ? String(form.course) : ''"
:professor-id="form.professor ? String(form.professor) : null"
:default-session-count="defaultSessionCount"
/>
</template>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import moment from 'jalali-moment';
import { classApi } from '@/api/classApi';
import { courseApi } from '@/api/courseApi';
import { professorApi } from '@/api/professorApi';
import { userApi } from '@/api/userApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import InputSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import DatePicker from 'vue3-persian-datetime-picker';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const { toPersianDigits, toLatinDigits } = usePersianDate();
const classId = route.params.id;
const queryCourseId = route.query.courseId ? String(route.query.courseId) : null;
const isEditMode = computed(() => !!classId);
const isSubmitting = ref(false);
const registering = ref(false);
const defaultSessionCount = ref(12);
const courses = ref([]);
const professors = ref([]);
const students = ref([]);
const allUsers = ref([]);
const selectedUserIds = ref([]);
const form = reactive({
name: '',
course: queryCourseId,
professor: null,
capacity: 20,
tuitionFee: 0,
startDate: '',
endDate: '',
isActive: true
});
const availableUsers = computed(() => {
const enrolled = new Set(students.value.map((s) => String(s._id)));
return allUsers.value
.filter((u) => !enrolled.has(String(u._id)))
.map((u) => ({
...u,
label: `${u.name || ''} ${u.surname || ''}${u.phoneNumber || ''}`.trim()
}));
});
const goBack = () => {
if (form.course) {
router.push(`/courses/edit/${form.course}`);
return;
}
router.push('/classes');
};
const toGregorianIso = (jalaliValue) => {
if (!jalaliValue) return undefined;
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
const latin = toLatinDigits(String(jalaliValue));
const m = moment(latin, 'jYYYY/jMM/jDD');
return m.isValid() ? m.toDate().toISOString() : undefined;
};
const toJalaliDisplay = (value) => {
if (!value) return '';
return moment(value).locale('fa').format('jYYYY/jMM/jDD');
};
const applyCourseDefaults = (course) => {
if (!course) return;
if (!form.professor && (course.professor?._id || course.professor)) {
form.professor = course.professor?._id || course.professor;
}
if (course.capacity) form.capacity = course.capacity;
if (course.price != null) form.tuitionFee = course.price;
if (course.sectionCount) defaultSessionCount.value = course.sectionCount;
};
const fetchData = async () => {
try {
const [cRes, pRes, uRes] = await Promise.all([
courseApi.getAll({ limit: 100 }),
professorApi.getAll({ limit: 100 }),
userApi.getAll({ limit: 200 })
]);
const cData = cRes.data || cRes;
courses.value = Array.isArray(cData) ? cData : (cData.items || cData.courses || cData.data || []);
const pData = pRes.data || pRes;
professors.value = (Array.isArray(pData) ? pData : (pData.items || pData.professors || pData.data || [])).map((p) => ({
...p,
name: `${p.name || ''} ${p.surname || ''}`.trim()
}));
const uData = uRes.data || uRes;
allUsers.value = Array.isArray(uData) ? uData : (uData.items || uData.users || uData.data || []);
if (classId) {
const res = await classApi.getOne(classId);
const data = res.data || res;
Object.assign(form, {
name: data.name || '',
course: data.course?._id || data.course || null,
professor: data.professor?._id || data.professor || null,
capacity: data.capacity || 20,
tuitionFee: data.tuitionFee || 0,
startDate: toJalaliDisplay(data.startDate),
endDate: toJalaliDisplay(data.endDate),
isActive: data.isActive !== false
});
students.value = data.students || [];
if (data.course?.sectionCount) defaultSessionCount.value = data.course.sectionCount;
} else if (queryCourseId) {
const linked = courses.value.find((c) => String(c._id) === String(queryCourseId));
if (linked) {
applyCourseDefaults(linked);
} else {
try {
const one = await courseApi.getOne(queryCourseId);
applyCourseDefaults(one.data || one);
} catch (e) {
console.warn('Course prefetch error:', e);
}
}
}
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.name) { showError('نام کلاس الزامی است'); return; }
if (!form.course) { showError('دوره مرتبط الزامی است'); return; }
isSubmitting.value = true;
try {
const payload = {
name: form.name,
course: form.course,
professor: form.professor || undefined,
capacity: form.capacity,
tuitionFee: form.tuitionFee,
startDate: toGregorianIso(form.startDate),
endDate: toGregorianIso(form.endDate),
isActive: form.isActive
};
if (isEditMode.value) {
await classApi.update(classId, payload);
showSuccess('کلاس با موفقیت ویرایش شد');
await fetchData();
} else {
const res = await classApi.create(payload);
const created = res.data || res;
showSuccess('کلاس جدید با موفقیت ایجاد شد');
router.push(`/classes/edit/${created._id || created.id}`);
}
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
const registerSelected = async () => {
if (!selectedUserIds.value.length) return;
registering.value = true;
try {
const res = await classApi.registerUsers(classId, selectedUserIds.value);
const data = res.data || res;
students.value = data.students || [];
selectedUserIds.value = [];
showSuccess('دانشجویان با موفقیت به کلاس اضافه شدند');
} catch (err) {
showError(err);
} finally {
registering.value = false;
}
};
onMounted(fetchData);
</script>
+93
View File
@@ -0,0 +1,93 @@
<!-- /src/views/classes/ClassListView.vue -->
<template>
<div class="class-list-view">
<PageHeader title="مدیریت کلاس‌ها" subtitle="لیست گروه‌ها و کلاس‌های در حال برگزاری">
<PermissionGate permission="classes:create">
<Button label="تعریف کلاس جدید" icon="pi pi-plus" @click="$router.push('/classes/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام کلاس" sortable>
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }}</span>
</template>
</Column>
<Column field="course.title" header="دوره مرتبط">
<template #body="{ data }">
{{ data.course?.title || data.courseTitle || '-' }}
</template>
</Column>
<Column field="capacity" header="ظرفیت">
<template #body="{ data }">
{{ toPersianDigits(data.capacity || 0) }} نفر
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-eye"
text
rounded
size="small"
severity="info"
@click="$router.push(`/classes/view/${data._id || data.id}`)"
/>
<PermissionGate permission="classes:update">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
@click="$router.push(`/classes/edit/${data._id || data.id}`)"
/>
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { classApi } from '@/api/classApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
const { toPersianDigits } = usePersianDate();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(classApi.getAll);
onMounted(() => {
loadData();
});
</script>
@@ -0,0 +1,173 @@
<!-- /src/views/contactInquiries/ContactInquiryDetailView.vue -->
<template>
<div class="contact-inquiry-detail" v-if="inquiry">
<PageHeader
:title="`${inquiry.name} ${inquiry.surname}`"
subtitle="جزئیات و پیگیری درخواست تماس"
>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/contact-inquiries')" />
</PageHeader>
<div class="grid">
<div class="col-12 lg:col-7">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-3">
<h2 class="text-lg font-bold mb-3">اطلاعات تماس</h2>
<div class="grid">
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">کد ملی</span>
<span class="font-semibold" dir="ltr">{{ inquiry.nationalIdCode ? toPersianDigits(inquiry.nationalIdCode) : '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">موبایل</span>
<span class="font-semibold" dir="ltr">{{ inquiry.phoneNumber || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">ایمیل</span>
<span class="font-semibold" dir="ltr">{{ inquiry.email || '—' }}</span>
</div>
<div class="col-12 sm:col-6">
<span class="text-muted text-xs block">زمان ثبت</span>
<span class="font-semibold">{{ formatJalali(inquiry.createdAt, 'jYYYY/jMM/jDD HH:mm') }}</span>
</div>
<div class="col-12">
<span class="text-muted text-xs block mb-1">روشهای ترجیحی تماس</span>
<div class="flex flex-wrap gap-1">
<Tag
v-for="method in (inquiry.preferredContactMethods || [])"
:key="method"
:value="methodLabel(method)"
/>
</div>
</div>
<div class="col-12">
<span class="text-muted text-xs block mb-1">دورههای مورد علاقه</span>
<div class="flex flex-wrap gap-1">
<Tag
v-for="course in (inquiry.courses || [])"
:key="course._id || course"
:value="course.title || course"
severity="info"
/>
<span v-if="!(inquiry.courses || []).length" class="text-muted">انتخاب نشده</span>
</div>
</div>
<div class="col-12">
<span class="text-muted text-xs block mb-1">پیام کاربر</span>
<p class="m-0 white-space-pre-wrap">{{ inquiry.message || '—' }}</p>
</div>
</div>
</div>
</div>
<div class="col-12 lg:col-5">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold mb-3">پیگیری</h2>
<div class="flex flex-column gap-3">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">وضعیت</label>
<Dropdown
v-model="form.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full text-sm"
/>
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">یادداشت داخلی</label>
<Textarea
v-model="form.notes"
rows="8"
class="w-full text-sm"
placeholder="نتیجه تماس، زمان مناسب تماس بعدی و …"
/>
</div>
<Button
label="ذخیره پیگیری"
icon="pi pi-save"
:loading="saving"
@click="save"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { contactInquiryApi } from '@/api/contactInquiryApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import Button from 'primevue/button';
import Dropdown from 'primevue/select';
import Textarea from 'primevue/textarea';
import Tag from 'primevue/tag';
const route = useRoute();
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const inquiry = ref(null);
const saving = ref(false);
const form = reactive({ status: 'new', notes: '' });
const statusOptions = [
{ label: 'جدید', value: 'new' },
{ label: 'مشاهده‌شده', value: 'seen' },
{ label: 'تماس بعدی', value: 'call_later' },
{ label: 'تماس گرفته شد', value: 'contacted' },
{ label: 'بسته', value: 'closed' }
];
const METHOD_LABELS = {
WhatsApp: 'واتساپ',
Telegram: 'تلگرام',
Soroush: 'سروش',
Bale: 'بله',
Eitaa: 'ایتا',
SMS: 'پیامک',
Call: 'تماس تلفنی'
};
const methodLabel = (value) => METHOD_LABELS[value] || value;
const load = async () => {
try {
const res = await contactInquiryApi.getOne(route.params.id);
const data = res.data || res;
inquiry.value = data;
form.status = data.status || 'new';
form.notes = data.notes || '';
if (data.status === 'new') {
await contactInquiryApi.update(route.params.id, { status: 'seen' });
form.status = 'seen';
inquiry.value.status = 'seen';
}
} catch (err) {
showError(err);
}
};
const save = async () => {
saving.value = true;
try {
const res = await contactInquiryApi.update(route.params.id, {
status: form.status,
notes: form.notes
});
inquiry.value = res.data || res;
showSuccess('پیگیری ذخیره شد');
} catch (err) {
showError(err);
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
@@ -0,0 +1,172 @@
<!-- /src/views/contactInquiries/ContactInquiryListView.vue -->
<template>
<div class="contact-inquiry-list-view">
<PageHeader title="درخواست‌های تماس" subtitle="پیگیری علاقه‌مندان و درخواست‌های مشاوره از وب‌سایت">
<div class="flex flex-wrap gap-2 align-items-center">
<Dropdown
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="وضعیت"
showClear
class="w-12rem text-sm"
@change="onStatusFilter"
/>
<Dropdown
v-model="selectedCourse"
:options="courses"
optionLabel="title"
optionValue="_id"
placeholder="فیلتر دوره"
showClear
filter
class="w-16rem text-sm"
@change="onCourseFilter"
/>
</div>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
<span v-if="data.nationalIdCode" class="text-xs text-muted block" dir="ltr">
کد ملی: {{ toPersianDigits(data.nationalIdCode) }}
</span>
</template>
</Column>
<Column field="phoneNumber" header="تماس">
<template #body="{ data }">
<span class="block" dir="ltr">{{ data.phoneNumber || '—' }}</span>
<span v-if="data.email" class="text-xs text-muted block" dir="ltr">{{ data.email }}</span>
</template>
</Column>
<Column field="courses" header="دوره‌های انتخابی">
<template #body="{ data }">
<div class="flex flex-wrap gap-1">
<Tag
v-for="course in (data.courses || [])"
:key="course._id || course"
:value="course.title || course"
severity="info"
class="text-xs"
/>
<span v-if="!(data.courses || []).length" class="text-muted text-xs"></span>
</div>
</template>
</Column>
<Column field="preferredContactMethods" header="روش تماس">
<template #body="{ data }">
<span class="text-sm">{{ (data.preferredContactMethods || []).map(methodLabel).join('، ') || '—' }}</span>
</template>
</Column>
<Column field="status" header="وضعیت" sortable>
<template #body="{ data }">
<StatusTag :status="data.status" type="contact" />
</template>
</Column>
<Column field="createdAt" header="زمان ثبت" sortable>
<template #body="{ data }">
{{ formatJalali(data.createdAt, 'jYYYY/jMM/jDD HH:mm') }}
</template>
</Column>
<Column header="عملیات" style="width: 90px">
<template #body="{ data }">
<Button
icon="pi pi-eye"
text
rounded
size="small"
v-tooltip.top="'مشاهده و پیگیری'"
@click="$router.push(`/contact-inquiries/view/${data._id}`)"
/>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { contactInquiryApi } from '@/api/contactInquiryApi';
import { courseApi } from '@/api/courseApi';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
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 Dropdown from 'primevue/select';
import Tag from 'primevue/tag';
const { toPersianDigits, formatJalali } = usePersianDate();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch,
setFilter
} = useDataTable(contactInquiryApi.getAll, { sortBy: 'createdAt', sortOrder: 'desc' });
const courses = ref([]);
const selectedStatus = ref(null);
const selectedCourse = ref(null);
const statusOptions = [
{ label: 'جدید', value: 'new' },
{ label: 'مشاهده‌شده', value: 'seen' },
{ label: 'تماس بعدی', value: 'call_later' },
{ label: 'تماس گرفته شد', value: 'contacted' },
{ label: 'بسته', value: 'closed' }
];
const METHOD_LABELS = {
WhatsApp: 'واتساپ',
Telegram: 'تلگرام',
Soroush: 'سروش',
Bale: 'بله',
Eitaa: 'ایتا',
SMS: 'پیامک',
Call: 'تماس تلفنی'
};
const methodLabel = (value) => METHOD_LABELS[value] || value;
const onStatusFilter = () => setFilter('status', selectedStatus.value || '');
const onCourseFilter = () => setFilter('course', selectedCourse.value || '');
onMounted(async () => {
loadData();
try {
const res = await courseApi.getAll({ limit: 100 });
const data = res.data || res;
courses.value = Array.isArray(data) ? data : (data.items || data.courses || data.data || []);
} catch {
courses.value = [];
}
});
</script>
+112
View File
@@ -0,0 +1,112 @@
<!-- /src/views/courses/CourseDetailView.vue -->
<template>
<div class="course-detail-view" v-if="course">
<PageHeader :title="course.title" :subtitle="`استاد: ${course.professor?.name || course.professor || '-'}`">
<PermissionGate permission="courses:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
</PermissionGate>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/courses')" />
</PageHeader>
<div class="grid mb-4">
<div class="col-12 md:col-8">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<h3 class="text-lg font-bold mb-3">توضیحات دوره</h3>
<p class="text-color text-sm line-height-3">{{ course.description || 'توضیحاتی برای این دوره ثبت نشده است.' }}</p>
</div>
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
<h3 class="text-lg font-bold mb-3">جلسات دوره</h3>
<DataTable :value="courseSessions" class="p-datatable-sm text-sm">
<Column field="sessionNumber" header="#" style="width: 60px">
<template #body="{ data }">{{ toPersianDigits(data.sessionNumber) }}</template>
</Column>
<Column field="topic" header="موضوع جلسه" />
<Column field="date" header="تاریخ برگزاری">
<template #body="{ data }">{{ formatJalali(data.date) }}</template>
</Column>
<Column field="time" header="زمان">
<template #body="{ data }">{{ data.startTime }} - {{ data.endTime }}</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'Scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
<div class="col-12 md:col-4">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<h3 class="text-lg font-bold mb-3">اطلاعات تکمیلی</h3>
<div class="flex flex-column gap-3">
<div>
<span class="text-muted text-xs block mb-1">نوع دوره</span>
<Tag :value="course.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="course.type === 'Private' ? 'warning' : 'info'" />
</div>
<div>
<span class="text-muted text-xs block mb-1">شهریه</span>
<span class="font-bold text-color">{{ toPersianDigits(course.price?.toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">ظرفیت</span>
<span class="font-bold text-color">{{ toPersianDigits(course.capacity) }} نفر</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مدرک رسمی</span>
<Tag :value="course.isOfficial ? 'دارای مدرک رسمی' : 'بدون مدرک رسمی'" :severity="course.isOfficial ? 'success' : 'secondary'" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { courseApi } from '@/api/courseApi';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import PageHeader from '@/components/common/PageHeader.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const route = useRoute();
const courseId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const course = ref(null);
const courseSessions = ref([]);
const fetchDetail = async () => {
try {
const res = await courseApi.getOne(courseId);
course.value = res.data || res;
const sessionRes = await sessionApi.getAll({
courseId,
limit: 100,
sortBy: 'day',
sortOrder: 'asc'
});
const sessionData = sessionRes.data || sessionRes;
const list = Array.isArray(sessionData)
? sessionData
: (sessionData.items || sessionData.sessions || sessionData.data || []);
courseSessions.value = Array.isArray(list) ? list : [];
} catch (e) {
console.warn('Fetch course detail error:', e);
}
};
onMounted(() => {
fetchDetail();
});
</script>
+303
View File
@@ -0,0 +1,303 @@
<!-- /src/views/courses/CourseFormView.vue -->
<template>
<div class="course-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? $t('courses.editCourse') : $t('courses.addCourse')"
:subtitle="isEditMode ? 'ویرایش مشخصات دوره و مدیریت کلاس‌ها' : 'تعریف دوره آموزشی جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/courses')" />
</PageHeader>
<!-- Course Details -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-lg font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات اصلی دوره</h2>
<form @submit.prevent="handleSubmitCourse" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.courseTitle') }} *</label>
<InputText v-model.trim="form.title" class="w-full text-sm" :class="{ 'p-invalid': errors.title }" />
<small v-if="errors.title" class="text-red-500 text-xs">{{ errors.title }}</small>
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.type') }}</label>
<Dropdown v-model="form.type" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.price') }} *</label>
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" suffix=" تومان" />
<small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.professor') }}</label>
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="انتخاب استاد" class="w-full text-sm" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.capacity') }}</label>
<InputNumber v-model="form.capacity" class="w-full text-sm" :min="1" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تعداد جلسات</label>
<InputNumber v-model="form.sectionCount" class="w-full text-sm" :min="1" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت هر جلسه</label>
<InputNumber v-model="form.hoursPerSection" class="w-full text-sm" :min="0" :maxFractionDigits="1" />
</div>
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
<InputSwitch v-model="form.isOfficial" />
<label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
</div>
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
<InputSwitch v-model="form.showOnFrontend" />
<label class="font-semibold text-sm">نمایش در وبسایت</label>
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.description') }}</label>
<Textarea v-model="form.description" rows="3" class="w-full text-sm" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">نکات برجسته (هر خط یک مورد)</label>
<Textarea
v-model="highlightsText"
rows="4"
class="w-full text-sm"
placeholder="مثال:&#10;مناسب مبتدیان&#10;همراه با گواهی‌نامه&#10;تمرین عملی"
/>
</div>
<div class="col-12 flex justify-content-end mt-3">
<Button type="submit" label="ذخیره مشخصات دوره" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
<!-- Classes of this course (edit mode only) -->
<div v-if="isEditMode" class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div>
<h2 class="text-lg font-bold m-0">کلاسهای این دوره</h2>
<p class="text-muted text-sm m-0 mt-1">برای هر کلاس میتوانید دانشجویان و جلسات را جداگانه مدیریت کنید</p>
</div>
<Button
label="تعریف کلاس جدید"
icon="pi pi-plus"
@click="$router.push(`/classes/create?courseId=${courseId}`)"
/>
</div>
<TableSkeleton v-if="loadingClasses" :rows="5" :columns="6" />
<DataTable
v-else
:value="courseClasses"
class="p-datatable-sm text-sm"
emptyMessage="هنوز کلاسی برای این دوره تعریف نشده است"
>
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام کلاس">
<template #body="{ data }">
<span class="font-bold">{{ data.name }}</span>
</template>
</Column>
<Column header="استاد">
<template #body="{ data }">
{{ data.professor ? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() : '—' }}
</template>
</Column>
<Column header="ظرفیت" style="width: 90px">
<template #body="{ data }">{{ toPersianDigits(data.capacity || 0) }}</template>
</Column>
<Column header="دانشجو" style="width: 90px">
<template #body="{ data }">{{ toPersianDigits((data.students || []).length) }}</template>
</Column>
<Column header="وضعیت" style="width: 90px">
<template #body="{ data }">
<StatusTag :status="data.isActive !== false" />
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-eye"
text
rounded
size="small"
severity="info"
v-tooltip.top="'مشاهده'"
@click="$router.push(`/classes/view/${data._id || data.id}`)"
/>
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
v-tooltip.top="'ویرایش / جلسات'"
@click="$router.push(`/classes/edit/${data._id || data.id}`)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<div v-else class="surface-card p-4 border-round border-1 border-color shadow-sm">
<p class="text-muted text-sm m-0">پس از ذخیره دوره، میتوانید کلاسهای آن را در همین صفحه تعریف کنید. جلسات از داخل هر کلاس ساخته میشوند.</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { courseApi } from '@/api/courseApi';
import { professorApi } from '@/api/professorApi';
import { classApi } from '@/api/classApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/select';
import Textarea from 'primevue/textarea';
import InputSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const route = useRoute();
const router = useRouter();
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const courseId = route.params.id;
const isEditMode = computed(() => !!courseId);
const isSubmitting = ref(false);
const loadingClasses = ref(false);
const professors = ref([]);
const courseClasses = ref([]);
const typeOptions = [
{ label: 'عمومی', value: 'General' },
{ label: 'خصوصی', value: 'Private' }
];
const form = reactive({
title: '',
description: '',
type: 'General',
price: 1500000,
professor: null,
capacity: 20,
sectionCount: 12,
hoursPerSection: 1.5,
isOfficial: true,
showOnFrontend: true
});
const highlightsText = ref('');
const errors = reactive({
title: '',
price: ''
});
const fetchProfessors = async () => {
try {
const res = await professorApi.getAll({ limit: 100 });
const data = res.data || res;
professors.value = data.items || data.professors || data || [];
} catch (e) {
console.warn('Professors fetch error:', e);
}
};
const fetchClassesForCourse = async () => {
if (!courseId) return;
loadingClasses.value = true;
try {
const res = await classApi.getAll({ limit: 100, courseId });
const data = res.data || res;
courseClasses.value = Array.isArray(data) ? data : (data.items || data.data || []);
} catch (e) {
console.warn('Classes fetch error:', e);
} finally {
loadingClasses.value = false;
}
};
const fetchCourse = async () => {
if (!courseId) return;
try {
const res = await courseApi.getOne(courseId);
const data = res.data || res;
Object.assign(form, {
title: data.title || '',
description: data.description || '',
type: data.type || 'General',
price: data.price || 0,
professor: data.professor?._id || data.professor || null,
capacity: data.capacity || 20,
sectionCount: data.sectionCount || 1,
hoursPerSection: data.hoursPerSection ?? 1.5,
isOfficial: data.isOfficial !== false,
showOnFrontend: data.showOnFrontend !== false
});
highlightsText.value = Array.isArray(data.highlights) ? data.highlights.join('\n') : '';
} catch (err) {
showError(err);
}
};
const handleSubmitCourse = async () => {
if (!form.title) { errors.title = 'عنوان دوره الزامی است'; return; }
if (form.price === null) { errors.price = 'شهریه الزامی است'; return; }
isSubmitting.value = true;
try {
const payload = {
...form,
highlights: highlightsText.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
};
if (isEditMode.value) {
await courseApi.update(courseId, payload);
showSuccess('مشخصات دوره با موفقیت ویرایش شد');
} else {
const res = await courseApi.create(payload);
const newCourse = res.data || res;
showSuccess('دوره جدید با موفقیت ایجاد شد');
if (newCourse._id || newCourse.id) {
router.push(`/courses/edit/${newCourse._id || newCourse.id}`);
}
}
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchProfessors();
fetchCourse();
fetchClassesForCourse();
});
</script>
+144
View File
@@ -0,0 +1,144 @@
<!-- /src/views/courses/CourseListView.vue -->
<template>
<div class="course-list-view">
<PageHeader :title="$t('courses.title')" :subtitle="$t('courses.subtitle')">
<PermissionGate permission="courses:create">
<Button :label="$t('courses.addCourse')" icon="pi pi-plus" @click="$router.push('/courses/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="title" header="عنوان دوره" sortable>
<template #body="{ data }">
<router-link :to="`/courses/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.title }}
</router-link>
</template>
</Column>
<Column field="type" header="نوع دوره">
<template #body="{ data }">
<Tag :value="data.type === 'Private' ? $t('courses.typePrivate') : $t('courses.typeGeneral')" :severity="data.type === 'Private' ? 'warning' : 'info'" />
</template>
</Column>
<Column field="price" header="شهریه">
<template #body="{ data }">
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="rating" header="امتیاز">
<template #body="{ data }">
<Rating :modelValue="data.rating || 5" readonly :cancel="false" />
</template>
</Column>
<Column field="isOfficial" header="مدرک رسمی">
<template #body="{ data }">
<Tag :value="data.isOfficial ? $t('courses.official') : $t('courses.unofficial')" :severity="data.isOfficial ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="تخفیف فعال">
<template #body="{ data }">
<Tag v-if="data.activeDiscount" value="دارای تخفیف" severity="danger" />
<span v-else class="text-muted text-xs">-</span>
</template>
</Column>
<Column header="عملیات" style="width: 130px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="courses:read">
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/courses/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="courses:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/courses/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="courses:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { courseApi } from '@/api/courseApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Rating from 'primevue/rating';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(courseApi.getAll);
const deleteDialogVisible = ref(false);
const selectedCourse = ref(null);
const isDeleting = ref(false);
const confirmDelete = (course) => {
selectedCourse.value = course;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedCourse.value) return;
isDeleting.value = true;
try {
await courseApi.delete(selectedCourse.value._id || selectedCourse.value.id);
showSuccess('دوره آموزشی با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+432
View File
@@ -0,0 +1,432 @@
<!-- /src/views/dashboard/DashboardHomeView.vue -->
<template>
<div class="dashboard-home-view">
<PageHeader title="داشبورد مدیریتی" subtitle="خلاصه آمار، عملکرد و دسترسی‌های سریع سیستم">
<Button icon="pi pi-refresh" text rounded v-tooltip.top="'بروزرسانی'" :loading="isLoading" @click="loadStats" />
</PageHeader>
<!-- Stats Cards -->
<div class="grid mb-4">
<div class="col-12 sm:col-6 lg:col-3" v-for="stat in statCards" :key="stat.title">
<div class="stat-card surface-card p-4 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between gap-3">
<div class="flex-grow-1">
<span class="text-muted text-xs block mb-1">{{ stat.title }}</span>
<div class="flex align-items-baseline gap-2">
<span class="text-3xl font-bold text-color">
<span v-if="isLoading"></span>
<span v-else>{{ toPersianDigits(stat.value) }}</span>
</span>
<span v-if="stat.sub !== undefined && !isLoading" class="text-xs text-muted">
({{ toPersianDigits(stat.sub) }} فعال)
</span>
</div>
</div>
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="stat.bgClass">
<i :class="stat.icon" class="text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid mb-4">
<!-- Daily Enrollment Chart -->
<div class="col-12 lg:col-8">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<div class="flex align-items-center justify-content-between mb-4">
<h3 class="text-base font-bold text-color m-0">روند ثبتنام کاربران (۱۴ روز اخیر)</h3>
<Tag value="روزانه" severity="info" />
</div>
<div class="chart-container" style="height: 200px;">
<div v-if="isLoading" class="flex align-items-center justify-content-center h-full">
<ProgressSpinner style="width: 40px; height: 40px;" />
</div>
<div v-else-if="!dailyLabels.length" class="flex align-items-center justify-content-center h-full text-muted">
<span>دادهای برای نمایش وجود ندارد</span>
</div>
<div v-else class="bar-chart-wrapper h-full flex align-items-end gap-1">
<div
v-for="(item, idx) in dailyEnrollmentData"
:key="idx"
class="bar-group flex flex-column align-items-center gap-1 flex-grow-1"
>
<span class="text-xs font-bold text-primary">{{ toPersianDigits(item.count) }}</span>
<div
class="bar-fill border-round-top"
:style="{ height: getBarHeight(item.count, maxEnrollment) + 'px', minHeight: '4px' }"
></div>
<span class="bar-label text-muted text-center">{{ item.label }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Summary Doughnuts -->
<div class="col-12 lg:col-4">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<h3 class="text-base font-bold text-color m-0 mb-4">وضعیت فعال / کل</h3>
<div class="flex flex-column gap-3">
<div v-for="ring in ringStats" :key="ring.label" class="flex align-items-center gap-3">
<div class="ring-chart flex-shrink-0" :style="getRingStyle(ring)">
<span class="ring-pct text-xs font-bold">{{ ring.pct }}٪</span>
</div>
<div class="flex-grow-1">
<div class="text-sm font-semibold text-color">{{ ring.label }}</div>
<div class="text-xs text-muted mt-1">
<span class="font-bold" :class="ring.activeClass">{{ toPersianDigits(ring.active) }}</span>
<span class="mx-1">/</span>
<span>{{ toPersianDigits(ring.total) }} کل</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Access -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
<h3 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">دسترسی سریع</h3>
<div class="grid">
<div
v-for="link in quickLinks"
:key="link.to"
class="col-6 sm:col-4 lg:col-2"
>
<div
class="quick-link-card border-round-xl p-3 flex flex-column align-items-center gap-2 cursor-pointer text-center"
:class="link.cardClass"
@click="$router.push(link.to)"
>
<div class="quick-link-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center" :class="link.iconBg">
<i :class="[link.icon, 'text-xl', link.iconColor]"></i>
</div>
<span class="text-sm font-semibold text-color">{{ link.label }}</span>
<Tag v-if="link.badge !== undefined" :value="toPersianDigits(link.badge)" :severity="link.badgeSeverity || 'secondary'" class="text-xs" />
</div>
</div>
</div>
</div>
<!-- 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">
<h3 class="text-base font-bold text-color m-0">آخرین جلسات</h3>
<Button label="مشاهده همه" icon="pi pi-arrow-left" text size="small" @click="$router.push('/sessions')" />
</div>
<DataTable :value="recentSessions" class="p-datatable-sm text-sm" :loading="isLoading">
<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="class" header="کلاس">
<template #body="{ data }">
{{ data.class?.name || '—' }}
</template>
</Column>
<Column field="professor" header="استاد">
<template #body="{ data }">
{{
data.professor
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
: '—'
}}
</template>
</Column>
<Column field="day" header="تاریخ">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="time" header="ساعت">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
import { dashboardApi } from '@/api/dashboardApi';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import ProgressSpinner from 'primevue/progressspinner';
const { toPersianDigits, formatJalali } = usePersianDate();
// ── State ─────────────────────────────────────────────────────────────────────
const isLoading = ref(false);
const stats = ref({
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
recentSessions: [],
charts: { dailyUsers: [], dailySessions: [] }
});
// ── Stat Cards ────────────────────────────────────────────────────────────────
const statCards = computed(() => [
{
title: 'کل کاربران',
value: stats.value.totals.users,
sub: stats.value.totals.activeUsers,
icon: 'pi pi-users text-blue-500',
bgClass: 'bg-blue-100'
},
{
title: 'اساتید موسسه',
value: stats.value.totals.professors,
sub: stats.value.totals.activeProfessors,
icon: 'pi pi-id-card text-green-500',
bgClass: 'bg-green-100'
},
{
title: 'دوره‌های آموزشی',
value: stats.value.totals.courses,
icon: 'pi pi-book text-purple-500',
bgClass: 'bg-purple-100'
},
{
title: 'کل جلسات',
value: stats.value.totals.sessions,
icon: 'pi pi-calendar text-orange-500',
bgClass: 'bg-orange-100'
}
]);
// ── Ring Stats ────────────────────────────────────────────────────────────────
const ringStats = computed(() => {
const t = stats.value.totals;
return [
{
label: 'کاربران فعال',
total: t.users,
active: t.activeUsers,
pct: t.users ? Math.round((t.activeUsers / t.users) * 100) : 0,
color: '#3B82F6',
activeClass: 'text-blue-500'
},
{
label: 'اساتید فعال',
total: t.professors,
active: t.activeProfessors,
pct: t.professors ? Math.round((t.activeProfessors / t.professors) * 100) : 0,
color: '#10B981',
activeClass: 'text-green-500'
}
];
});
// ── Chart Helpers ─────────────────────────────────────────────────────────────
const dailyEnrollmentData = computed(() => {
const raw = stats.value.charts?.dailyUsers || [];
return raw.map((item) => ({
count: item.count || 0,
label: formatJalali(item.date || buildDateFromParts(item._id), 'jD jMMMM')
}));
});
const buildDateFromParts = (parts) => {
if (!parts?.year || !parts?.month || !parts?.day) return null;
return new Date(parts.year, parts.month - 1, parts.day);
};
const dailyLabels = computed(() => dailyEnrollmentData.value.map((i) => i.label));
const maxEnrollment = computed(() => {
const counts = dailyEnrollmentData.value.map((i) => i.count);
return Math.max(...counts, 1);
});
const MAX_BAR_HEIGHT = 140;
const getBarHeight = (val, max) => Math.max(4, (val / max) * MAX_BAR_HEIGHT);
const getRingStyle = (ring) => {
const deg = (ring.pct / 100) * 360;
return {
background: `conic-gradient(${ring.color} ${deg}deg, var(--surface-border) ${deg}deg)`,
borderRadius: '50%',
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative'
};
};
// ── Recent Sessions ───────────────────────────────────────────────────────────
const recentSessions = computed(() => stats.value.recentSessions || []);
// ── Quick Links ───────────────────────────────────────────────────────────────
const quickLinks = computed(() => [
{
to: '/users/create',
label: 'افزودن کاربر',
icon: 'pi pi-user-plus',
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
cardClass: 'quick-link-blue',
badge: stats.value.totals.users,
badgeSeverity: 'info'
},
{
to: '/professors/create',
label: 'افزودن استاد',
icon: 'pi pi-plus-circle',
iconBg: 'bg-green-100',
iconColor: 'text-green-600',
cardClass: 'quick-link-green',
badge: stats.value.totals.professors,
badgeSeverity: 'success'
},
{
to: '/courses/create',
label: 'دوره جدید',
icon: 'pi pi-book',
iconBg: 'bg-purple-100',
iconColor: 'text-purple-600',
cardClass: 'quick-link-purple',
badge: stats.value.totals.courses,
badgeSeverity: 'secondary'
},
{
to: '/sessions/create',
label: 'جلسه جدید',
icon: 'pi pi-calendar-plus',
iconBg: 'bg-orange-100',
iconColor: 'text-orange-600',
cardClass: 'quick-link-orange',
badge: stats.value.totals.sessions,
badgeSeverity: 'warning'
},
{
to: '/payments',
label: 'امور مالی',
icon: 'pi pi-wallet',
iconBg: 'bg-teal-100',
iconColor: 'text-teal-600',
cardClass: 'quick-link-teal'
},
{
to: '/roles',
label: 'نقش‌ها',
icon: 'pi pi-shield',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
cardClass: 'quick-link-red'
}
]);
// ── Load Data ─────────────────────────────────────────────────────────────────
const loadStats = async () => {
isLoading.value = true;
try {
const res = await dashboardApi.getStats();
const data = res.data || res;
// Support both { data: { totals... } } and { totals... } structures
stats.value = data.data || data;
} catch (err) {
console.warn('Dashboard stats not available, showing zeros:', err.message);
// Graceful fallback — zeros, no crash
} finally {
isLoading.value = false;
}
};
onMounted(() => {
loadStats();
});
</script>
<style scoped lang="scss">
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1) !important;
}
}
.stat-icon {
transition: transform 0.2s ease;
.stat-card:hover & {
transform: scale(1.05);
}
}
.bar-chart-wrapper {
padding-bottom: 4px;
}
.bar-fill {
width: 100%;
background: linear-gradient(to top, var(--primary-color), var(--primary-color-light, #818cf8));
min-width: 12px;
transition: height 0.4s ease;
}
.bar-label {
font-size: 9px;
line-height: 1.2;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ring-chart {
position: relative;
.ring-pct {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--surface-card);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
}
}
.quick-link-card {
background: var(--surface-ground);
transition: all 0.2s ease;
border: 1px solid transparent;
&:hover {
transform: translateY(-3px);
border-color: var(--primary-color);
background: var(--primary-50, rgba(99, 102, 241, 0.04));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<!-- /src/views/errors/ForbiddenView.vue -->
<template>
<div class="forbidden-view flex flex-column align-items-center justify-content-center min-h-screen text-center p-4">
<div class="inline-flex align-items-center justify-content-center w-6rem h-6rem border-circle bg-red-100 text-red-600 mb-4">
<i class="pi pi-lock text-5xl"></i>
</div>
<h1 class="text-4xl font-bold text-color mb-2">{{ $t('errors.forbiddenTitle') }}</h1>
<p class="text-muted text-base max-w-28rem mb-4">{{ $t('errors.forbiddenMessage') }}</p>
<Button :label="$t('errors.backToDashboard')" icon="pi pi-home" @click="$router.push('/')" />
</div>
</template>
<script setup>
import Button from 'primevue/button';
</script>
+15
View File
@@ -0,0 +1,15 @@
<!-- /src/views/errors/NotFoundView.vue -->
<template>
<div class="not-found-view flex flex-column align-items-center justify-content-center min-h-screen text-center p-4">
<div class="inline-flex align-items-center justify-content-center w-6rem h-6rem border-circle bg-orange-100 text-orange-600 mb-4">
<i class="pi pi-exclamation-circle text-5xl"></i>
</div>
<h1 class="text-4xl font-bold text-color mb-2">{{ $t('errors.notFoundTitle') }}</h1>
<p class="text-muted text-base max-w-28rem mb-4">{{ $t('errors.notFoundMessage') }}</p>
<Button :label="$t('errors.backToDashboard')" icon="pi pi-home" @click="$router.push('/')" />
</div>
</template>
<script setup>
import Button from 'primevue/button';
</script>
@@ -0,0 +1,131 @@
<!-- /src/views/notifications/NotificationListView.vue -->
<template>
<div class="notification-list-view">
<PageHeader :title="$t('notifications.title')" :subtitle="$t('notifications.subtitle')" />
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="recipient" header="گیرنده">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.user?.name || data.recipient || '-' }}</span>
<span class="text-xs text-muted block" dir="ltr">{{ data.user?.phoneNumber || data.targetAddress || '' }}</span>
</template>
</Column>
<Column field="channel" header="کانال ارسال">
<template #body="{ data }">
<Tag :value="getChannelLabel(data.channel)" :severity="getChannelSeverity(data.channel)" />
</template>
</Column>
<Column field="subject" header="موضوع / متن">
<template #body="{ data }">
<span class="text-sm truncate max-w-20rem block" :title="data.subject || data.content">
{{ data.subject || data.content || '-' }}
</span>
</template>
</Column>
<Column field="status" header="وضعیت تحویل">
<template #body="{ data }">
<StatusTag :status="data.status || 'delivered'" type="notification" />
</template>
</Column>
<Column field="retryCount" header="تعداد تلاش">
<template #body="{ data }">
{{ toPersianDigits(data.retryCount || 0) }}
</template>
</Column>
<Column header="عملیات" style="width: 120px">
<template #body="{ data }">
<PermissionGate permission="notifications:retry">
<Button
v-if="data.status === 'failed' || data.status === 'ناموفق'"
icon="pi pi-refresh"
label="تلاش مجدد"
size="small"
severity="warning"
:loading="retryingId === (data._id || data.id)"
@click="handleRetry(data)"
/>
</PermissionGate>
</template>
</Column>
</DataTableWrapper>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { notificationApi } from '@/api/notificationApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(notificationApi.getAll);
const retryingId = ref(null);
const getChannelLabel = (ch) => {
if (ch === 'bale') return 'پیام‌رسان بله';
if (ch === 'sms') return 'پیامک SMS';
if (ch === 'email') return 'ایمیل';
return ch || 'بله';
};
const getChannelSeverity = (ch) => {
if (ch === 'bale') return 'success';
if (ch === 'sms') return 'info';
if (ch === 'email') return 'warning';
return 'secondary';
};
const handleRetry = async (item) => {
const id = item._id || item.id;
retryingId.value = id;
try {
await notificationApi.retry(id);
showSuccess('ارسال مجدد با موفقیت درخواست شد');
loadData();
} catch (err) {
showError(err);
} finally {
retryingId.value = null;
}
};
onMounted(() => {
loadData();
});
</script>
+176
View File
@@ -0,0 +1,176 @@
<!-- /src/views/payments/PaymentDetailView.vue -->
<template>
<div class="payment-detail-view" v-if="payment">
<PageHeader :title="$t('payments.paymentDetail')" :subtitle="`کد صورتحساب: ${payment._id || payment.id}`">
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
</PageHeader>
<div class="grid mb-4">
<!-- Summary Card -->
<div class="col-12 lg:col-4">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<h3 class="text-lg font-bold mb-3">خلاصه صورتحساب</h3>
<div class="flex flex-column gap-3">
<div>
<span class="text-muted text-xs block mb-1">نام کاربر / دانشجو</span>
<span class="font-bold text-color text-base">{{ payment.user?.name || payment.userName || 'کاربر' }} {{ payment.user?.surname || '' }}</span>
</div>
<div v-if="payment.classes && payment.classes.length">
<span class="text-muted text-xs block mb-1">کلاسهای مربوطه</span>
<div class="flex flex-wrap gap-1 mt-1">
<Tag v-for="c in payment.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
</div>
</div>
<div v-else-if="payment.course">
<span class="text-muted text-xs block mb-1">دوره آموزشی</span>
<span class="font-semibold text-color text-sm">{{ payment.course?.title }}</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مبلغ کل صورتحساب</span>
<span class="font-bold text-color text-xl">{{ toPersianDigits(payment.amount?.toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مبلغ کل دریافتی</span>
<span class="font-bold text-green-600 text-lg">{{ toPersianDigits((payment.paidAmount || 0).toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">باقیمانده</span>
<span class="font-bold text-red-500 text-lg">{{ toPersianDigits((payment.amount - (payment.paidAmount || 0)).toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">وضعیت پرداخت</span>
<StatusTag :status="payment.status" type="payment" />
</div>
</div>
</div>
</div>
<!-- Transaction History & Form -->
<div class="col-12 lg:col-8">
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-lg font-bold m-0">تاریخچه تراکنشهای دریافتی</h3>
<PermissionGate permission="payments:update">
<Button label="ثبت تراکنش جدید" icon="pi pi-plus" size="small" severity="success" @click="showTransactionModal = true" />
</PermissionGate>
</div>
<DataTable :value="payment.transactions || []" class="p-datatable-sm text-sm">
<Column field="amount" header="مبلغ تراکنش">
<template #body="{ data }">
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="method" header="روش پرداخت">
<template #body="{ data }">
<Tag :value="data.method === 'online' ? 'درگاه آنلاین' : (data.method === 'card' ? 'کارت به کارت' : 'نقدی')" severity="info" />
</template>
</Column>
<Column field="receiptNumber" header="شماره فیش / پیگیری">
<template #body="{ data }">
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '-') }}</span>
</template>
</Column>
<Column field="createdAt" header="تاریخ ثبت">
<template #body="{ data }">
{{ formatJalali(data.createdAt || data.date) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Record New Transaction Modal -->
<Dialog v-model:visible="showTransactionModal" header="ثبت تراکنش جدید" modal :style="{ width: '450px' }">
<div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
<InputNumber v-model="trxForm.amount" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">روش پرداخت *</label>
<Dropdown v-model="trxForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">شماره فیش / پیگیری *</label>
<InputText v-model.trim="trxForm.receiptNumber" class="w-full text-sm" dir="ltr" />
</div>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showTransactionModal = false" />
<Button label="ثبت تراکنش" icon="pi pi-check" severity="success" :loading="isSubmittingTrx" @click="handleRecordTransaction" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { paymentApi } from '@/api/paymentApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
const route = useRoute();
const paymentId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const payment = ref(null);
const showTransactionModal = ref(false);
const isSubmittingTrx = ref(false);
const methodOptions = [
{ label: 'کارت به کارت', value: 'card' },
{ label: 'درگاه آنلاین', value: 'online' },
{ label: 'نقدی / شبا', value: 'cash' }
];
const trxForm = reactive({
amount: 500000,
method: 'card',
receiptNumber: ''
});
const fetchPayment = async () => {
try {
const res = await paymentApi.getOne(paymentId);
payment.value = res.data || res;
} catch (err) {
showError(err);
}
};
const handleRecordTransaction = async () => {
if (!trxForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
if (!trxForm.receiptNumber) { showError('لطفا شماره فیش یا پیگیری را وارد کنید'); return; }
isSubmittingTrx.value = true;
try {
await paymentApi.recordTransaction(paymentId, trxForm);
showSuccess('تراکنش جدید با موفقیت ثبت شد');
showTransactionModal.value = false;
fetchPayment();
} catch (err) {
showError(err);
} finally {
isSubmittingTrx.value = false;
}
};
onMounted(() => {
fetchPayment();
});
</script>
+250
View File
@@ -0,0 +1,250 @@
<!-- /src/views/payments/PaymentListView.vue -->
<template>
<div class="payment-list-view">
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
<PermissionGate permission="payments:create">
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="showCreateModal = true" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="user" header="نام دانشجو / کاربر">
<template #body="{ data }">
<router-link :to="`/payments/view/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.user?.name || data.userName || 'کاربر' }} {{ data.user?.surname || '' }}
</router-link>
</template>
</Column>
<Column field="classes" header="کلاس‌های مربوطه">
<template #body="{ data }">
<div class="flex flex-wrap gap-1" v-if="data.classes && data.classes.length">
<Tag v-for="c in data.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
</div>
<span v-else-if="data.course" class="text-muted text-xs">{{ data.course?.title }}</span>
<span v-else class="text-muted text-xs">-</span>
</template>
</Column>
<Column field="amount" header="مبلغ کل (تومان)" sortable>
<template #body="{ data }">
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان
</template>
</Column>
<Column field="paidAmount" header="مبلغ پرداختی">
<template #body="{ data }">
{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان
</template>
</Column>
<Column field="dueDate" header="تاریخ سررسید" sortable>
<template #body="{ data }">
{{ formatJalali(data.dueDate) }}
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status" type="payment" />
</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="payments:read">
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="payments:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<!-- Create Payment Modal -->
<Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '480px' }">
<div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
<Dropdown v-model="createForm.user" :options="usersList" optionLabel="fullName" optionValue="_id" filter placeholder="کاربر را انتخاب کنید" class="w-full text-sm" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس مربوطه *</label>
<MultiSelect
v-model="createForm.classes"
:options="classesList"
optionLabel="name"
optionValue="_id"
display="chip"
placeholder="کلاس‌ها را انتخاب کنید"
class="w-full text-sm"
@change="onClassesSelected"
/>
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ کل صورتحساب (تومان) *</label>
<InputNumber v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ سررسید *</label>
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
<Button label="ایجاد صورتحساب" icon="pi pi-check" severity="success" :loading="isCreating" @click="handleCreatePayment" />
</template>
</Dialog>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { paymentApi } from '@/api/paymentApi';
import { userApi } from '@/api/userApi';
import { classApi } from '@/api/classApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber';
import DatePicker from 'vue3-persian-datetime-picker';
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(paymentApi.getAll);
const showCreateModal = ref(false);
const usersList = ref([]);
const classesList = ref([]);
const isCreating = ref(false);
const createForm = reactive({
user: null,
classes: [],
amount: 0,
dueDate: ''
});
const deleteDialogVisible = ref(false);
const selectedPayment = ref(null);
const isDeleting = ref(false);
const onClassesSelected = () => {
if (!createForm.classes || createForm.classes.length === 0) return;
let totalFee = 0;
createForm.classes.forEach(classId => {
const c = classesList.value.find(item => (item._id || item.id) === classId);
if (c) {
totalFee += (c.tuitionFee || c.course?.price || 0);
}
});
if (totalFee > 0) {
createForm.amount = totalFee;
}
};
const fetchDropdownData = async () => {
try {
const [uRes, cRes] = await Promise.all([
userApi.getAll({ limit: 150 }),
classApi.getAll({ limit: 100 })
]);
const uData = uRes.data || uRes;
const rawUsers = uData.items || uData.users || uData || [];
usersList.value = rawUsers.map(u => ({ ...u, fullName: `${u.name} ${u.surname}` }));
const cData = cRes.data || cRes;
classesList.value = cData.items || cData.classes || cData || [];
} catch (e) {
console.warn('Dropdown fetch error:', e);
}
};
const handleCreatePayment = async () => {
if (!createForm.user) { showError('لطفا کاربر را انتخاب کنید'); return; }
if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
isCreating.value = true;
try {
await paymentApi.create(createForm);
showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
showCreateModal.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isCreating.value = false;
}
};
const confirmDelete = (payment) => {
selectedPayment.value = payment;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedPayment.value) return;
isDeleting.value = true;
try {
await paymentApi.delete(selectedPayment.value._id || selectedPayment.value.id);
showSuccess('صورتحساب با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
fetchDropdownData();
});
</script>
+112
View File
@@ -0,0 +1,112 @@
<!-- /src/views/professors/ProfessorFormView.vue -->
<template>
<div class="professor-form-view w-full max-w-4xl mx-auto">
<PageHeader
:title="isEditMode ? $t('professors.editProfessor') : $t('professors.addProfessor')"
:subtitle="isEditMode ? 'ویرایش رزومه و تخصص‌های استاد' : 'ثبت نام و اطلاعات استاد جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
<InputText v-model.trim="form.surname" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.phone') }} *</label>
<InputText v-model.trim="form.phone" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.email') }}</label>
<InputText v-model.trim="form.email" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('professors.bio') }}</label>
<Textarea v-model="form.bio" rows="3" class="w-full text-sm" />
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { professorApi } from '@/api/professorApi';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const professorId = route.params.id;
const isEditMode = computed(() => !!professorId);
const isSubmitting = ref(false);
const form = reactive({
name: '',
surname: '',
phone: '',
email: '',
bio: ''
});
const fetchProfessor = async () => {
if (!professorId) return;
try {
const res = await professorApi.getOne(professorId);
const data = res.data || res;
Object.assign(form, {
name: data.name || '',
surname: data.surname || '',
phone: data.phone || '',
email: data.email || '',
bio: data.bio || ''
});
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.name || !form.surname) { showError('نام و نام خانوادگی الزامی است'); return; }
isSubmitting.value = true;
try {
if (isEditMode.value) {
await professorApi.update(professorId, form);
showSuccess('اطلاعات استاد با موفقیت ویرایش شد');
} else {
await professorApi.create(form);
showSuccess('استاد جدید با موفقیت ایجاد شد');
}
router.push('/professors');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchProfessor();
});
</script>
+130
View File
@@ -0,0 +1,130 @@
<!-- /src/views/professors/ProfessorListView.vue -->
<template>
<div class="professor-list-view">
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
<PermissionGate permission="professors:create">
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام و نام خانوادگی" sortable>
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
</template>
</Column>
<Column field="specialization" header="تخصص">
<template #body="{ data }">
<Tag :value="data.specialization || 'عمومی'" severity="secondary" />
</template>
</Column>
<Column field="phoneNumber" header="شماره همراه">
<template #body="{ data }">
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
</template>
</Column>
<Column field="email" header="ایمیل">
<template #body="{ data }">
{{ data.email || '-' }}
</template>
</Column>
<Column field="activeCoursesCount" header="دوره‌های فعال">
<template #body="{ data }">
{{ toPersianDigits(data.activeCoursesCount || data.courses?.length || 0) }}
</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="professors:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/professors/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="professors:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { professorApi } from '@/api/professorApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(professorApi.getAll);
const deleteDialogVisible = ref(false);
const selectedProf = ref(null);
const isDeleting = ref(false);
const confirmDelete = (prof) => {
selectedProf.value = prof;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedProf.value) return;
isDeleting.value = true;
try {
await professorApi.delete(selectedProf.value._id || selectedProf.value.id);
showSuccess('استاد با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+306
View File
@@ -0,0 +1,306 @@
<!-- /src/views/roles/RoleFormView.vue -->
<template>
<div class="role-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? $t('roles.editRole') : $t('roles.addRole')"
:subtitle="isEditMode ? 'ویرایش مشخصات نقش و سطح دسترسی‌ها' : 'ایجاد نقش سیستمی جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
</PageHeader>
<div class="grid">
<!-- Basic Info -->
<div class="col-12">
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<h2 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات نقش</h2>
<div class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('roles.roleName') }} *</label>
<InputText
v-model.trim="form.name"
class="w-full text-sm"
:disabled="isSystemRole"
:placeholder="isSystemRole ? 'نقش‌های سیستمی قابل تغییر نام نیستند' : 'مثلا: مشاور، حسابدار'"
/>
<small v-if="isSystemRole" class="text-orange-500 text-xs flex align-items-center gap-1">
<i class="pi pi-lock text-xs"></i> نقش سیستمی نام قابل تغییر نیست
</small>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">توضیحات نقش</label>
<InputText
v-model.trim="form.description"
class="w-full text-sm"
:disabled="isSystemRole"
placeholder="شرح وظایف و حوزه دسترسی این نقش..."
/>
</div>
</div>
</div>
</div>
<!-- Permissions -->
<div class="col-12">
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-4 pb-2 border-bottom-1 border-color">
<h2 class="text-base font-bold text-color m-0">{{ $t('roles.permissionsTitle') }}</h2>
<div class="flex align-items-center gap-2">
<span class="text-muted text-xs">{{ selectedCount }} از {{ totalCount }} مجوز انتخاب شده</span>
<Button
:label="isAllSelected ? 'لغو انتخاب همه' : 'انتخاب همه مجوزها'"
:icon="isAllSelected ? 'pi pi-times-circle' : 'pi pi-check-circle'"
size="small"
:severity="isAllSelected ? 'secondary' : 'success'"
text
:disabled="isSystemRole"
@click="toggleSelectAll"
/>
</div>
</div>
<div v-if="isSystemRole" class="surface-ground p-3 border-round border-1 border-color mb-4 text-sm flex align-items-center gap-2">
<i class="pi pi-info-circle text-primary"></i>
<span>مجوزهای نقشهای سیستمی قابل تغییر نیستند. برای دیدن مجوزهای فعال این نقش، لیست زیر را مشاهده کنید.</span>
</div>
<div class="grid">
<div
v-for="group in permissionGroups"
:key="group.key"
class="col-12 md:col-6 xl:col-4 mb-3"
>
<div class="permission-group border-round border-1 border-color overflow-hidden">
<!-- Group Header -->
<div
class="group-header p-3 flex align-items-center justify-content-between cursor-pointer"
:class="getGroupHeaderClass(group)"
@click="!isSystemRole && toggleGroup(group)"
>
<div class="flex align-items-center gap-2">
<i :class="group.icon" class="text-base"></i>
<span class="font-semibold text-sm">{{ group.label }}</span>
</div>
<div class="flex align-items-center gap-2">
<Badge
:value="`${getGroupSelectedCount(group)}/${group.permissions.length}`"
:severity="getGroupSelectedCount(group) === group.permissions.length ? 'success' : getGroupSelectedCount(group) > 0 ? 'warning' : 'secondary'"
/>
<Checkbox
:modelValue="isGroupFullySelected(group)"
:indeterminate="isGroupPartiallySelected(group)"
:disabled="isSystemRole"
binary
@click.stop="!isSystemRole && toggleGroup(group)"
/>
</div>
</div>
<!-- Permission Items -->
<div class="group-items">
<div
v-for="perm in group.permissions"
:key="perm.key"
class="perm-item flex align-items-center gap-2 px-3 py-2 border-top-1 border-color"
:class="{ 'perm-active': form.permissions.includes(perm.key) }"
>
<Checkbox
v-model="form.permissions"
:value="perm.key"
:disabled="isSystemRole"
/>
<label
:for="`perm-${perm.key}`"
class="text-xs flex-grow-1"
:class="{ 'font-semibold text-primary': form.permissions.includes(perm.key), 'cursor-pointer': !isSystemRole }"
@click="!isSystemRole && togglePermission(perm.key)"
>
{{ perm.label }}
</label>
<code class="text-xs text-muted" style="font-size: 10px;">{{ perm.key }}</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Submit -->
<div class="col-12">
<div class="flex justify-content-end gap-2">
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
<Button
:label="$t('app.save')"
icon="pi pi-check"
:loading="isSubmitting"
:disabled="isSystemRole"
@click="handleSubmit"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { roleApi } from '@/api/roleApi';
import { useToast } from '@/composables/useToast';
import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/constants/permissions';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Checkbox from 'primevue/checkbox';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const roleId = route.params.id;
const isEditMode = computed(() => !!roleId);
const isSubmitting = ref(false);
const isSystemRole = ref(false);
const permissionGroups = PERMISSION_GROUPS;
const totalCount = ALL_PERMISSIONS.length;
const form = reactive({
name: '',
description: '',
permissions: []
});
// ── Computed helpers ──────────────────────────────────────────────────────────
const selectedCount = computed(() => form.permissions.length);
const isAllSelected = computed(
() => form.permissions.length === ALL_PERMISSIONS.length
);
const isGroupFullySelected = (group) =>
group.permissions.every(p => form.permissions.includes(p.key));
const isGroupPartiallySelected = (group) => {
const count = group.permissions.filter(p => form.permissions.includes(p.key)).length;
return count > 0 && count < group.permissions.length;
};
const getGroupSelectedCount = (group) =>
group.permissions.filter(p => form.permissions.includes(p.key)).length;
const getGroupHeaderClass = (group) => {
if (isGroupFullySelected(group)) return 'bg-green-50';
if (isGroupPartiallySelected(group)) return 'bg-orange-50';
return 'surface-ground';
};
// ── Actions ───────────────────────────────────────────────────────────────────
const toggleSelectAll = () => {
if (isAllSelected.value) {
form.permissions = [];
} else {
form.permissions = [...ALL_PERMISSIONS];
}
};
const toggleGroup = (group) => {
const groupKeys = group.permissions.map(p => p.key);
if (isGroupFullySelected(group)) {
form.permissions = form.permissions.filter(p => !groupKeys.includes(p));
} else {
const toAdd = groupKeys.filter(k => !form.permissions.includes(k));
form.permissions = [...form.permissions, ...toAdd];
}
};
const togglePermission = (key) => {
const idx = form.permissions.indexOf(key);
if (idx === -1) {
form.permissions.push(key);
} else {
form.permissions.splice(idx, 1);
}
};
// ── API ───────────────────────────────────────────────────────────────────────
const fetchRole = async () => {
if (!roleId) return;
try {
const res = await roleApi.getOne(roleId);
const data = res.data || res;
isSystemRole.value = !!data.isSystem;
Object.assign(form, {
name: data.name || '',
description: data.description || '',
permissions: data.permissions || []
});
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.name) { showError('نام نقش الزامی است'); return; }
if (isSystemRole.value) { showError('نقش‌های سیستمی قابل ویرایش نیستند'); return; }
isSubmitting.value = true;
try {
if (isEditMode.value) {
await roleApi.update(roleId, form);
showSuccess('نقش با موفقیت ویرایش شد');
} else {
await roleApi.create(form);
showSuccess('نقش جدید با موفقیت ایجاد شد');
}
router.push('/roles');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchRole();
});
</script>
<style scoped lang="scss">
.permission-group {
background: var(--surface-card);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
.group-header {
transition: background-color 0.15s ease;
&:hover {
background-color: var(--surface-hover) !important;
}
}
.perm-item {
transition: background-color 0.12s ease;
&:hover {
background-color: var(--surface-hover);
}
&.perm-active {
background-color: var(--primary-color-light);
}
}
.bg-green-50 {
background-color: rgba(16, 185, 129, 0.08) !important;
}
.bg-orange-50 {
background-color: rgba(249, 115, 22, 0.06) !important;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<!-- /src/views/roles/RoleListView.vue -->
<template>
<div class="role-list-view">
<PageHeader :title="$t('roles.title')" :subtitle="$t('roles.subtitle')">
<PermissionGate permission="roles:create">
<Button :label="$t('roles.addRole')" icon="pi pi-shield" @click="$router.push('/roles/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام نقش" sortable>
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }}</span>
<Tag v-if="data.isSystem" value="سیستمی" severity="danger" class="mr-2 text-xs" />
</template>
</Column>
<Column field="description" header="توضیحات">
<template #body="{ data }">
{{ data.description || '-' }}
</template>
</Column>
<Column field="permissionCount" header="تعداد مجوزها">
<template #body="{ data }">
<Badge :value="toPersianDigits(data.permissions?.length || 0)" severity="info" />
</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="roles:update">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
:disabled="data.isSystem"
v-tooltip.top="data.isSystem ? $t('roles.systemRoleWarning') : 'ویرایش'"
@click="$router.push(`/roles/edit/${data._id || data.id}`)"
/>
</PermissionGate>
<PermissionGate permission="roles:delete">
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
:disabled="data.isSystem"
v-tooltip.top="data.isSystem ? $t('roles.systemRoleWarning') : 'حذف'"
@click="confirmDelete(data)"
/>
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { roleApi } from '@/api/roleApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Badge from 'primevue/badge';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(roleApi.getAll);
const deleteDialogVisible = ref(false);
const selectedRole = ref(null);
const isDeleting = ref(false);
const confirmDelete = (role) => {
if (role.isSystem) return;
selectedRole.value = role;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedRole.value) return;
isDeleting.value = true;
try {
await roleApi.delete(selectedRole.value._id || selectedRole.value.id);
showSuccess('نقش با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+247
View File
@@ -0,0 +1,247 @@
<!-- /src/views/sessions/SessionFormView.vue -->
<template>
<div class="session-form-view w-full max-w-5xl mx-auto">
<PageHeader
:title="isEditMode ? $t('sessions.editSession') : $t('sessions.addSession')"
:subtitle="isEditMode ? 'ویرایش اطلاعات جلسه آموزشی' : 'تعریف جلسه جدید'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب دوره *</label>
<Dropdown
v-model="form.course"
:options="courses"
optionLabel="title"
optionValue="_id"
placeholder="دوره را انتخاب کنید"
class="w-full text-sm"
filter
@change="onCourseChange"
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">کلاس *</label>
<Dropdown
v-model="form.class"
:options="classes"
optionLabel="name"
optionValue="_id"
placeholder="کلاس را انتخاب کنید"
class="w-full text-sm"
:disabled="!form.course"
filter
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">استاد مدرس *</label>
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="استاد را انتخاب کنید" class="w-full text-sm" filter />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('sessions.date') }} *</label>
<DatePicker v-model="form.day" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.startTime') }} *</label>
<InputText v-model="form.startTime" class="w-full text-sm" dir="ltr" placeholder="19:00" />
</div>
<div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.endTime') }} *</label>
<InputText v-model="form.endTime" class="w-full text-sm" dir="ltr" placeholder="20:30" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">وضعیت جلسه</label>
<Dropdown v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">مکان برگزاری</label>
<InputText v-model="form.place" class="w-full text-sm" placeholder="کلاس / آنلاین / آدرس" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('sessions.topic') }}</label>
<InputText v-model="form.topic" class="w-full text-sm" placeholder="عنوان یا موضوع این جلسه" />
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">یادداشت جلسه</label>
<Textarea v-model="form.note" rows="3" class="w-full text-sm" placeholder="یادداشت داخلی درباره این جلسه…" />
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import moment from 'jalali-moment';
import { sessionApi } from '@/api/sessionApi';
import { courseApi } from '@/api/courseApi';
import { classApi } from '@/api/classApi';
import { professorApi } from '@/api/professorApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Dropdown from 'primevue/select';
import DatePicker from 'vue3-persian-datetime-picker';
import Button from 'primevue/button';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const { toLatinDigits } = usePersianDate();
const sessionId = route.params.id;
const isEditMode = computed(() => !!sessionId);
const isSubmitting = ref(false);
const courses = ref([]);
const classes = ref([]);
const professors = ref([]);
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزارشده', value: 'held' },
{ label: 'لغوشده', value: 'cancelled' }
];
const form = reactive({
course: null,
class: null,
professor: null,
day: moment().locale('fa').format('jYYYY/jMM/jDD'),
startTime: '19:00',
endTime: '20:30',
status: 'scheduled',
topic: '',
place: '',
note: ''
});
const toGregorianIso = (jalaliValue) => {
if (!jalaliValue) return null;
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
const latin = toLatinDigits(String(jalaliValue));
const m = moment(latin, 'jYYYY/jMM/jDD');
return m.isValid() ? m.toDate().toISOString() : null;
};
const toJalaliDisplay = (value) => {
if (!value) return '';
return moment(value).locale('fa').format('jYYYY/jMM/jDD');
};
const loadClasses = async (courseId) => {
if (!courseId) {
classes.value = [];
return;
}
const res = await classApi.getAll({ limit: 100, courseId });
const data = res.data || res;
classes.value = Array.isArray(data) ? data : (data.items || data.data || []);
};
const onCourseChange = async () => {
form.class = null;
await loadClasses(form.course);
};
const fetchData = async () => {
try {
const [cRes, pRes] = await Promise.all([
courseApi.getAll({ limit: 100 }),
professorApi.getAll({ limit: 100 })
]);
const cData = cRes.data || cRes;
courses.value = Array.isArray(cData) ? cData : (cData.items || cData.courses || cData.data || []);
const pData = pRes.data || pRes;
professors.value = (Array.isArray(pData) ? pData : (pData.items || pData.professors || pData.data || [])).map((p) => ({
...p,
name: `${p.name || ''} ${p.surname || ''}`.trim()
}));
if (sessionId) {
const res = await sessionApi.getOne(sessionId);
const data = res.data || res;
const courseId = data.course?._id || data.course || null;
await loadClasses(courseId);
Object.assign(form, {
course: courseId,
class: data.class?._id || data.class || null,
professor: data.professor?._id || data.professor || null,
day: toJalaliDisplay(data.day || data.date),
startTime: data.startTime || '19:00',
endTime: data.endTime || '20:30',
status: data.status || 'scheduled',
topic: data.topic || '',
place: data.place || '',
note: data.note || ''
});
}
} catch (err) {
showError(err);
}
};
const handleSubmit = async () => {
if (!form.course) { showError('لطفا دوره را انتخاب کنید'); return; }
if (!form.class) { showError('لطفا کلاس را انتخاب کنید'); return; }
if (!form.professor) { showError('لطفا استاد را انتخاب کنید'); return; }
if (!form.day) { showError('لطفا تاریخ جلسه را وارد کنید'); return; }
if (!form.startTime || !form.endTime) { showError('ساعت شروع و پایان الزامی است'); return; }
const dayIso = toGregorianIso(form.day);
if (!dayIso) { showError('تاریخ جلسه نامعتبر است'); return; }
isSubmitting.value = true;
try {
const payload = {
course: form.course,
class: form.class,
professor: form.professor,
day: dayIso,
startTime: form.startTime,
endTime: form.endTime,
status: form.status,
topic: form.topic,
place: form.place,
note: form.note
};
if (isEditMode.value) {
await sessionApi.update(sessionId, payload);
showSuccess('جلسه با موفقیت ویرایش شد');
} else {
await sessionApi.create(payload);
showSuccess('جلسه جدید با موفقیت ایجاد شد');
}
router.push('/sessions');
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(fetchData);
</script>
+255
View File
@@ -0,0 +1,255 @@
<!-- /src/views/sessions/SessionListView.vue -->
<template>
<div class="session-list-view">
<PageHeader :title="$t('sessions.title')" :subtitle="$t('sessions.subtitle')">
<PermissionGate permission="sessions:create">
<Button :label="$t('sessions.addSession')" icon="pi pi-plus" @click="$router.push('/sessions/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
selectable
v-model:selection="selectedSessions"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<template #toolbar>
<div v-if="selectedSessions.length" class="flex align-items-center gap-2 flex-wrap justify-content-end">
<Tag :value="`${toPersianDigits(selectedSessions.length)} انتخاب‌شده`" severity="info" />
<PermissionGate permission="sessions:update">
<Select
v-model="bulkStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="تغییر وضعیت"
class="text-sm"
style="min-width: 10rem"
/>
<Button
label="اعمال وضعیت"
icon="pi pi-check"
size="small"
:loading="isBulkUpdating"
:disabled="!bulkStatus"
@click="applyBulkStatus"
/>
</PermissionGate>
<PermissionGate permission="sessions:delete">
<Button
label="حذف انتخاب‌شده‌ها"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="confirmBulkDelete"
/>
</PermissionGate>
<Button label="لغو انتخاب" text size="small" severity="secondary" @click="selectedSessions = []" />
</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 }">
<span class="font-bold text-color">{{ data.course?.title || data.courseTitle || '-' }}</span>
</template>
</Column>
<Column field="class" header="کلاس">
<template #body="{ data }">
{{ data.class?.name || '—' }}
</template>
</Column>
<Column field="professor" header="استاد مدرس">
<template #body="{ data }">
{{
data.professor
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
: (data.professorName || '—')
}}
</template>
</Column>
<Column field="date" header="تاریخ برگزاری" sortable sortField="day">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="timeRange" header="زمان برگزاری">
<template #body="{ data }">
{{ data.startTime || '—' }} - {{ data.endTime || '—' }}
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
<Column header="عملیات" style="width: 170px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="sessions:attendance">
<Button
icon="pi pi-check-square"
text
rounded
size="small"
severity="success"
v-tooltip.top="$t('sessions.enterAttendance')"
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
</PermissionGate>
<PermissionGate permission="sessions:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/sessions/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="sessions:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
:title="bulkDeleteMode ? 'حذف گروهی جلسات' : ''"
:message="bulkDeleteMessage"
@confirm="handleDeleteConfirm"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { sessionApi } from '@/api/sessionApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Select from 'primevue/select';
const { formatJalali, toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(sessionApi.getAll, {
sortBy: 'day',
sortOrder: 'asc'
});
const selectedSessions = ref([]);
const bulkStatus = ref(null);
const isBulkUpdating = ref(false);
const deleteDialogVisible = ref(false);
const selectedSession = ref(null);
const bulkDeleteMode = ref(false);
const isDeleting = ref(false);
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزار شده', value: 'held' },
{ label: 'لغو شده', value: 'cancelled' }
];
const selectedIds = computed(() =>
selectedSessions.value.map((s) => s._id || s.id).filter(Boolean)
);
const bulkDeleteMessage = computed(() => {
if (bulkDeleteMode.value) {
return `آیا از حذف ${toPersianDigits(selectedSessions.value.length)} جلسه انتخاب‌شده اطمینان دارید؟ این عملیات قابل بازگشت نیست.`;
}
return 'آیا از حذف این جلسه اطمینان دارید؟ این عملیات قابل بازگشت نیست.';
});
const confirmDelete = (session) => {
bulkDeleteMode.value = false;
selectedSession.value = session;
deleteDialogVisible.value = true;
};
const confirmBulkDelete = () => {
if (!selectedSessions.value.length) return;
bulkDeleteMode.value = true;
selectedSession.value = null;
deleteDialogVisible.value = true;
};
const handleDeleteConfirm = async () => {
isDeleting.value = true;
try {
if (bulkDeleteMode.value) {
const res = await sessionApi.bulkDelete(selectedIds.value);
const deletedCount = res?.data?.deletedCount ?? selectedIds.value.length;
showSuccess(`${toPersianDigits(deletedCount)} جلسه حذف شد`);
selectedSessions.value = [];
} else if (selectedSession.value) {
await sessionApi.delete(selectedSession.value._id || selectedSession.value.id);
showSuccess('جلسه با موفقیت حذف شد');
}
deleteDialogVisible.value = false;
await loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
const applyBulkStatus = async () => {
if (!selectedIds.value.length || !bulkStatus.value) return;
isBulkUpdating.value = true;
try {
const res = await sessionApi.bulkUpdateStatus(selectedIds.value, bulkStatus.value);
const updatedCount = res?.data?.updatedCount ?? selectedIds.value.length;
showSuccess(`وضعیت ${toPersianDigits(updatedCount)} جلسه به‌روز شد`);
bulkStatus.value = null;
selectedSessions.value = [];
await loadData();
} catch (err) {
showError(err);
} finally {
isBulkUpdating.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+267
View File
@@ -0,0 +1,267 @@
<!-- /src/views/users/UserDetailView.vue -->
<template>
<div class="user-detail-view" v-if="user">
<PageHeader :title="`${user.name || ''} ${user.surname || ''}`" :subtitle="`کد ملی: ${toPersianDigits(user.nationalId)}`">
<PermissionGate permission="users:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
</PermissionGate>
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/users')" />
</PageHeader>
<!-- Main Profile Tabs -->
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
<TabList>
<Tab value="0">{{ $t('users.tabInfo') }}</Tab>
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
<Tab value="4">{{ $t('users.tabCertificates') }}</Tab>
</TabList>
<TabPanels>
<!-- Tab 1: User Info -->
<TabPanel value="0">
<div class="grid p-3">
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">نام و نام خانوادگی</span>
<span class="font-bold text-color text-base">{{ user.name }} {{ user.surname }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">کد ملی</span>
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalId) }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">شماره همراه</span>
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone) }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">پست الکترونیکی</span>
<span class="font-bold text-color text-base">{{ user.email || '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">پیامرسان ترجیحی</span>
<div class="flex flex-wrap gap-1">
<Tag
v-for="messenger in (Array.isArray(user.preferredMessenger) ? user.preferredMessenger : (user.preferredMessenger ? [user.preferredMessenger] : ['SMS']))"
:key="messenger"
:value="messenger"
severity="info"
/>
</div>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">نقش سیستم</span>
<Tag :value="user.role?.name || 'دانشجو'" severity="secondary" />
</div>
<div class="col-12 mb-3">
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
<span class="text-color text-sm">{{ user.address || '-' }}</span>
</div>
</div>
</TabPanel>
<!-- Tab 2: Enrolled Courses -->
<TabPanel value="1">
<div class="p-3">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-lg font-bold m-0">دورههای ثبتنام شده</h3>
<PermissionGate permission="users:enroll">
<Button label="ثبت‌نام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
</PermissionGate>
</div>
<DataTable :value="enrolledCourses" class="p-datatable-sm text-sm">
<Column field="title" header="عنوان دوره" />
<Column field="type" header="نوع">
<template #body="{ data }">
<Tag :value="data.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="data.type === 'Private' ? 'warning' : 'info'" />
</template>
</Column>
<Column field="price" header="شهریه">
<template #body="{ data }">
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="enrollDate" header="تاریخ ثبت‌نام">
<template #body="{ data }">
{{ formatJalali(data.enrollDate || data.createdAt) }}
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 3: Sessions & Attendance -->
<TabPanel value="2">
<div class="p-3">
<h3 class="text-lg font-bold mb-3">تاریخچه حضور و غیاب دانشجو</h3>
<DataTable :value="attendances" class="p-datatable-sm text-sm">
<Column field="sessionTitle" header="عنوان جلسه / دوره" />
<Column field="date" header="تاریخ جلسه">
<template #body="{ data }">
{{ formatJalali(data.date) }}
</template>
</Column>
<Column field="status" header="وضعیت حضور">
<template #body="{ data }">
<Tag :value="data.status" :severity="getAttendanceSeverity(data.status)" />
</template>
</Column>
<Column field="note" header="یادداشت" />
</DataTable>
</div>
</TabPanel>
<!-- Tab 4: Payments -->
<TabPanel value="3">
<div class="p-3">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-lg font-bold m-0">صورتحسابها و پرداختها</h3>
<PermissionGate permission="payments:update">
<Button label="ثبت پرداخت جدید" icon="pi pi-wallet" size="small" severity="success" @click="$router.push('/payments')" />
</PermissionGate>
</div>
<DataTable :value="payments" class="p-datatable-sm text-sm">
<Column field="amount" header="مبلغ کل">
<template #body="{ data }">
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="paidAmount" header="پرداختی">
<template #body="{ data }">
{{ toPersianDigits(data.paidAmount?.toLocaleString()) }} تومان
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status" type="payment" />
</template>
</Column>
<Column field="dueDate" header="تاریخ سررسید">
<template #body="{ data }">
{{ formatJalali(data.dueDate) }}
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 5: Certificates -->
<TabPanel value="4">
<div class="p-3">
<h3 class="text-lg font-bold mb-3">گواهینامههای صادر شده دانشجو</h3>
<CertificateUploader v-model="userCertificates" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Enroll Modal -->
<Dialog v-model:visible="showEnrollModal" header="ثبت‌نام دانشجو در دوره جدید" modal :style="{ width: '450px' }">
<div class="flex flex-column gap-3 py-2">
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
<Dropdown
v-model="selectedCourseId"
:options="allCourses"
optionLabel="title"
optionValue="_id"
placeholder="دوره را انتخاب کنید"
class="w-full text-sm"
/>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showEnrollModal = false" />
<Button label="تایید ثبت‌نام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { userApi } from '@/api/userApi';
import { courseApi } from '@/api/courseApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import CertificateUploader from '@/components/uploader/CertificateUploader.vue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select';
const route = useRoute();
const userId = route.params.id;
const { toPersianDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const user = ref(null);
const enrolledCourses = ref([]);
const attendances = ref([]);
const payments = ref([]);
const userCertificates = ref([]);
const showEnrollModal = ref(false);
const allCourses = ref([]);
const selectedCourseId = ref(null);
const isEnrolling = ref(false);
const getAttendanceSeverity = (status) => {
if (status === 'حاضر' || status === 'present') return 'success';
if (status === 'غایب' || status === 'absent') return 'danger';
if (status === 'تاخیر' || status === 'late') return 'warning';
return 'info';
};
const fetchUserDetail = async () => {
try {
const res = await userApi.getOne(userId);
user.value = res.data || res;
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
payments.value = user.value.payments || [];
userCertificates.value = user.value.certificates || [];
} catch (err) {
showError(err);
}
};
const fetchAllCourses = async () => {
try {
const res = await courseApi.getAll({ limit: 100 });
const data = res.data || res;
allCourses.value = data.items || data.courses || data || [];
} catch (e) {
console.warn('Courses fetch error:', e);
}
};
const handleEnroll = async () => {
if (!selectedCourseId.value) return;
isEnrolling.value = true;
try {
await userApi.enroll(userId, { courseId: selectedCourseId.value });
showSuccess('دانشجو با موفقیت در دوره ثبت‌نام شد');
showEnrollModal.value = false;
fetchUserDetail();
} catch (err) {
showError(err);
} finally {
isEnrolling.value = false;
}
};
onMounted(() => {
fetchUserDetail();
fetchAllCourses();
});
</script>
+276
View File
@@ -0,0 +1,276 @@
<!-- /src/views/users/UserFormView.vue -->
<template>
<div class="user-form-view w-full max-w-4xl mx-auto">
<PageHeader
:title="isEditMode ? $t('users.editUser') : $t('users.addUser')"
:subtitle="isEditMode ? 'ویرایش اطلاعات حساب کاربر' : 'ایجاد حساب کاربری جدید — نام کاربری و رمز عبور به‌صورت خودکار ساخته و پیامک می‌شود'"
>
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
</PageHeader>
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
<InputText v-model.trim="form.surname" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.nationalId') }} *</label>
<InputText v-model.trim="form.nationalId" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.phone') }} *</label>
<InputText v-model.trim="form.phone" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.email') }}</label>
<InputText v-model.trim="form.email" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.role') }} *</label>
<Dropdown v-model="form.role" :options="roles" optionLabel="name" optionValue="_id" placeholder="انتخاب نقش" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.preferredMessenger') }}</label>
<MultiSelect
v-model="form.preferredMessenger"
:options="messengerOptions"
optionLabel="label"
optionValue="value"
display="chip"
placeholder="اختیاری — چند مورد قابل انتخاب"
class="w-full text-sm preferred-messenger-select"
:showClear="false"
:maxSelectedLabels="3"
selectedItemsLabel="{0} مورد انتخاب شده"
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
<label class="font-semibold text-sm">{{ $t('auth.username') }}</label>
<InputText
v-model.trim="form.username"
class="w-full text-sm"
dir="ltr"
disabled
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
<label class="font-semibold text-sm">رمز عبور جدید (اختیاری)</label>
<InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" />
</div>
<div v-if="!isEditMode" class="col-12">
<p class="text-sm text-color-secondary m-0 line-height-3">
نام کاربری و رمز عبور ساده بهصورت خودکار ساخته میشود و از طریق پیامک برای کاربر ارسال میگردد. بعداً میتوان رمز را تغییر داد.
</p>
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
</div>
</form>
</div>
<Dialog
v-model:visible="credentialsVisible"
modal
header="حساب ساخته شد"
:style="{ width: '28rem' }"
:closable="false"
>
<p class="text-sm mb-3 line-height-3">
اطلاعات ورود ساخته شد و در صورت فعال بودن پیامک، برای کاربر ارسال میشود. این رمز فقط یکبار نمایش داده میشود:
</p>
<div class="flex flex-column gap-2 surface-ground p-3 border-round">
<div class="flex justify-content-between gap-2">
<span class="text-color-secondary text-sm">نام کاربری</span>
<code class="font-semibold" dir="ltr">{{ createdCredentials.username }}</code>
</div>
<div class="flex justify-content-between gap-2">
<span class="text-color-secondary text-sm">رمز عبور</span>
<code class="font-semibold" dir="ltr">{{ createdCredentials.password }}</code>
</div>
</div>
<template #footer>
<Button label="متوجه شدم" icon="pi pi-check" @click="finishAfterCreate" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { userApi } from '@/api/userApi';
import { roleApi } from '@/api/roleApi';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
const route = useRoute();
const router = useRouter();
const { showSuccess, showError } = useToast();
const userId = route.params.id;
const isEditMode = computed(() => !!userId);
const isSubmitting = ref(false);
const roles = ref([]);
const credentialsVisible = ref(false);
const createdCredentials = reactive({ username: '', password: '' });
const messengerOptions = [
{ label: 'پیامک (SMS)', value: 'SMS' },
{ label: 'بله', value: 'Bale' },
{ label: 'واتساپ', value: 'WhatsApp' },
{ label: 'تلگرام', value: 'Telegram' },
{ label: 'ایمیل', value: 'Email' }
];
const toMessengerList = (value) => {
if (!value) return [];
return Array.isArray(value) ? [...value] : [value];
};
const form = reactive({
name: '',
surname: '',
nationalId: '',
phone: '',
email: '',
role: null,
preferredMessenger: [],
username: '',
password: ''
});
const buildPayload = () => {
const payload = {
name: form.name,
surname: form.surname,
nationalIdCode: form.nationalId,
phoneNumber: form.phone,
email: form.email || undefined,
role: form.role || undefined
};
if (form.preferredMessenger?.length) {
payload.preferredMessenger = form.preferredMessenger;
} else if (isEditMode.value) {
payload.preferredMessenger = [];
}
if (isEditMode.value && form.password) {
payload.password = form.password;
}
return payload;
};
const fetchRoles = async () => {
try {
const res = await roleApi.getAll({ limit: 100 });
const data = res.data || res;
roles.value = Array.isArray(data)
? data
: (data.items || data.roles || data.data || []);
} catch (e) {
console.warn('Fetch roles error:', e);
}
};
const fetchUser = async () => {
if (!userId) return;
try {
const res = await userApi.getOne(userId);
const data = res.data || res;
Object.assign(form, {
name: data.name || '',
surname: data.surname || '',
nationalId: data.nationalIdCode || data.nationalId || '',
phone: data.phoneNumber || data.phone || '',
email: data.email || '',
role: data.role?._id || data.role || null,
preferredMessenger: toMessengerList(data.preferredMessenger),
username: data.username || '',
password: ''
});
} catch (err) {
showError(err);
}
};
const finishAfterCreate = () => {
credentialsVisible.value = false;
router.push('/users');
};
const handleSubmit = async () => {
if (!form.name || !form.surname) {
showError('نام و نام خانوادگی الزامی است');
return;
}
if (!form.nationalId || !form.phone) {
showError('کد ملی و شماره همراه الزامی است');
return;
}
isSubmitting.value = true;
try {
const payload = buildPayload();
if (isEditMode.value) {
await userApi.update(userId, payload);
showSuccess('اطلاعات کاربر با موفقیت ویرایش شد');
router.push('/users');
} else {
const res = await userApi.create(payload);
const data = res.data || res;
const creds = data.generatedCredentials || {};
createdCredentials.username = creds.username || data.username || '';
createdCredentials.password = creds.password || '';
showSuccess('کاربر جدید با موفقیت ایجاد شد');
if (createdCredentials.username && createdCredentials.password) {
credentialsVisible.value = true;
} else {
router.push('/users');
}
}
} catch (err) {
showError(err);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
fetchRoles();
fetchUser();
});
</script>
<style scoped>
.preferred-messenger-select :deep(.p-multiselect-label) {
padding-inline-end: 2.5rem;
}
.preferred-messenger-select :deep(.p-chip),
.preferred-messenger-select :deep(.p-multiselect-token) {
margin-inline-end: 0.35rem;
}
</style>
+150
View File
@@ -0,0 +1,150 @@
<!-- /src/views/users/UserListView.vue -->
<template>
<div class="user-list-view">
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
<PermissionGate permission="users:create">
<Button :label="$t('users.addUser')" icon="pi pi-user-plus" @click="$router.push('/users/create')" />
</PermissionGate>
</PageHeader>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام و نام خانوادگی" sortable>
<template #body="{ data }">
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.name }} {{ data.surname }}
</router-link>
</template>
</Column>
<Column field="nationalId" header="کد ملی">
<template #body="{ data }">
{{ toPersianDigits(data.nationalId) || '-' }}
</template>
</Column>
<Column field="phoneNumber" header="شماره همراه">
<template #body="{ data }">
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
</template>
</Column>
<Column field="preferredMessenger" header="پیام‌رسان ترجیحی">
<template #body="{ data }">
<div class="flex flex-wrap gap-1 justify-content-end">
<Tag
v-for="messenger in (Array.isArray(data.preferredMessenger) ? data.preferredMessenger : (data.preferredMessenger ? [data.preferredMessenger] : ['SMS']))"
:key="messenger"
:value="messenger"
severity="info"
/>
</div>
</template>
</Column>
<Column field="activeCoursesCount" header="دوره‌های فعال">
<template #body="{ data }">
{{ toPersianDigits(data.activeCoursesCount || data.enrolledCourses?.length || 0) }}
</template>
</Column>
<Column field="isActive" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.isActive !== false" />
</template>
</Column>
<Column header="عمولیات" style="width: 130px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="users:read">
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/users/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="users:update">
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/users/edit/${data._id || data.id}`)" />
</PermissionGate>
<PermissionGate permission="users:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { userApi } from '@/api/userApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(userApi.getAll);
const deleteDialogVisible = ref(false);
const selectedUser = ref(null);
const isDeleting = ref(false);
const confirmDelete = (user) => {
selectedUser.value = user;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedUser.value) return;
isDeleting.value = true;
try {
await userApi.delete(selectedUser.value._id || selectedUser.value.id);
showSuccess('کاربر با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})