Initial commit: admin dashboard for GameNo.
Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.env
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
+25
@@ -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?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
+13
@@ -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"]
|
||||||
@@ -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
@@ -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>
|
||||||
Generated
+3439
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -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>
|
||||||
@@ -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')
|
||||||
|
};
|
||||||
@@ -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}`)
|
||||||
|
};
|
||||||
@@ -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')
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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')
|
||||||
|
};
|
||||||
@@ -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 })
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
@@ -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}`)
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// /src/api/dashboardApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
getStats: () => axiosInstance.get('/dashboard/admin/stats')
|
||||||
|
};
|
||||||
@@ -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`)
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
@@ -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}`)
|
||||||
|
};
|
||||||
@@ -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}`)
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- /src/components/layout/AppLayout.vue -->
|
||||||
|
<template>
|
||||||
|
<DashboardLayout />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import DashboardLayout from '@/layouts/DashboardLayout.vue';
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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": "جزئیات صورتحساب"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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');
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* /src/style.css */
|
||||||
|
@import './assets/styles/main.scss';
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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="مثال: مناسب مبتدیان همراه با گواهینامه تمرین عملی"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user