Compare commits

...

10 Commits

Author SHA1 Message Date
sajjadtalkhabi 7ed6ec9ae1 fix: user changes 2026-05-28 13:34:30 +03:30
sajjadtalkhabi cb86920015 fix: admin courses 2026-05-27 14:47:19 +03:30
sajjadtalkhabi 7eb2984f5e fix: docker file 2026-05-21 18:01:25 +03:30
sajjadtalkhabi fc3de7269d fix: deploy 2026-05-21 17:59:29 +03:30
sajjadtalkhabi 494bd7254f fix: docker file 2026-05-21 17:49:31 +03:30
sajjadtalkhabi 35a0f2c04c fix 2026-05-21 16:08:36 +03:30
sajjadtalkhabi 74227d5021 fix 2026-05-21 15:51:32 +03:30
sajjadtalkhabi 8990ceaddd fix: design 2026-05-19 22:53:05 +03:30
sajjadtalkhabi c82701888a fix 2026-05-15 17:12:51 +03:30
sajjadtalkhabi b8ced45375 Fix 2026-05-13 03:06:59 +03:30
268 changed files with 16588 additions and 3727 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env.local
.env.*.local
+2 -2
View File
@@ -1,2 +1,2 @@
VITE_API_BASE_URL= VITE_API_BASE_URL=http://109.122.252.86/api
VITE_USE_MOCKS=true VITE_USE_MOCKS=false
+8 -5
View File
@@ -5,8 +5,8 @@ module.exports = {
node: true, node: true,
es2021: true, es2021: true,
}, },
plugins: ['prettier', 'unicorn', 'import'], plugins: ['prettier', 'unicorn', 'import', 'waterfall'],
ignorePatterns: ['node_modules/', 'dist/', 'build/', '*.min.js'], ignorePatterns: ['node_modules/', 'dist/', 'build/', '*.min.js', 'src/components/icons/icon-names.d.ts'],
parserOptions: { parserOptions: {
ecmaVersion: 2021, ecmaVersion: 2021,
sourceType: 'module', sourceType: 'module',
@@ -71,7 +71,10 @@ module.exports = {
'unicorn/prefer-logical-operator-over-ternary': 'off', 'unicorn/prefer-logical-operator-over-ternary': 'off',
'unicorn/prefer-number-properties': 'off', 'unicorn/prefer-number-properties': 'off',
'unicorn/prefer-ternary': 'off', 'unicorn/prefer-ternary': 'off',
'no-unused-vars': 'off', 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'waterfall/waterfall-imports': 'warn',
'waterfall/waterfall-requires': 'warn',
'waterfall/waterfall-objects': 'off',
'no-irregular-whitespace': 'off', 'no-irregular-whitespace': 'off',
'no-empty': 'off', 'no-empty': 'off',
'import/extensions': 'off', 'import/extensions': 'off',
@@ -80,9 +83,9 @@ module.exports = {
'vue/html-self-closing': 'off', 'vue/html-self-closing': 'off',
'vue/max-attributes-per-line': 'off', 'vue/max-attributes-per-line': 'off',
'vue/html-indent': 'off', 'vue/html-indent': 'off',
'vue/no-unused-components': 'off', 'vue/no-unused-components': 'error',
'vue/no-reserved-component-names': 'off', 'vue/no-reserved-component-names': 'off',
'vue/no-unused-vars': 'off', 'vue/no-unused-vars': 'error',
'vue/require-v-for-key': 'off', 'vue/require-v-for-key': 'off',
'vue/require-toggle-inside-transition': 'off', 'vue/require-toggle-inside-transition': 'off',
'vue/require-valid-default-prop': 'off', 'vue/require-valid-default-prop': 'off',
+31
View File
@@ -0,0 +1,31 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm config set registry https://mirror-npm.runflare.com
RUN npm config set strict-ssl false
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=http://109.122.252.86/api
ARG VITE_USE_MOCKS=false
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_USE_MOCKS=$VITE_USE_MOCKS
RUN npm run build
# Stage 2: Serve
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
set -e
REPO="ssh://git@git.meshkee.com:222/sajjad.talkhabi/test.git"
APP_DIR="/root/banu-front"
IMAGE="banu-front"
CONTAINER="banu-front"
PORT="8080"
VITE_API_BASE_URL="http://109.122.252.86/api"
echo "==> Pulling latest code..."
if [ -d "$APP_DIR/.git" ]; then
git -C "$APP_DIR" pull
else
git clone "$REPO" "$APP_DIR"
fi
echo "==> Building Docker image..."
docker build \
--build-arg VITE_API_BASE_URL="$VITE_API_BASE_URL" \
-t "$IMAGE" "$APP_DIR"
echo "==> Stopping old container..."
docker rm -f "$CONTAINER" 2>/dev/null || true
echo "==> Starting new container..."
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
-p "$PORT:80" \
"$IMAGE"
echo "==> Done. App running at http://$(hostname -I | awk '{print $1}'):$PORT"
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
# Backend (Postman) vs Frontend — fields & endpoints to decide on
Source of truth: the Postman collection covering Terms, Courses, Sessions, Exams, Homeworks, Media (2026-05). This doc lists every place where backend and FE disagree on shape, plus FE-side concepts the backend doc has no slot for. Each item needs a product/design call before we wire it up.
Conventions in the rest of this doc:
- **B → FE** means the backend exposes a field/endpoint that the FE doesn't surface yet.
- **FE → B** means the FE shows/sends a field the backend doc doesn't accept.
- **shape diff** means both sides handle the concept but in different shapes (enum values, nesting, naming).
---
## Terms
### Endpoints
| Backend | FE today | Status |
|---|---|---|
| `GET /terms?per_page&active_only` | `getTermsList` | aligned |
| `GET /terms/:id` | `showTerm` | aligned |
| `POST /terms` | `addNewTerm` | aligned |
| `PATCH /terms/:id` | `updateTerm` | aligned |
| `DELETE /terms/:id` | `deleteTerm` | aligned |
| — | `cloneTerm` (`POST /admin/terms/:id/clone`) | **FE-only; backend has nothing.** Decide: drop the clone button, or ask backend to add it. |
| — | `changeStatusTerm` (`POST /admin/terms/:id/status`) | **Use `PATCH /terms/:id` with `is_active`** — dedicated status endpoint dropped. |
| — | `listUserTerm`, `addUserTerm`, `removeUserTerm`, `changeLeaveStatus` | **FE-only.** Term-students subtab + leave toggle. Backend exposes nothing equivalent. Keep mock-only until backend adds. |
| — | `listCourseTerm`, `addCourseTerm`, `removeCourseTerm` | **FE-only.** Can be replaced by `GET /courses?term_id=` for the list; the attach/detach side has no backend equivalent (course `term_id` is set at create-time). |
### Fields
| Backend → FE | FE has it as | Notes |
|---|---|---|
| `starts_at`, `ends_at` (ISO datetime) | `startDate`, `endDate` | Naming diff; we send `starts_at`/`ends_at` in the mock already — confirm the FE form should rename or keep an adapter. |
| `cover_url` (read) + `cover_media_id` (write) | `coverUrl` / `coverMediaId` | aligned in shape, just camelCase. |
| `created_at` | `createdAt` | aligned. |
| `is_active` | `isActive` | aligned. |
| `description` | `description` | aligned. |
**FE → B** (need decision):
- `studentsCount`, `coursesCount` — computed UI counts. Backend response doesn't include them. Either compute client-side or ask backend for `?include=counts`.
- `image` (UI alias of cover) — FE keeps this for a fallback. Backend response only has `cover_url`.
---
## Courses
### Endpoints
| Backend | FE today | Status |
|---|---|---|
| `GET /courses?term_id&per_page` | `getCoursesList` | aligned (drop `/admin/` prefix in `endpoints.js`). |
| `GET /courses/:id` | `showCourse` | aligned. Backend response includes nested `term` and `teacher` — FE already reads `course.term` / `course.teacher`, good. |
| `POST /courses` | `addNewCourse` | aligned. Backend currently requires `term_id` (422 example), but per user instruction the standalone (template-tab) flow must allow `term_id: null`. **Decide:** ask backend to make `term_id` nullable, or refuse to submit until a term is picked. |
| `PATCH /courses/:id` | `updateCourse` | aligned. Used now for status toggle too (drops dedicated `/status` endpoint). |
| `DELETE /courses/:id` | `deleteCourse` | aligned. |
| — | `changeStatusCourse` (`/admin/courses/:id/status`) | **Dropped.** Use `PATCH /courses/:id { is_active }`. |
| — | `listCourseStudents`, `addCourseStudent`, `removeCourseStudent` | **FE-only.** CourseDetailsModal "students" tab + `AddCourseStudentModal`. Backend doesn't expose course-students; either add a sub-resource or remove the UI. Kept mock-only for now. |
| — | `listCourseSessions`, `attachCourseSession`, `detachCourseSession` | **FE-only.** CourseDetailsModal "sessions" tab can be served by `GET /sessions?course_id=`. The attach/detach side (M:N) has no backend equivalent — backend uses session.course_id (1:N). Kept mock-only for now; recommend swapping the list tab to the regular sessions query. |
### Fields
| Backend ↔ FE | Notes |
|---|---|
| `term_id``termId` | aligned. Nullable in CourseFormPage, required in AddOfferedCourseModal — schema reflects this. |
| `teacher_id``teacherId` | aligned. |
| `capacity``capacity` | aligned. |
| `is_active``isActive` | aligned. |
| `cover_media_id` (write) / `cover_url` (read) ⇄ `coverMediaId` / `coverUrl` | aligned. |
| `description``description` | aligned. |
| Nested `term`, `teacher` on show response | FE already reads. |
**FE → B** (need decision):
- `sessionsCount` — number-of-sessions field on the create form. Backend has nothing. Either compute server-side from related sessions, or drop the field.
- `prerequisites` (array of { courseId, course }) — backend has no prerequisite relation. Drop or ask backend for it.
- `contentType` (video/voice/text) + `contentMediaId` — single course-level content file. Backend treats files only as session media. Decide whether course-level content should move to "intro session" or stay a course concept.
- `image` (UI alias of cover_url) — kept as a fallback alongside `coverUrl`.
---
## Sessions
### Endpoints
| Backend | FE today | Status |
|---|---|---|
| `GET /sessions?course_id&per_page` | `getSessionsList` | aligned. |
| `GET /sessions/:id` | `showSession` | aligned. |
| `POST /sessions` | `addNewSession` | aligned. |
| `PATCH /sessions/:id` | `updateSession` | aligned. Used for status toggle now. |
| `DELETE /sessions/:id` | `deleteSession` | aligned. |
| — | `changeStatusSession` (`/admin/sessions/:id/toggle-status`) | **Dropped.** Use PATCH with `is_active`. |
| — | `getSessionsAttendance` (`/admin/sessions/:sessionId/attendances`) | **FE-only.** `SessionAttendanceModal` depends on this. Kept mock-only. |
### Fields — biggest gap of all five resources
| Backend | FE today | Notes |
|---|---|---|
| `type` enum: `online \| offline \| content` | `sessionType` enum: `in_person`, `online`, `video`, `audio`, `text`, `slide`, `pdf` | **Shape diff.** FE has 7 values; backend has 3. There's an existing `SESSION_TYPE_TO_SPEC` mapper in `services/mock/fixtures/admin-sessions.js`. Decide whether the FE keeps the richer 7-value enum (and we map down to backend's 3) or collapses. |
| `starts_at`, `location`, `link` | All three live **inside** `form.sessionConfig.*` plus also derived to top-level `startsAt` / `location` / `link` in the mock | **Structural diff.** Backend wants flat fields; FE form nests them under `sessionConfig` keyed by `sessionType`. The mock derives top-level from `sessionConfig.*` for show payloads. Decide whether the FE form should flatten the schema to match backend or keep the conditional-by-type config UI. |
| `media_ids[]` (write) / `media[]` (read with `collection_name`, `file_name`, `mime_type`, `file_size`, `url`, `download_url`) | `materials[]` with `{ fileId, isRequired, type, title, order }` | **Shape diff.** Backend's media rows are typed by upload-purpose (video/voice/pdf/slide/attachment); FE has its own `type` enum. Decide which shape the FE keeps. |
| — | `durationMinutes`, `order`, `sessionConfig.minWatchedPercent`, `sessionConfig.minReadPercent`, `sessionConfig.mustCompleteBeforeNext`, `sessionConfig.platform` | **FE → B**, all UI-only fields. Backend has nothing equivalent. Drop, move into a `metadata` JSON, or ask backend to add. |
| — | `usedInTerms` | UI-only count, no backend. |
| — | `image` (vs `media`) | UI-only thumbnail; backend doesn't separate. |
---
## Exams
### Endpoints
| Backend | FE today | Status |
|---|---|---|
| `GET /exams/:id` (with questions+options) | `showExam` | aligned. |
| `POST /exams` | `addNewExam` | aligned. |
| `PATCH /exams/:id` | `updateExam` | aligned. |
| `DELETE /exams/:id` | `deleteExam` | aligned. |
| `POST /exams/:examId/questions` | — | **Backend → FE.** New endpoint. Today the FE submits the whole question list inside the exam create/update payload. Decide whether to keep "all-in-one" submission (and ask backend to accept it) or switch to add-questions-after-create. |
| `POST /questions/:questionId/options` | — | **Backend → FE.** Same as above — backend lets you add options one at a time. FE today bundles all options with the question. |
| `POST /exams/:examId/submit` | — (student-side feature) | **Backend → FE.** Student-side; not in current admin UI. |
| — | `getExamsList` (`/admin/exams`) | **FE-only.** ExamsListPage depends on it. Kept mock-only; ask backend to add a list endpoint. |
| — | `getExamParticipants`, `showExamParticipant` | **FE-only.** ExamParticipantsModal + ExamParticipantDetailsModal depend on these. Kept mock-only. |
### Fields
| Backend ↔ FE | Notes |
|---|---|
| `session_id``sessionId` | aligned. |
| `title``title` | aligned. |
| `description``description` | aligned. |
| `pass_score``passingScore` | aligned (naming diff). |
| `is_active` | **Backend → FE.** Exam form has no active toggle. Decide whether to add it. |
| Backend question shape: `{ question_text, position, options: [{ option_text, is_correct }] }` | FE: `{ title, score, correctAnswerId, answers: [{ id, title }] }` | **Shape diff.** Backend hides `is_correct` from public reads (only on add). FE concept of `score` (per-question weighting) has no backend slot. Decide: keep FE scoring (ask backend to store) or drop. |
**FE → B** (need decision):
- `durationMinutes` — no backend slot.
- `randomize` — no backend slot.
- `endDate` / `startDate` — exam validity window, no backend slot.
- `usedInTerms` — derived UI count.
---
## Homeworks (FE calls them "assignments")
### Endpoints
| Backend | FE today | Status |
|---|---|---|
| `POST /homeworks` | `addNewAssignment` | aligned (URL renamed to `/homeworks`; FE key name kept). |
| `PATCH /homeworks/:id` | `updateAssignment` | aligned. |
| `DELETE /homeworks/:id` | `deleteAssignment` | aligned. |
| `POST /homeworks/:homeworkId/submit` (student) | — | **Backend → FE.** Student submit, not in admin UI yet. |
| `PATCH /homework-submissions/:submissionId/review` | `reviewAssignmentSubmission` | aligned (URL renamed). |
| — | `getAssignmentsList` | **FE-only.** AssignmentsListPage depends on it. Kept mock-only. |
| — | `showAssignment`, `getAssignmentSubmissions`, `showAssignmentSubmission` | **FE-only.** Detail + submissions list — kept mock-only. |
### Fields
| Backend ↔ FE | Notes |
|---|---|
| `session_id``sessionId` | aligned. |
| `title`, `description` | aligned. |
| `deadline` (single datetime) | FE: `startDate` + `endDate` + computed `durationDays` | **Shape diff.** Backend has one deadline; FE has a window. Decide: drop start/end and use single deadline, or ask backend to add a window. |
| `is_active` | **Backend → FE.** FE form has no active toggle. |
| Submission `status`: `accepted \| denied` | FE: `pending \| approved \| rejected \| needs_revision` | **Shape diff.** Backend has two states; FE has four. The FE `pending/needs_revision` have no backend slot. |
| Submission `media_id` (single) | FE `attachments[]` (multiple) | **Shape diff.** Backend allows one file per submission; FE expects many. |
| Submission `teacher_feedback``reviewerNote` | naming diff. |
| Submission `reviewed_at` (read) | — | Backend provides; FE doesn't surface. |
**FE → B** (need decision):
- `priority` enum (`mandatory \| optional`) — no backend slot.
- `submissionsCount` — derived count, no backend slot.
---
## Media
### Endpoints
| Backend | FE today | Status |
|---|---|---|
| `POST /media` (multipart with `purpose` + `file`) | `uploadMedia` | aligned URL & method. |
| `GET /media/:id/download` | — | **Backend → FE.** Add as `downloadMedia`. |
| `DELETE /media/:id` | — | **Backend → FE.** Add as `deleteMedia`. |
### Fields
- Backend response: `{ id, collection_name, file_name, mime_type, file_size, url, download_url }`. FE today reads `payload.id`, `payload.url`, sometimes `payload.uploadId` (a now-stale field). **Cleanup needed:** drop `uploadId`, use `id` everywhere.
- Backend `purpose` enum: `avatar | cover | video | voice | pdf | slide | attachment | homework_file`. FE upload calls currently hardcode `purpose: 'cover'` or `purpose: 'content'`. **`content` is not in the backend enum.** Decide which of the backend purposes each FE uploader should send (e.g., session video → `video`, course PDF → `pdf`, homework upload → `homework_file`).
- Pending media TTL: backend deletes unreferenced pending uploads after 24h. FE doesn't track this; if a user uploads a cover, abandons the form, and comes back next day, the `cover_media_id` reference will 404 on submit. Document the failure mode.
---
## Cross-cutting
### Snake_case vs camelCase
Backend wire format is snake_case throughout. FE today reads camelCase (e.g., the mock returns `coverUrl`, `isActive`). When the real backend lands, the FE will either need:
- a HTTP-layer transformer (camelize on response, snake_case on request), or
- camelCase field aliases on the backend serializer.
Decide before swapping the mock for the real API. Affects every screen.
### `/admin/` URL prefix
Backend doc has none — endpoints live at `/terms`, `/courses`, etc. The FE previously had `/admin/courses`, `/admin/sessions`, `/admin/exams`, `/admin/assignments`. **Aligned to backend (prefix dropped).** Auth context (admin role) is implicit in the token, not the URL.
### Sub-features that depend on missing backend endpoints
UI screens that work today against the mock but have no backend equivalent in the current doc (kept mock-only with TODOs in `endpoints.js`):
- Term: clone term, term status toggle, students-in-term subtab, leave toggle, courses-in-term subtab (could swap to `GET /courses?term_id=`)
- Course: status toggle (swapped to PATCH), students-in-course subtab, attach/detach sessions (M:N), `AddCourseStudentModal`, `AddSessionToCourseModal`
- Session: status toggle (swapped to PATCH), attendance roster
- Exam: list page, participants list, participant detail
- Assignment/Homework: list page, detail show, submissions list, submission detail
For each of these we need to either (a) ask backend to expose the endpoint, or (b) drop the UI surface.
+2
View File
@@ -1,9 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"checkJs": false,
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
} }
}, },
"include": ["src/**/*", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+35
View File
@@ -11,9 +11,12 @@
"@floating-ui/dom": "^1.7.6", "@floating-ui/dom": "^1.7.6",
"@tanstack/vue-query": "^5.62.2", "@tanstack/vue-query": "^5.62.2",
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"tinymce": "^8.5.0",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
@@ -29,6 +32,7 @@
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^55.0.0", "eslint-plugin-unicorn": "^55.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"eslint-plugin-waterfall": "^1.0.1",
"postcss-html": "^1.8.0", "postcss-html": "^1.8.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass": "^1.83.0", "sass": "^1.83.0",
@@ -1670,6 +1674,24 @@
} }
} }
}, },
"node_modules/@tinymce/tinymce-vue": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-vue/-/tinymce-vue-4.0.7.tgz",
"integrity": "sha512-1esB8wGWrjPCY+rK8vy3QB1cxwXo7HLJWuNrcyPl6LOVR+QJjub0OiV/C+TUEsLN6OpCtRv+QnIqMC5vXz783Q==",
"license": "Apache-2.0",
"dependencies": {
"tinymce": "^5.5.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@tinymce/tinymce-vue/node_modules/tinymce": {
"version": "5.10.9",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz",
"integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==",
"license": "LGPL-2.1"
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -3467,6 +3489,13 @@
"eslint": ">=6.0.0" "eslint": ">=6.0.0"
} }
}, },
"node_modules/eslint-plugin-waterfall": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-waterfall/-/eslint-plugin-waterfall-1.0.1.tgz",
"integrity": "sha512-g6rmlPsbKXRnDDcOLjNifqU5Ctml/90KDsvJill1SMIyBVZ17MgcggC3lIM9UcnbFmOttVHBVV3a71Yd/33TKw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "7.2.2", "version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -7193,6 +7222,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinymce": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.5.0.tgz",
"integrity": "sha512-DnKEfPNQnOJc8Ca1roZBs/GSbkAZyIIbC4p8eHZyZQi85OSAXtiVNYMaRxo4mzsGKpa0sA4/Us4KXQkX8q7w2A==",
"license": "SEE LICENSE IN license.md"
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+4
View File
@@ -18,9 +18,12 @@
"@floating-ui/dom": "^1.7.6", "@floating-ui/dom": "^1.7.6",
"@tanstack/vue-query": "^5.62.2", "@tanstack/vue-query": "^5.62.2",
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"tinymce": "^8.5.0",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
@@ -36,6 +39,7 @@
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^55.0.0", "eslint-plugin-unicorn": "^55.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"eslint-plugin-waterfall": "^1.0.1",
"postcss-html": "^1.8.0", "postcss-html": "^1.8.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass": "^1.83.0", "sass": "^1.83.0",
+52
View File
@@ -0,0 +1,52 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ICONS_DIR = path.resolve(__dirname, '../src/assets/icons')
const OUTPUT_FILE = path.resolve(__dirname, '../src/components/icons/icon-names.d.ts')
const readNames = () =>
fs
.readdirSync(ICONS_DIR)
.filter((f) => f.endsWith('.svg'))
.map((f) => f.replace(/\.svg$/, ''))
.sort()
const buildContent = (names) => {
const union = names.length > 0 ? names.map((n) => ` | '${n}'`).join('\n') : ' | never'
return `// AUTO-GENERATED — do not edit by hand.
// Regenerated whenever src/assets/icons/*.svg changes (via scripts/vite-icon-names.js).
export type IconName =
${union}
export const iconNames: readonly IconName[]
`
}
const writeIfChanged = () => {
const content = buildContent(readNames())
const prev = fs.existsSync(OUTPUT_FILE) ? fs.readFileSync(OUTPUT_FILE, 'utf8') : ''
if (prev !== content) fs.writeFileSync(OUTPUT_FILE, content)
}
export default function viteIconNames() {
return {
name: 'vite-icon-names',
buildStart() {
writeIfChanged()
},
configureServer(server) {
writeIfChanged()
server.watcher.add(path.join(ICONS_DIR, '*.svg'))
const handler = (file) => {
if (file.startsWith(ICONS_DIR) && file.endsWith('.svg')) writeIfChanged()
}
server.watcher.on('add', handler)
server.watcher.on('unlink', handler)
},
}
}
export { writeIfChanged as generateIconNames }
+30 -8
View File
@@ -1,20 +1,22 @@
<template> <template>
<component :is="currentLayout" /> <component v-if="isReady" :is="currentLayout" />
<ConfirmModal /> <ConfirmModal />
<VueQueryDevtools /> <VueQueryDevtools />
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
import ConfirmModal from '@/components/ConfirmModal.vue'
import PublicLayout from '@/layouts/PublicLayout.vue'
import AdminLayout from '@/layouts/AdminLayout.vue'
import StudentLayout from '@/layouts/StudentLayout.vue'
import { useModalStore } from '@/store/modal' import { useModalStore } from '@/store/modal'
import { useAuthStore } from '@/store/auth'
import { useGetMeQuery } from '@/services/query/auth'
import { tokenService } from '@/services/api/token-service'
import AdminLayout from '@/layouts/AdminLayout.vue'
import PublicLayout from '@/layouts/PublicLayout.vue'
import StudentLayout from '@/layouts/StudentLayout.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
const route = useRoute() const route = useRoute()
@@ -26,6 +28,26 @@ const layouts = {
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout) const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
const authStore = useAuthStore()
const hasToken = computed(() => tokenService.hasToken())
const {
data: me,
isLoading: isMeLoading,
isFetched: isMeFetched,
} = useGetMeQuery({ enabled: hasToken })
watch(
me,
(value) => {
if (value) authStore.setUser(value)
},
{ immediate: true }
)
const isReady = computed(() => !hasToken.value || isMeFetched.value || !isMeLoading.value)
const { modals } = storeToRefs(useModalStore()) const { modals } = storeToRefs(useModalStore())
watch( watch(
() => modals.value.length, () => modals.value.length,
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<g clip-path="url(#clip0_1_32404)">
<path d="M12 12.375C12.6213 12.375 13.125 11.8713 13.125 11.25C13.125 10.6287 12.6213 10.125 12 10.125C11.3787 10.125 10.875 10.6287 10.875 11.25C10.875 11.8713 11.3787 12.375 12 12.375Z" fill="#B3B3B3"/>
<path d="M7.875 12.375C8.49632 12.375 9 11.8713 9 11.25C9 10.6287 8.49632 10.125 7.875 10.125C7.25368 10.125 6.75 10.6287 6.75 11.25C6.75 11.8713 7.25368 12.375 7.875 12.375Z" fill="#B3B3B3"/>
<path d="M16.125 12.375C16.7463 12.375 17.25 11.8713 17.25 11.25C17.25 10.6287 16.7463 10.125 16.125 10.125C15.5037 10.125 15 10.6287 15 11.25C15 11.8713 15.5037 12.375 16.125 12.375Z" fill="#B3B3B3"/>
<path d="M9.85031 18L11.3503 20.625C11.4159 20.74 11.5107 20.8356 11.6251 20.9021C11.7395 20.9685 11.8695 21.0036 12.0019 21.0036C12.1342 21.0036 12.2642 20.9685 12.3787 20.9021C12.4931 20.8356 12.5879 20.74 12.6534 20.625L14.1534 18H20.25C20.4489 18 20.6397 17.921 20.7803 17.7803C20.921 17.6397 21 17.4489 21 17.25V5.25C21 5.05109 20.921 4.86032 20.7803 4.71967C20.6397 4.57902 20.4489 4.5 20.25 4.5H3.75C3.55109 4.5 3.36032 4.57902 3.21967 4.71967C3.07902 4.86032 3 5.05109 3 5.25V17.25C3 17.4489 3.07902 17.6397 3.21967 17.7803C3.36032 17.921 3.55109 18 3.75 18H9.85031Z" stroke="#B3B3B3" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_32404">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_32401)">
<path d="M12 21C12 21 2.25 15.75 2.25 9.5625C2.25 8.21984 2.78337 6.93217 3.73277 5.98277C4.68217 5.03337 5.96984 4.5 7.3125 4.5C9.43031 4.5 11.2444 5.65406 12 7.5C12.7556 5.65406 14.5697 4.5 16.6875 4.5C18.0302 4.5 19.3178 5.03337 20.2672 5.98277C21.2166 6.93217 21.75 8.21984 21.75 9.5625C21.75 15.75 12 21 12 21Z" stroke="#B3B3B3" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_32401">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 625 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M12.4258 5.68491L13.3926 4.71811C13.7731 4.3375 14.2248 4.03558 14.722 3.82959C15.2192 3.6236 15.7521 3.51758 16.2903 3.51758C16.8285 3.51758 17.3614 3.6236 17.8586 3.82959C18.3558 4.03558 18.8076 4.3375 19.1881 4.71811C19.5687 5.09861 19.8706 5.55036 20.0766 6.04756C20.2826 6.54477 20.3886 7.07768 20.3886 7.61586C20.3886 8.15405 20.2826 8.68696 20.0766 9.18416C19.8706 9.68136 19.5687 10.1331 19.1881 10.5136L17.0453 12.6564L16.1365 13.5652C15.7556 13.9462 15.3032 14.2483 14.8054 14.4544C14.3076 14.6604 13.774 14.7662 13.2352 14.7658C12.6964 14.7654 12.163 14.6587 11.6655 14.452C11.168 14.2452 10.7161 13.9424 10.3357 13.5608C9.94202 13.1672 9.6329 12.6973 9.42735 12.18C9.2218 11.6626 9.12416 11.1087 9.14042 10.5523" stroke="#DDDDDD" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0742 16.8152L9.10745 17.782C8.7265 18.163 8.27417 18.4651 7.77633 18.6711C7.2785 18.8772 6.74492 18.983 6.20614 18.9826C5.66737 18.9822 5.13395 18.8755 4.63643 18.6688C4.1389 18.462 3.68703 18.1592 3.30667 17.7776C2.54025 17.0085 2.11034 15.9667 2.11133 14.8809C2.11232 13.7952 2.54412 12.7542 3.31194 11.9865L6.3635 8.93491C6.744 8.5543 7.19575 8.25238 7.69296 8.04639C8.19016 7.8404 8.72307 7.73438 9.26126 7.73438C9.79944 7.73438 10.3324 7.8404 10.8296 8.04639C11.3268 8.25238 11.7785 8.5543 12.159 8.93491C12.5543 9.32844 12.8648 9.79882 13.0713 10.317C13.2778 10.8352 13.3759 11.3902 13.3596 11.9478" stroke="#DDDDDD" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none">
<g clip-path="url(#clip0_1_33264)">
<path d="M5.625 4.21875C6.40165 4.21875 7.03125 3.58915 7.03125 2.8125C7.03125 2.03585 6.40165 1.40625 5.625 1.40625C4.84835 1.40625 4.21875 2.03585 4.21875 2.8125C4.21875 3.58915 4.84835 4.21875 5.625 4.21875Z" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.625 4.21875V7.73438" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.38281 6.11133C9.03428 6.375 10.1953 7.00298 10.1953 7.73423C10.1953 8.70498 8.14922 9.49204 5.625 9.49204C3.10078 9.49204 1.05469 8.70498 1.05469 7.73423C1.05469 7.00298 2.21572 6.37588 3.86719 6.11133" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_33264">
<rect width="11.25" height="11.25" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 952 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<g clip-path="url(#clip0_1_27293)">
<path d="M14.7656 5.625C14.7656 3.68337 13.1916 2.10938 11.25 2.10938C9.30837 2.10938 7.73438 3.68337 7.73438 5.625V11.25C7.73438 13.1916 9.30837 14.7656 11.25 14.7656C13.1916 14.7656 14.7656 13.1916 14.7656 11.25V5.625Z" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.25 17.5781V21.0938" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5781 11.25C17.5781 12.9283 16.9114 14.5379 15.7247 15.7247C14.5379 16.9114 12.9283 17.5781 11.25 17.5781C9.57168 17.5781 7.96209 16.9114 6.77534 15.7247C5.58859 14.5379 4.92188 12.9283 4.92188 11.25" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_27293">
<rect width="22.5" height="22.5" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 955 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.3488 15.0762C22.7483 15.0762 23.0857 14.9392 23.3611 14.6654C23.6365 14.3918 23.7742 14.0594 23.7742 13.6681C23.7742 13.2771 23.6343 12.9469 23.3545 12.6773C23.075 12.4078 22.7353 12.273 22.3356 12.273C21.9361 12.273 21.5986 12.41 21.3232 12.6838C21.0479 12.9574 20.9102 13.2898 20.9102 13.6811C20.9102 14.0721 21.0501 14.4023 21.3299 14.6719C21.6094 14.9414 21.9491 15.0762 22.3488 15.0762ZM12.9121 15.0762C13.3116 15.0762 13.649 14.9392 13.9244 14.6654C14.1998 14.3918 14.3375 14.0594 14.3375 13.6681C14.3375 13.2771 14.1976 12.9469 13.9178 12.6773C13.6383 12.4078 13.2986 12.273 12.8989 12.273C12.4994 12.273 12.1619 12.41 11.8865 12.6838C11.6111 12.9574 11.4734 13.2898 11.4734 13.6811C11.4734 14.0721 11.6133 14.4023 11.8931 14.6719C12.1727 14.9414 12.5123 15.0762 12.9121 15.0762ZM17.6326 29.577C15.8909 29.577 14.2524 29.2533 12.7171 28.6059C11.1818 27.9583 9.84618 27.0797 8.71035 25.9699C7.57452 24.8599 6.67711 23.5562 6.01813 22.0588C5.35891 20.5614 5.0293 18.9615 5.0293 17.259C5.0293 15.5544 5.36001 13.9507 6.02144 12.4481C6.68311 10.9454 7.58088 9.63823 8.71476 8.52656C9.84888 7.4149 11.1809 6.53658 12.7109 5.89162C14.2408 5.24643 15.8755 4.92383 17.615 4.92383C19.3567 4.92383 20.9952 5.24751 22.5306 5.89486C24.0659 6.54245 25.4015 7.42112 26.5373 8.53087C27.6731 9.64086 28.5705 10.9446 29.2295 12.442C29.8887 13.9393 30.2184 15.5393 30.2184 17.2418C30.2184 18.9464 29.8876 20.5501 29.2262 22.0527C28.5645 23.5554 27.6668 24.8626 26.5329 25.9742C25.3988 27.0859 24.0667 27.9642 22.5368 28.6092C21.0068 29.2544 19.3721 29.577 17.6326 29.577ZM17.6227 28.6605C20.867 28.6605 23.6212 27.5528 25.8855 25.3374C28.1499 23.1222 29.282 20.4269 29.282 17.2515C29.282 14.0763 28.1502 11.3806 25.8866 9.16445C23.6233 6.94831 20.8694 5.84023 17.6249 5.84023C14.3807 5.84023 11.6264 6.94795 9.36211 9.16337C7.09779 11.3786 5.96562 14.0739 5.96562 17.2493C5.96562 20.4245 7.09742 23.1202 9.36101 25.3363C11.6244 27.5525 14.3783 28.6605 17.6227 28.6605ZM17.6238 23.8988C18.755 23.8988 19.7995 23.6475 20.7574 23.1449C21.715 22.642 22.5105 21.959 23.1438 21.0961C23.2703 20.8802 23.2794 20.6615 23.1709 20.4398C23.0627 20.2182 22.8757 20.1074 22.6099 20.1074H12.6521C12.3804 20.1074 12.1895 20.2182 12.0793 20.4398C11.9691 20.6615 11.9835 20.8765 12.1223 21.0849C12.7423 21.9546 13.5364 22.641 14.5046 23.1441C15.4727 23.6473 16.5125 23.8988 17.6238 23.8988Z" fill="currentColor"/> <path d="M22.3488 15.0762C22.7483 15.0762 23.0857 14.9392 23.3611 14.6654C23.6365 14.3918 23.7742 14.0594 23.7742 13.6681C23.7742 13.2771 23.6343 12.9469 23.3545 12.6773C23.075 12.4078 22.7353 12.273 22.3356 12.273C21.9361 12.273 21.5986 12.41 21.3232 12.6838C21.0479 12.9574 20.9102 13.2898 20.9102 13.6811C20.9102 14.0721 21.0501 14.4023 21.3299 14.6719C21.6094 14.9414 21.9491 15.0762 22.3488 15.0762ZM12.9121 15.0762C13.3116 15.0762 13.649 14.9392 13.9244 14.6654C14.1998 14.3918 14.3375 14.0594 14.3375 13.6681C14.3375 13.2771 14.1976 12.9469 13.9178 12.6773C13.6383 12.4078 13.2986 12.273 12.8989 12.273C12.4994 12.273 12.1619 12.41 11.8865 12.6838C11.6111 12.9574 11.4734 13.2898 11.4734 13.6811C11.4734 14.0721 11.6133 14.4023 11.8931 14.6719C12.1727 14.9414 12.5123 15.0762 12.9121 15.0762ZM17.6326 29.577C15.8909 29.577 14.2524 29.2533 12.7171 28.6059C11.1818 27.9583 9.84618 27.0797 8.71035 25.9699C7.57452 24.8599 6.67711 23.5562 6.01813 22.0588C5.35891 20.5614 5.0293 18.9615 5.0293 17.259C5.0293 15.5544 5.36001 13.9507 6.02144 12.4481C6.68311 10.9454 7.58088 9.63823 8.71476 8.52656C9.84888 7.4149 11.1809 6.53658 12.7109 5.89162C14.2408 5.24643 15.8755 4.92383 17.615 4.92383C19.3567 4.92383 20.9952 5.24751 22.5306 5.89486C24.0659 6.54245 25.4015 7.42112 26.5373 8.53087C27.6731 9.64086 28.5705 10.9446 29.2295 12.442C29.8887 13.9393 30.2184 15.5393 30.2184 17.2418C30.2184 18.9464 29.8876 20.5501 29.2262 22.0527C28.5645 23.5554 27.6668 24.8626 26.5329 25.9742C25.3988 27.0859 24.0667 27.9642 22.5368 28.6092C21.0068 29.2544 19.3721 29.577 17.6326 29.577ZM17.6227 28.6605C20.867 28.6605 23.6212 27.5528 25.8855 25.3374C28.1499 23.1222 29.282 20.4269 29.282 17.2515C29.282 14.0763 28.1502 11.3806 25.8866 9.16445C23.6233 6.94831 20.8694 5.84023 17.6249 5.84023C14.3807 5.84023 11.6264 6.94795 9.36211 9.16337C7.09779 11.3786 5.96562 14.0739 5.96562 17.2493C5.96562 20.4245 7.09742 23.1202 9.36101 25.3363C11.6244 27.5525 14.3783 28.6605 17.6227 28.6605ZM17.6238 23.8988C18.755 23.8988 19.7995 23.6475 20.7574 23.1449C21.715 22.642 22.5105 21.959 23.1438 21.0961C23.2703 20.8802 23.2794 20.6615 23.1709 20.4398C23.0627 20.2182 22.8757 20.1074 22.6099 20.1074H12.6521C12.3804 20.1074 12.1895 20.2182 12.0793 20.4398C11.9691 20.6615 11.9835 20.8765 12.1223 21.0849C12.7423 21.9546 13.5364 22.641 14.5046 23.1441C15.4727 23.6473 16.5125 23.8988 17.6238 23.8988Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1279_3967)"> <g clip-path="url(#clip0_1279_3967)">
<path d="M12.2344 10.875H6.79688" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12.2344 10.875H6.79688" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.11956 18.8005C4.07177 18.931 4.06515 19.073 4.10058 19.2074C4.13601 19.3418 4.2118 19.4621 4.31772 19.5521C4.42364 19.6421 4.55461 19.6975 4.69297 19.7108C4.83133 19.724 4.97043 19.6945 5.09152 19.6263L19.365 11.4624C19.4712 11.4036 19.5597 11.3175 19.6214 11.2129C19.6831 11.1083 19.7156 10.9891 19.7156 10.8677C19.7156 10.7463 19.6831 10.6271 19.6214 10.5225C19.5597 10.4179 19.4712 10.3317 19.365 10.273L5.09152 2.12945C4.97079 2.06193 4.83234 2.03285 4.69466 2.04609C4.55697 2.05934 4.4266 2.11428 4.32096 2.20357C4.21533 2.29286 4.13945 2.41226 4.10346 2.54582C4.06747 2.67938 4.07309 2.82074 4.11956 2.95102L6.79668 10.8753L4.11956 18.8005Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/> <path d="M4.11956 18.8005C4.07177 18.931 4.06515 19.073 4.10058 19.2074C4.13601 19.3418 4.2118 19.4621 4.31772 19.5521C4.42364 19.6421 4.55461 19.6975 4.69297 19.7108C4.83133 19.724 4.97043 19.6945 5.09152 19.6263L19.365 11.4624C19.4712 11.4036 19.5597 11.3175 19.6214 11.2129C19.6831 11.1083 19.7156 10.9891 19.7156 10.8677C19.7156 10.7463 19.6831 10.6271 19.6214 10.5225C19.5597 10.4179 19.4712 10.3317 19.365 10.273L5.09152 2.12945C4.97079 2.06193 4.83234 2.03285 4.69466 2.04609C4.55697 2.05934 4.4266 2.11428 4.32096 2.20357C4.21533 2.29286 4.13945 2.41226 4.10346 2.54582C4.06747 2.67938 4.07309 2.82074 4.11956 2.95102L6.79668 10.8753L4.11956 18.8005Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 109 109" fill="none">
<g clip-path="url(#clip0_1_29013)">
<path d="M84.9609 74.7656V27.1875C84.9609 24.4835 83.8868 21.8903 81.9748 19.9783C80.0628 18.0663 77.4696 16.9922 74.7656 16.9922H16.9922" stroke="#C3C3C3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.1797 44.1797H71.3672" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.1797 57.7734H71.3672" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.1953 33.9844C10.1953 33.9844 6.79688 31.4355 6.79688 27.1875C6.79688 24.4835 7.87102 21.8903 9.78301 19.9783C11.695 18.0663 14.2882 16.9922 16.9922 16.9922C19.6962 16.9922 22.2894 18.0663 24.2014 19.9783C26.1134 21.8903 27.1875 24.4835 27.1875 27.1875V81.5625C27.1875 84.2665 28.2616 86.8597 30.1736 88.7717C32.0856 90.6837 34.6788 91.7578 37.3828 91.7578M37.3828 91.7578C40.0868 91.7578 42.68 90.6837 44.592 88.7717C46.504 86.8597 47.5781 84.2665 47.5781 81.5625C47.5781 77.3145 44.1797 74.7656 44.1797 74.7656H91.7578C91.7578 74.7656 95.1562 77.3145 95.1562 81.5625C95.1562 84.2665 94.0821 86.8597 92.1701 88.7717C90.2581 90.6837 87.6649 91.7578 84.9609 91.7578H37.3828Z" stroke="#C3C3C3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_29013">
<rect width="108.75" height="108.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+142
View File
@@ -0,0 +1,142 @@
<template>
<span
class="badge"
:class="[`badge--${variant}`, `badge--${size}`, { 'badge--clickable': clickable }]"
:role="clickable ? 'button' : undefined"
@click="onClick"
>
<span v-if="dot" class="badge__dot" />
<SvgIcon v-if="icon" :name="icon" :size="iconSize" class="badge__icon" :color="iconColor" />
<slot name="prepend" />
<span v-if="label" class="badge__label">{{ label }}</span>
<span v-if="$slots.default || value" class="badge__value">
<slot>{{ value }}</slot>
</span>
<slot name="append" />
</span>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan'>} */
variant: { type: String, default: 'neutral' },
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
size: { type: String, default: 'md' },
label: { type: String, default: '' },
value: { type: [String, Number], default: '' },
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName | ''>} */
icon: { type: String, default: '' },
dot: { type: Boolean, default: false },
clickable: { type: Boolean, default: false },
})
const emit = defineEmits(['click'])
const ICON_SIZE_MAP = { sm: 11, md: 14, lg: 16 }
const iconSize = computed(() => ICON_SIZE_MAP[props.size] ?? 14)
const iconColor = computed(() => 'currentColor')
const onClick = (event) => {
if (!props.clickable) return
emit('click', event)
}
</script>
<style lang="scss" scoped>
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
line-height: 1.5;
transition: filter 0.15s ease;
&--clickable {
cursor: pointer;
&:hover {
filter: brightness(0.96);
}
}
&__dot {
width: 0.4em;
height: 0.4em;
border-radius: 9999px;
background: currentcolor;
flex-shrink: 0;
}
&__icon {
flex-shrink: 0;
}
&__label {
font-weight: 300;
color: inherit;
opacity: 0.85;
}
&__value {
font-weight: 500;
}
&--sm {
padding: 0.2rem 0.625rem;
font-size: 0.65rem;
gap: 0.3rem;
border-radius: 0.625rem;
}
&--md {
padding: 0.3rem 0.875rem;
font-size: 0.75rem;
}
&--lg {
padding: 0.4rem 1rem;
font-size: 0.875rem;
border-radius: 0.875rem;
}
&--neutral {
background: rgba(107, 107, 107, 5%);
color: #535353;
}
&--success {
background: rgba(0, 153, 76, 10%);
color: #00994c;
}
&--danger {
background: rgba(243, 102, 117, 8%);
color: #cc2831;
}
&--warning {
background: rgba(204, 154, 40, 10%);
color: #cc6f00;
}
&--primary {
background: rgba(0, 112, 116, 8%);
color: #007074;
}
&--info {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
&--cyan {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
}
</style>
+1
View File
@@ -79,6 +79,7 @@ const handleClick = (event) => {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
text-decoration: none; text-decoration: none;
padding: 0 1rem; padding: 0 1rem;
white-space: nowrap;
&__text { &__text {
line-height: 1.25rem; line-height: 1.25rem;
+3 -4
View File
@@ -28,11 +28,10 @@
</template> </template>
<script setup> <script setup>
import { computed, getCurrentInstance, onBeforeUnmount, useSlots, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useModalStore } from '@/store/modal' import { useModalStore } from '@/store/modal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, getCurrentInstance, onBeforeUnmount, useSlots, watch } from 'vue'
const props = defineProps({ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
+3 -6
View File
@@ -28,10 +28,7 @@
role="tooltip" role="tooltip"
> >
{{ tooltip }} {{ tooltip }}
<span <span class="circle-button__tooltip-arrow" :style="arrowStyle" />
class="circle-button__tooltip-arrow"
:style="arrowStyle"
/>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
@@ -98,8 +95,8 @@ const updatePosition = async () => {
const side = result.placement.split('-')[0] const side = result.placement.split('-')[0]
const oppositeSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side] const oppositeSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side]
arrowStyle.value = { arrowStyle.value = {
left: arrowData.x != null ? `${arrowData.x}px` : '', left: arrowData.x == null ? '' : `${arrowData.x}px`,
top: arrowData.y != null ? `${arrowData.y}px` : '', top: arrowData.y == null ? '' : `${arrowData.y}px`,
[oppositeSide]: '-0.25rem', [oppositeSide]: '-0.25rem',
} }
} }
+1 -1
View File
@@ -31,9 +31,9 @@
</template> </template>
<script setup> <script setup>
import { useModalStore } from '@/store/modal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import { useModalStore } from '@/store/modal'
defineOptions({ name: 'ConfirmModal' }) defineOptions({ name: 'ConfirmModal' })
+1 -2
View File
@@ -27,6 +27,7 @@
<script setup> <script setup>
import { onBeforeUnmount, ref, watch } from 'vue' import { onBeforeUnmount, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { import {
autoUpdate, autoUpdate,
computePosition, computePosition,
@@ -35,8 +36,6 @@ import {
shift, shift,
} from '@floating-ui/dom' } from '@floating-ui/dom'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
reference: { type: [Object, null], default: null }, reference: { type: [Object, null], default: null },
+3 -4
View File
@@ -34,11 +34,10 @@
</template> </template>
<script setup> <script setup>
import { computed, useSlots } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { gallery } from '@/utils/gallery'
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { computed, useSlots } from 'vue'
import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({ defineProps({
pageTitle: { type: String, default: '' }, pageTitle: { type: String, default: '' },
+118
View File
@@ -0,0 +1,118 @@
<template>
<div class="my-tinymce">
<Editor v-model="contentValue" :init="myInit" />
</div>
</template>
<script setup>
import axios from 'axios'
import tinymce from 'tinymce/tinymce'
import '@/plugins/tinymce/importTinymce'
import Editor from '@tinymce/tinymce-vue'
import { initTiny } from '@/plugins/tinymce/tinymce'
import { onMounted, toRefs, ref, reactive, defineProps, defineEmits, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
// placeholder
placeholder: {
type: String,
default: 'Please enter content',
},
// Default style
style: {
type: Object,
default: () => {
return { width: '100%', heigth: '400' }
},
},
// image upload server address
imgUploadUrl: {
type: String,
default: '/api/v1/media',
},
batchId: {
type: String,
},
})
const emit = defineEmits(['update:modelValue'])
// Parameter custom initialization
const customer = (init) => {
// Allow the outside world to pass in height and placeholder
init.height = props.style.heigth
init.placeholder = props.placeholder
// Paste pictures and automatically process base64
init.urlconverter_callback = (url, node, onSave, name) => {
if (node === 'img' && url.startsWith('blob:')) {
tinymce.activeEditor && tinymce.activeEditor.uploadImages()
}
return url
}
// upload picture
init.images_upload_handler = (blobInfo, success, failure) => {
imgUploadFn(blobInfo, success, failure)
}
return init
}
// const myInit = ref(customer(initTiny(props.batchId)));
const state = reactive({
myInit: ref(customer(initTiny(props.batchId))),
contentValue: props.modelValue, // binding text
timeout: null,
})
onMounted(() => {
tinymce.init({})
})
// Listen for text changes and pass them to the outside world
watch(
() => state.contentValue,
(n) => {
debounce(() => {
emit('update:modelValue', state.contentValue)
})
}
)
// Listen to the default value. The first time a v-model is passed in from the outside world, it is assigned to contentValue.
watch(
() => props.modelValue,
(n) => {
if (n && n !== state.contentValue) {
state.contentValue = n
}
}
)
const debounce = (fn, wait = 400) => {
if (state.timeout !== null) {
clearTimeout(state.timeout)
}
state.timeout = setTimeout(fn, wait)
}
const imgUploadFn = async (blobInfo, success, failure) => {
// Can limit image size
// if (blobInfo.blob().size / 1024 / 1024 > 2) {
// failure('Upload failed, please control the image size within 2M')
// } else {}
const formData = new FormData()
formData.append('file', blobInfo.blob(), blobInfo.filename())
formData.append('batch_id', props.batchId)
const response = await axios.post('https://api.madomotor.ir'.props.imgUploadUrl, formData)
if (response && response.status == 200) {
return success(response.data.data.url)
}
return failure(`HTTP Error: ${response.status}`)
}
const { myInit, contentValue, timeout } = toRefs(state)
</script>
<style></style>
+113 -31
View File
@@ -1,23 +1,57 @@
<template> <template>
<div class="tabs-block"> <div class="tabs-block">
<div class="tabs-block__nav"> <div class="tabs-block__nav">
<button <div class="tabs-block__tabs">
<button
v-for="tab in tabs"
:key="tab.name"
type="button"
class="tabs-block__tab"
:class="{ 'tabs-block__tab--active': activeTab === tab.name }"
@click="onChange(tab.name)"
>
<span class="tabs-block__tab-card">
<SvgIcon v-if="tab.icon" :name="tab.icon" :size="16" color="#bcbcbc" />
<span class="tabs-block__tab-label">{{ tab.label }}</span>
</span>
</button>
</div>
<div
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.name" :key="`btn-${tab.name}`"
type="button" class="tabs-block__action tabs-block__action--desktop"
class="tabs-block__btn"
:class="{ 'tabs-block__btn--active': activeTab === tab.name }"
@click="onChange(tab.name)"
> >
<SvgIcon <BaseButton
v-if="tab.icon" v-if="tab.hasButton && activeTab === tab.name"
:name="tab.icon" :text="tab.textButton"
:size="16" custom-class="tabs-block__action-btn"
:color="activeTab === tab.name ? 'var(--color-primary)' : '#bcbcbc'" @click="onActionClick(tab)"
/> >
<span>{{ tab.label }}</span> <template #appendIcon>
</button> <SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div> </div>
<div
v-for="tab in tabs"
:key="`mbtn-${tab.name}`"
class="tabs-block__action tabs-block__action--mobile"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
<div class="tabs-block__content"> <div class="tabs-block__content">
<slot :name="activeTab" /> <slot :name="activeTab" />
</div> </div>
@@ -26,7 +60,7 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
@@ -51,39 +85,87 @@ const onChange = (name) => {
emit('update:modelValue', name) emit('update:modelValue', name)
emit('change-tab', name) emit('change-tab', name)
} }
const onActionClick = (tab) => {
tab.buttonAction?.(tab)
emit('button-click', tab)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tabs-block { .tabs-block {
&__nav { &__nav {
display: flex; display: flex;
gap: 0.5rem; gap: 0.625rem;
border-block-end: 1px solid #eee;
margin-bottom: 1rem; margin-bottom: 1rem;
overflow: auto hidden;
} }
&__btn { &__tabs {
display: inline-flex; flex: 1;
align-items: center; display: flex;
gap: 0.375rem; gap: 0.625rem;
padding: 0.625rem 1rem; }
&__tab {
flex: 1;
border: none; border: none;
background: transparent; background: transparent;
padding: 0;
cursor: pointer; cursor: pointer;
font-family: var(--font-family-fa); transition: opacity 0.15s ease;
font-size: 0.875rem; opacity: 0.6;
color: #848484;
border-block-end: 2px solid transparent;
margin-block-end: -1px;
white-space: nowrap;
&--active { &--active {
color: var(--color-primary); opacity: 1;
border-color: var(--color-primary);
} }
} }
&__tab-card {
display: flex;
align-items: center;
gap: 0.375rem;
background: rgba(255, 255, 255, 80%);
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
padding: 0.875rem;
border-radius: 0.75rem;
}
&__tab-label {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.875rem;
color: #4b4b4b;
}
&__action {
display: flex;
align-items: stretch;
&--desktop {
display: none;
min-width: fit-content;
margin-block-end: 0.625rem;
@media (min-width: 768px) {
display: flex;
}
}
&--mobile {
display: flex;
margin-block-end: 0.625rem;
@media (min-width: 768px) {
display: none;
}
}
}
&__action-btn {
padding: 0 1.25rem;
min-width: fit-content;
}
&__content { &__content {
padding-block: 0.5rem; padding-block: 0.5rem;
} }
@@ -0,0 +1,95 @@
<template>
<div class="video-player">
<video
v-if="src"
ref="player"
:src="src"
:poster="poster || ''"
controls
playsinline
class="video-player__media"
@timeupdate="onTimeUpdate"
/>
<div v-else class="video-player__placeholder">
<SvgIcon name="paper-plane-right" :size="48" color="rgba(0, 112, 116, 0.31)" />
<p>ویدیویی برای نمایش وجود ندارد</p>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
src: { type: String, default: '' },
poster: { type: String, default: '' },
videoId: { type: [String, Number], default: '' },
})
const player = ref(null)
const storageKey = () => (props.videoId ? `video_progress_${props.videoId}` : '')
onMounted(() => {
if (!player.value || !props.videoId) return
const saved = localStorage.getItem(storageKey())
if (saved) {
const t = Number.parseFloat(saved)
if (Number.isFinite(t)) player.value.currentTime = t
}
})
const onTimeUpdate = () => {
if (!player.value || !props.videoId) return
try {
localStorage.setItem(storageKey(), String(player.value.currentTime))
} catch {
/* storage unavailable */
}
}
onBeforeUnmount(() => {
if (player.value) {
player.value.pause()
player.value.src = ''
}
})
</script>
<style lang="scss" scoped>
.video-player {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 1.5rem;
overflow: hidden;
background: #000;
display: flex;
align-items: center;
justify-content: center;
&__media {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
height: 100%;
background: rgba(0, 112, 116, 4%);
color: #007074;
font-family: var(--font-family-fa);
font-size: 0.875rem;
}
&__placeholder p {
margin: 0;
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<button
type="button"
class="checkbox-field"
:class="{
'checkbox-field--selected': modelValue,
'checkbox-field--disabled': disabled,
}"
:disabled="disabled"
@click="onToggle"
>
<span class="checkbox-field__box" />
<span v-if="label" class="checkbox-field__label">{{ label }}</span>
<slot />
</button>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
const onToggle = () => {
if (props.disabled) return
const next = !props.modelValue
emit('update:modelValue', next)
emit('change', next)
}
</script>
<style lang="scss" scoped>
.checkbox-field {
display: inline-flex;
align-items: center;
gap: 0.625rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
font-family: var(--font-family-fa);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.85;
}
&--disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--selected .checkbox-field__box {
background: #999999;
border-color: #999999;
}
&__box {
width: 0.95rem;
height: 0.95rem;
border-radius: 0.3rem;
border: 0.75px solid #c2c2c2;
background: transparent;
flex-shrink: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
&__label {
font-weight: 400;
font-size: 0.95rem;
line-height: 1.4;
color: #5d5d5d;
white-space: nowrap;
}
}
</style>
+469 -121
View File
@@ -4,7 +4,7 @@
<div <div
:id="inputId" :id="inputId"
ref="referenceEl" ref="referenceRef"
tabindex="0" tabindex="0"
class="datepicker-field__trigger" class="datepicker-field__trigger"
:class="{ :class="{
@@ -22,10 +22,10 @@
</div> </div>
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <transition name="fade">
<div <div
v-show="isOpen" v-show="isOpen"
ref="floatingEl" ref="floatingRef"
class="date-picker-dropdown datepicker-field__dropdown" class="date-picker-dropdown datepicker-field__dropdown"
:style="floatingStyles" :style="floatingStyles"
> >
@@ -34,146 +34,354 @@
locale="fa" locale="fa"
inline inline
editable editable
:min="min" compact-time
:max="max" :min="jalaliMin"
:max="jalaliMax"
:type="type" :type="type"
:simple="simple" :simple="simple"
:auto-submit="type === 'date'" :range="range"
:auto-submit="false"
:time-picker="type === 'datetime'"
:compact-time="type === 'datetime'"
:format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'" :format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'" :display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:confirm="type === 'datetime'"
@update:model-value="onSelect" @update:model-value="onSelect"
/> />
</div> </div>
</Transition> </transition>
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script setup> <script>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import DatePicker from 'vue3-persian-datetime-picker'
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import DatePicker from 'vue3-persian-datetime-picker'
import { computePosition, autoUpdate, offset, shift, flip, size } from '@floating-ui/dom'
import { import {
formatJalaaliDate, JalaliToGregorianString,
formatJalaaliDateTime, gregorianToJalaliString,
jalaaliStringToIsoDate, gregorianToJalaliStringLong,
jalaaliStringToIsoDateTime, JalaliToGregorianStringWithTime,
} from '@/utils/date-utils' } from '@/utils/date-convertor'
let uid = 0 let uid = 0
const nextUid = () => `datepicker-field-${++uid}` const nextUid = () => `datepicker-field-${++uid}`
const props = defineProps({ export default {
modelValue: { type: String, default: '' }, name: 'DatePickerField',
name: { type: String, default: '' }, components: { DatePicker, SvgIcon },
label: { type: String, default: '' },
placeholder: { type: String, default: 'یک تاریخ را انتخاب کنید' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
vibration: { type: Boolean, default: false },
min: { type: String, default: undefined },
max: { type: String, default: undefined },
type: { type: String, default: 'date' },
simple: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change']) props: {
modelValue: { type: String, default: '' },
name: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: 'یک تاریخ را انتخاب کنید' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
vibration: { type: Boolean, default: false },
min: { type: String, default: undefined },
max: { type: String, default: undefined },
type: { type: String, default: 'date' },
simple: { type: Boolean, default: false },
range: { type: [Boolean, Array], default: false },
autoSubmit: { type: Boolean, default: true },
},
const inputId = computed(() => props.name || nextUid()) emits: ['update:modelValue', 'change'],
const referenceEl = ref(null)
const floatingEl = ref(null)
const isOpen = ref(false)
const internalValue = ref(null)
const floatingStyles = ref({ position: 'absolute', top: '0px', left: '0px' })
let cleanup = null
const displayValue = computed(() => internalValue.value || props.placeholder) data() {
return {
inputId: this.name || nextUid(),
isOpen: false,
internalValue: null,
cleanup: null,
floatingStyles: {
position: 'absolute',
top: '0px',
left: '0px',
},
confirmHandler: null,
mutationObserver: null,
}
},
const syncFromProp = () => { computed: {
const v = props.modelValue displayValue() {
if (v && typeof v === 'string') { return this.internalValue || this.placeholder
internalValue.value = },
props.type === 'datetime' ? formatJalaaliDateTime(v) : formatJalaaliDate(v) jalaliMin() {
} else { if (!this.min) return
internalValue.value = null if (this.type === 'datetime') {
} return gregorianToJalaliStringLong(this.min, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.min, '/', false)
},
jalaliMax() {
if (!this.max) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.max, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.max, '/', false)
},
},
watch: {
modelValue: {
immediate: true,
handler(val) {
this.syncFromModel(val)
},
},
},
beforeUnmount() {
this.cleanup?.()
this.removeConfirmButtonListener()
document.removeEventListener('mousedown', this.handleOutside)
},
methods: {
syncFromModel(val) {
if (val && typeof val === 'string') {
this.internalValue =
this.type === 'datetime'
? gregorianToJalaliStringLong(val, '/', false)
: gregorianToJalaliString(val, '/', false)
} else {
this.internalValue = null
}
},
toggle() {
if (this.disabled) return
this.isOpen ? this.close() : this.open()
},
open() {
this.isOpen = true
this.$nextTick(() => {
this.initFloating()
if (this.type === 'datetime') {
this.$nextTick(() => {
this.attachConfirmButtonListener()
})
}
})
},
close() {
this.isOpen = false
this.cleanup?.()
this.cleanup = null
this.removeConfirmButtonListener()
},
removeConfirmButtonListener() {
if (this.confirmHandler) {
this.confirmHandler.element.removeEventListener('click', this.confirmHandler.handler)
delete this.confirmHandler.element.dataset.confirmListener
this.confirmHandler = null
}
if (this.mutationObserver) {
this.mutationObserver.disconnect()
this.mutationObserver = null
}
},
initFloating() {
const reference = this.$refs.referenceRef
const floating = this.$refs.floatingRef
if (!reference || !floating) return
this.cleanup = autoUpdate(reference, floating, () => {
computePosition(reference, floating, {
placement: 'bottom-start',
middleware: [
offset(6),
flip(),
shift({ padding: 8 }),
size({
apply({ rects }) {
Object.assign(floating.style, {
minWidth: `${rects.reference.width}px`,
})
},
}),
],
}).then(({ x, y }) => {
Object.assign(this.floatingStyles, {
left: `${x}px`,
top: `${y}px`,
})
})
})
document.addEventListener('mousedown', this.handleOutside)
},
handleOutside(e) {
const r = this.$refs.referenceRef
const f = this.$refs.floatingRef
if (r?.contains(e.target) || f?.contains(e.target)) return
this.close()
document.removeEventListener('mousedown', this.handleOutside)
},
onSelect(jalaliValue) {
if (!jalaliValue) return
if (this.type === 'datetime') {
const gregorian = JalaliToGregorianStringWithTime(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
} else {
const gregorian = JalaliToGregorianString(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
this.close()
}
},
attachConfirmButtonListener() {
const findAndAttach = () => {
const dropdown = this.$refs.floatingRef
if (!dropdown) return false
let confirmBtn = dropdown.querySelector('.vpd-actions')
if (confirmBtn) {
const allButtons = confirmBtn.querySelectorAll('button')
for (const btn of allButtons) {
if (btn.innerHTML.includes('svg') || btn.innerText.includes('تایید')) {
confirmBtn = btn
break
}
}
}
if (confirmBtn && !Object.hasOwn(confirmBtn.dataset, 'confirmListener')) {
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
confirmBtn.addEventListener('click', handler, true)
confirmBtn.dataset.confirmListener = 'true'
this.confirmHandler = { element: confirmBtn, handler }
return true
}
return false
}
if (!findAndAttach()) {
const timeouts = [100, 300, 500, 1000]
timeouts.forEach((delay) => {
setTimeout(() => {
if (this.isOpen) {
findAndAttach()
}
}, delay)
})
}
this.setupMutationObserver()
},
setupMutationObserver() {
const dropdown = this.$refs.floatingRef
if (!dropdown) return
this.mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
setTimeout(() => {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (this.isConfirmButton(node)) {
this.attachListenerToButton(node)
}
const btns = node.querySelectorAll ? node.querySelectorAll('button') : []
for (const btn of btns) {
if (this.isConfirmButton(btn)) {
this.attachListenerToButton(btn)
}
}
}
}
}, 50)
}
}
})
this.mutationObserver.observe(dropdown, {
childList: true,
subtree: true,
})
},
isConfirmButton(element) {
if (!element || !element.tagName) return false
if (
element.classList?.contains('vpd-confirm-btn') ||
element.classList?.contains('vpd-action-btn')
) {
return true
}
if (element.innerText?.includes('تایید')) {
return true
}
if (element.innerHTML?.includes('svg')) {
const svgPaths = element.innerHTML.match(/d="[^"]*"/g) || []
const hasCheckIcon = svgPaths.some(
(path) =>
path.includes('M20 6L9 17l-5-5') ||
path.includes('M9 16.17L4.83 12') ||
path.includes('M5 13l4 4L19 7')
)
return hasCheckIcon
}
return false
},
attachListenerToButton(btn) {
if (Object.hasOwn(btn.dataset, 'confirmListener')) return
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
btn.addEventListener('click', handler, true)
btn.dataset.confirmListener = 'true'
this.confirmHandler = { element: btn, handler }
},
handleConfirm() {
if (!this.internalValue) return
try {
const gregorian = JalaliToGregorianStringWithTime(this.internalValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.close()
} catch (error) {
console.error('Error in handleConfirm:', error)
}
},
},
} }
watch(() => props.modelValue, syncFromProp, { immediate: true })
const onSelect = (jalaliValue) => {
if (!jalaliValue) return
internalValue.value = jalaliValue
const gregorian =
props.type === 'datetime'
? jalaaliStringToIsoDateTime(jalaliValue)
: jalaaliStringToIsoDate(jalaliValue)
emit('update:modelValue', gregorian)
emit('change', gregorian)
if (props.type === 'date') close()
}
const mountFloating = () => {
const reference = referenceEl.value
const floating = floatingEl.value
if (!reference || !floating) return
cleanup = autoUpdate(reference, floating, () => {
computePosition(reference, floating, {
placement: 'bottom-start',
middleware: [
offset(6),
flip(),
shift({ padding: 8 }),
size({
apply({ rects }) {
Object.assign(floating.style, { minWidth: `${rects.reference.width}px` })
},
}),
],
}).then(({ x, y }) => {
floatingStyles.value = { position: 'absolute', left: `${x}px`, top: `${y}px` }
})
})
document.addEventListener('mousedown', handleOutsideClick)
}
const unmountFloating = () => {
cleanup?.()
cleanup = null
document.removeEventListener('mousedown', handleOutsideClick)
}
const handleOutsideClick = (event) => {
const reference = referenceEl.value
const floating = floatingEl.value
if (
reference &&
!reference.contains(event.target) &&
floating &&
!floating.contains(event.target)
) {
close()
}
}
const open = () => {
isOpen.value = true
setTimeout(mountFloating, 0)
}
const close = () => {
isOpen.value = false
unmountFloating()
}
const toggle = () => {
if (props.disabled) return
isOpen.value ? close() : open()
}
onBeforeUnmount(() => unmountFloating())
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -255,3 +463,143 @@ onBeforeUnmount(() => unmountFloating())
} }
} }
</style> </style>
<style>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.date-picker-dropdown .vpd-main .vpd-input-group {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-container {
margin: 0 !important;
font-family: iran-yekan;
width: 100%;
}
.date-picker-dropdown .vpd-wrapper .vpd-content {
width: auto;
border-radius: 8px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-date {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-header {
background-color: transparent !important;
padding: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-year-label {
color: #5f5f5f;
text-align: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body {
overflow-x: hidden;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-controls button {
display: flex;
align-items: center;
justify-content: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-clearfix.vpd-week
.vpd-weekday {
width: auto;
float: none;
clear: both;
padding: 0 12px;
}
@media screen and (max-width: 400px) {
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week,
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
padding: 0 5px;
}
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.direction-prev
.vpd-clearfix {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
height: fit-content !important;
padding: 0 12px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .vpd-day {
width: 35px;
height: 35px;
float: right;
line-height: 35px;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']).vpd-selected
.vpd-day-effect,
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']):hover
.vpd-day-effect {
background-color: #f36675 !important;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day
.vpd-day-effect {
width: 35px;
height: 35px;
top: -2px;
left: 0;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-close-addon {
background-color: #f5f5f5;
border-radius: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-month-label {
width: auto;
}
</style>
+2 -2
View File
@@ -49,7 +49,7 @@
@click="removeFile(index, file)" @click="removeFile(index, file)"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="16" /> <SvgIcon name="close" color="red" :size="16" />
</template> </template>
</CircleButton> </CircleButton>
</div> </div>
@@ -62,8 +62,8 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] },
+4 -5
View File
@@ -7,7 +7,7 @@
class="image-cropper__upload-btn" class="image-cropper__upload-btn"
@click="triggerUpload" @click="triggerUpload"
> >
<SvgIcon name="upload" :size="64" /> <SvgIcon name="upload" color="" :size="64" />
</button> </button>
<div v-if="isCropping && image" class="image-cropper__crop-stage"> <div v-if="isCropping && image" class="image-cropper__crop-stage">
@@ -83,12 +83,11 @@
</template> </template>
<script setup> <script setup>
import 'vue-advanced-cropper/dist/style.css'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
import { markRaw, onBeforeUnmount, ref, watch } from 'vue' import { markRaw, onBeforeUnmount, ref, watch } from 'vue'
import { Cropper, CircleStencil } from 'vue-advanced-cropper' import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: [Object, String], default: null }, modelValue: { type: [Object, String], default: null },
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="image-uploader">
<div class="image-uploader__stage">
<div v-if="previewUrl" class="image-uploader__preview">
<img :src="previewUrl" :alt="fileName || 'تصویر'" />
</div>
<button
v-else
type="button"
class="image-uploader__main-btn"
:disabled="disabled"
@click="triggerFilePicker"
>
<SvgIcon name="upload" :size="48" color="var(--color-thd-gray)" />
</button>
<p v-if="fileName && previewUrl" class="image-uploader__name">{{ fileName }}</p>
</div>
<div v-if="previewUrl" class="image-uploader__actions">
<button
type="button"
class="image-uploader__btn"
:disabled="disabled"
title="جایگزینی تصویر"
@click="triggerFilePicker"
>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</button>
<button type="button" class="image-uploader__btn" title="حذف" @click="clear">
<SvgIcon name="close" :size="20" color="var(--color-thd-gray)" />
</button>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="image-uploader__file-input"
@change="onFileSelected"
/>
<p v-if="error" class="image-uploader__error">{{ error }}</p>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { onBeforeUnmount, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
modelValue: { type: [Object, File, String, null], default: null },
accept: { type: String, default: 'image/*' },
maxSizeKb: { type: Number, default: 5120 },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const initialUrl =
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
const previewUrl = ref(initialUrl)
const fileName = ref(
props.modelValue instanceof File ? props.modelValue.name : props.modelValue?.name || ''
)
const fileInput = ref(null)
const revokePreview = () => {
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
}
const setFile = (file) => {
revokePreview()
previewUrl.value = URL.createObjectURL(file)
fileName.value = file.name
emit('update:modelValue', file)
}
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (event.target) event.target.value = ''
if (!file) return
if (file.size > props.maxSizeKb * 1024) {
toast.error(`حجم فایل نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
return
}
setFile(file)
}
const clear = () => {
revokePreview()
previewUrl.value = null
fileName.value = ''
emit('update:modelValue', null)
}
watch(
() => props.modelValue,
(val) => {
if (val == null) {
revokePreview()
previewUrl.value = null
fileName.value = ''
}
}
)
onBeforeUnmount(revokePreview)
</script>
<style lang="scss" scoped>
.image-uploader {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #eeeeee;
border-radius: 1.5rem;
min-height: 18rem;
justify-content: space-between;
&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
}
&__main-btn {
width: 8rem;
height: 8rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover:enabled {
transform: scale(1.02);
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__preview {
width: 8rem;
height: 8rem;
border-radius: 1rem;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__name {
max-width: 14rem;
margin: 0;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #5d5d5d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
transition: transform 0.15s ease;
&:hover:enabled {
transform: scale(1.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__file-input {
display: none;
}
&__error {
margin: 0;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
</style>
+1 -2
View File
@@ -24,9 +24,8 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import TextField from '@/components/form/TextField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
+1 -2
View File
@@ -74,9 +74,8 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
let uid = 0 let uid = 0
const nextUid = () => `select-field-${++uid}` const nextUid = () => `select-field-${++uid}`
+422
View File
@@ -0,0 +1,422 @@
<template>
<div class="voice-recorder">
<div class="voice-recorder__stage">
<button
v-if="!isRecording && !audioUrl"
type="button"
class="voice-recorder__main-btn"
:disabled="disabled"
@click="startRecording"
>
<SvgIcon name="microphone" :size="48" color="transparent" />
</button>
<button
v-else-if="isRecording"
type="button"
class="voice-recorder__main-btn voice-recorder__main-btn--recording"
@click="stopRecording"
>
<SvgIcon name="close" :size="40" color="var(--color-error)" />
</button>
<div v-else class="voice-recorder__preview">
<button type="button" class="voice-recorder__main-btn" @click="togglePlay">
<SvgIcon
:name="isPlaying ? 'close' : 'paper-plane-right'"
:size="40"
color="var(--color-thd-gray)"
/>
</button>
<audio
ref="audioEl"
:src="audioUrl"
class="voice-recorder__audio"
@ended="onEnded"
@timeupdate="onTimeUpdate"
/>
</div>
<p v-if="isRecording" class="voice-recorder__status">
<span class="voice-recorder__dot" />
در حال ضبط...
<span class="voice-recorder__timer">{{ formattedTime }}</span>
</p>
</div>
<div class="voice-recorder__actions">
<button
v-if="audioUrl && !isRecording && !disabled"
type="button"
class="voice-recorder__btn"
:title="'ضبط مجدد'"
@click="resetAndStart"
>
<SvgIcon name="microphone" :size="20" color="transparent" />
</button>
<button
type="button"
class="voice-recorder__btn"
:disabled="isRecording || disabled"
:title="'بارگذاری فایل'"
@click="triggerFilePicker"
v-if="canUpload"
>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</button>
<input
ref="fileInput"
type="file"
accept="audio/*"
class="voice-recorder__file-input"
@change="onFileSelected"
/>
</div>
<div v-if="audioUrl" class="voice-recorder__progress">
<div class="voice-recorder__progress-fill" :style="{ width: `${progress}%` }" />
</div>
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
</div>
<PermissionModal v-if="isModal('VoiceRecorderPermissionModal')" />
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
const props = defineProps({
modelValue: { type: [Object, File, String, null], default: null },
maxSeconds: { type: Number, default: 600 },
maxSizeKb: { type: Number, default: 25_600 },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
canUpload: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'recorded'])
const { openModal, isModal } = useModal()
const audioUrl = ref(
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
)
const audioFile = ref(null)
const isRecording = ref(false)
const isPlaying = ref(false)
const elapsedMs = ref(0)
const progress = ref(0)
const audioEl = ref(null)
const fileInput = ref(null)
let mediaRecorder = null
let mediaStream = null
let chunks = []
let timerHandle = null
let timerStart = 0
const formattedTime = computed(() => {
const s = Math.floor(elapsedMs.value / 1000)
const mm = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${mm}:${ss}`
})
const stopTracks = () => {
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
}
const clearTimer = () => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
}
const revokePreview = () => {
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
}
const setRecording = (file) => {
revokePreview()
audioFile.value = file
audioUrl.value = URL.createObjectURL(file)
progress.value = 0
isPlaying.value = false
emit('update:modelValue', file)
emit('recorded', file)
}
const startRecording = async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
chunks = []
mediaRecorder = new window.MediaRecorder(mediaStream)
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => {
isRecording.value = false
stopTracks()
const blob = new Blob(chunks, { type: 'audio/webm' })
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: 'audio/webm',
lastModified: Date.now(),
})
setRecording(file)
}
mediaRecorder.start()
isRecording.value = true
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
elapsedMs.value = Date.now() - timerStart
if (elapsedMs.value >= props.maxSeconds * 1000) stopRecording()
}, 250)
} catch {
openModal('VoiceRecorderPermissionModal', { kind: 'audio' })
}
}
const stopRecording = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
clearTimer()
}
const resetAndStart = () => {
if (isPlaying.value) {
audioEl.value?.pause()
isPlaying.value = false
}
revokePreview()
audioFile.value = null
audioUrl.value = null
progress.value = 0
emit('update:modelValue', null)
startRecording()
}
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (event.target) event.target.value = ''
if (!file) return
if (file.size > props.maxSizeKb * 1024) {
toast.error(`حجم فایل صوتی نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
return
}
setRecording(file)
}
const togglePlay = () => {
if (!audioEl.value) return
if (audioEl.value.paused) {
audioEl.value.play().then(() => {
isPlaying.value = true
})
} else {
audioEl.value.pause()
isPlaying.value = false
}
}
const onEnded = () => {
isPlaying.value = false
progress.value = 0
}
const onTimeUpdate = () => {
const a = audioEl.value
if (!a?.duration) return
progress.value = (a.currentTime / a.duration) * 100
}
watch(
() => props.modelValue,
(val) => {
if (val == null) {
revokePreview()
audioFile.value = null
audioUrl.value = null
progress.value = 0
}
}
)
onBeforeUnmount(() => {
stopRecording()
stopTracks()
clearTimer()
revokePreview()
})
</script>
<style lang="scss" scoped>
.voice-recorder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f3f4f6;
border-radius: 1.5rem;
min-height: 18rem;
justify-content: space-between;
height: 100%;
&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
height: 100%;
}
&__main-btn {
width: 8rem;
height: 8rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover:enabled {
transform: scale(1.02);
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--recording {
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
animation: pulse 1.4s infinite;
}
}
&__preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
&__audio {
display: none;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-error);
margin: 0;
}
&__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: var(--color-error);
}
&__timer {
margin-inline-start: 0.5rem;
font-family: var(--font-family-en);
color: #5d5d5d;
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
transition: transform 0.15s ease;
&:hover:enabled {
transform: scale(1.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__file-input {
display: none;
}
&__progress {
width: 100%;
height: 0.125rem;
border-radius: 9999px;
background: var(--color-thd-gray);
overflow: hidden;
}
&__progress-fill {
height: 100%;
background: rgba(0, 0, 0, 40%);
border-radius: 9999px;
transition: width 0.2s ease;
}
&__error {
margin: 0;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
}
50% {
box-shadow: 0 0 0 10px rgba(204, 40, 49, 18%);
}
}
</style>
+5 -1
View File
@@ -14,10 +14,13 @@ import { computed } from 'vue'
import { iconRegistry } from '@/components/icons/registry' import { iconRegistry } from '@/components/icons/registry'
/** @typedef {import('@/components/icons/icon-names').IconName} IconName */
const props = defineProps({ const props = defineProps({
/** @type {import('vue').PropType<IconName>} */
name: { type: String, required: true }, name: { type: String, required: true },
size: { type: [String, Number], default: '1.25rem' }, size: { type: [String, Number], default: '1.25rem' },
color: { type: String, default: 'currentColor' }, color: { type: String, default: 'transparent' },
}) })
const component = computed(() => { const component = computed(() => {
@@ -25,6 +28,7 @@ const component = computed(() => {
if (!c && import.meta.env.DEV) { if (!c && import.meta.env.DEV) {
console.warn(`[SvgIcon] unknown icon "${props.name}"`) console.warn(`[SvgIcon] unknown icon "${props.name}"`)
} }
return c return c
}) })
+48
View File
@@ -0,0 +1,48 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerated whenever src/assets/icons/*.svg changes (via scripts/vite-icon-names.js).
export type IconName =
| 'arrow-left'
| 'arrows-clockwise'
| 'attach-file'
| 'bell'
| 'book'
| 'calendar'
| 'caret-down'
| 'caret-left'
| 'caret-right'
| 'chat'
| 'chat-centered-dots'
| 'check'
| 'check-square'
| 'close'
| 'copy'
| 'eye'
| 'eye-slash'
| 'file'
| 'funnel'
| 'heart'
| 'instagram'
| 'link'
| 'list-bullets'
| 'map-pin-simple-area'
| 'menu'
| 'microphone'
| 'mood'
| 'paper-plane'
| 'paper-plane-right'
| 'pencil'
| 'phone'
| 'plus'
| 'scroll'
| 'spinner'
| 'square'
| 'telegram'
| 'trash'
| 'upload'
| 'user'
| 'users'
| 'users-three'
| 'warning'
export const iconNames: readonly IconName[]
+3 -4
View File
@@ -1,7 +1,6 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { tokenService } from '@/services/api/token-service'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { tokenService } from '@/services/api/token-service'
export default function useAuth() { export default function useAuth() {
const store = useAuthStore() const store = useAuthStore()
@@ -16,8 +15,8 @@ export default function useAuth() {
const savePhoneNumber = (phoneNumber) => { const savePhoneNumber = (phoneNumber) => {
if (!phoneNumber) return if (!phoneNumber) return
const current = tokenService.getUserInfo() || {} const current = tokenService.getUserInfo() || {}
if (!current.phoneNumber) { if (!current.phone && !current.phoneNumber) {
store.setUser({ ...current, phoneNumber }) store.setUser({ ...current, phone: phoneNumber })
} }
} }
+9 -3
View File
@@ -1,8 +1,14 @@
import { ref } from 'vue' import { ref } from 'vue'
export default function useYup(validationSchema) { export default function useYup(validationSchema, getValues) {
const errors = ref({}) const errors = ref({})
const readFormValues = () => {
if (typeof getValues !== 'function') return {}
const v = getValues()
return v && typeof v === 'object' ? v : {}
}
const validate = async (values, options) => { const validate = async (values, options) => {
const mergeOptions = { abortEarly: false, stripUnknown: true, ...options } const mergeOptions = { abortEarly: false, stripUnknown: true, ...options }
try { try {
@@ -30,7 +36,7 @@ export default function useYup(validationSchema) {
const pathParts = field.split('.') const pathParts = field.split('.')
const lastPart = pathParts.pop() const lastPart = pathParts.pop()
let schemaContext = validationSchema let schemaContext = validationSchema
let context = {} let context = { ...readFormValues() }
if (pathParts.length > 0) { if (pathParts.length > 0) {
pathParts.forEach((part) => { pathParts.forEach((part) => {
if (part.includes('[')) { if (part.includes('[')) {
@@ -47,7 +53,7 @@ export default function useYup(validationSchema) {
await schemaContext.validateAt(lastPart, context) await schemaContext.validateAt(lastPart, context)
errors.value[field] = null errors.value[field] = null
} else { } else {
await validationSchema.validateAt(field, { [field]: value }) await validationSchema.validateAt(field, { ...context, [field]: value })
errors.value[field] = null errors.value[field] = null
} }
} catch (error) { } catch (error) {
+16 -5
View File
@@ -36,13 +36,13 @@ export const fields = {
activitySummary: 'خلاصهٔ فعالیت', activitySummary: 'خلاصهٔ فعالیت',
avatar: 'تصویر پروفایل', avatar: 'تصویر پروفایل',
avatarId: 'تصویر پروفایل',
image: 'تصویر', image: 'تصویر',
status: 'وضعیت', status: 'وضعیت',
role: 'نقش', role: 'نقش',
roleId: 'نقش', roleId: 'نقش',
isActive: 'فعال', isActive: 'فعال',
isActiveByDefault: 'فعال به‌صورت پیش‌فرض',
randomize: 'تصادفی', randomize: 'تصادفی',
startDate: 'تاریخ شروع', startDate: 'تاریخ شروع',
@@ -53,7 +53,6 @@ export const fields = {
capacity: 'ظرفیت', capacity: 'ظرفیت',
minCapacity: 'حداقل ظرفیت', minCapacity: 'حداقل ظرفیت',
maxCapacity: 'حداکثر ظرفیت', maxCapacity: 'حداکثر ظرفیت',
defaultCapacity: 'ظرفیت پیش‌فرض',
order: 'ترتیب', order: 'ترتیب',
priority: 'اولویت', priority: 'اولویت',
@@ -64,18 +63,18 @@ export const fields = {
minAssignments: 'حداقل تکالیف', minAssignments: 'حداقل تکالیف',
termId: 'ترم', termId: 'ترم',
templateId: 'قالب',
courseId: 'دوره', courseId: 'دوره',
courseTemplateId: 'قالب دوره',
sessionId: 'جلسه', sessionId: 'جلسه',
sessionType: 'نوع جلسه', sessionType: 'نوع جلسه',
teacherId: 'مدرس', teacherId: 'مدرس',
defaultTeacherId: 'مدرس پیش‌فرض',
studentIds: 'دانش‌آموزان', studentIds: 'دانش‌آموزان',
educationStatus: 'وضعیت تحصیلی', educationStatus: 'وضعیت تحصیلی',
fieldOfStudy: 'رشتهٔ تحصیلی', fieldOfStudy: 'رشتهٔ تحصیلی',
seminaryLevel: 'مقطع حوزه', seminaryLevel: 'مقطع حوزه',
universityLevel: 'مقطع دانشگاهی',
universityName: 'نام حوزه علمیه / دانشگاه',
workExperienceSummary: 'خلاصهٔ سوابق شغلی',
hijabApproach: 'رویکرد حجاب', hijabApproach: 'رویکرد حجاب',
prerequisites: 'پیش‌نیازها', prerequisites: 'پیش‌نیازها',
@@ -89,5 +88,17 @@ export const fields = {
otherPlatformDetails: 'جزئیات سایر بسترها', otherPlatformDetails: 'جزئیات سایر بسترها',
faithProductionAudio: 'صدای تولید ایمانی', faithProductionAudio: 'صدای تولید ایمانی',
faithProductionId: 'صدای تولید ایمانی',
leaderMessageVideo: 'ویدیوی پیام رهبر', leaderMessageVideo: 'ویدیوی پیام رهبر',
leaderMessageId: 'ویدیوی پیام رهبر',
responseToLowSatisfaction: 'واکنش به رضایت پایین مخاطب',
responseToLowSatisfactionDescription: 'توضیح واکنش به رضایت پایین مخاطب',
responseToSessionCancellation: 'واکنش به لغو جلسه',
responseToSessionCancellationDescription: 'توضیح واکنش به لغو جلسه',
responseToAudienceConflict: 'واکنش به تعارض با مخاطب',
responseToAudienceConflictDescription: 'توضیح واکنش به تعارض با مخاطب',
responseToCompetingPropagator: 'واکنش به مبلغ رقیب',
responseToCompetingPropagatorDescription: 'توضیح واکنش به مبلغ رقیب',
responseToUnqualifiedAdvisors: 'واکنش به مشاوران غیرمتخصص',
} }
+16 -5
View File
@@ -133,9 +133,22 @@ export const VERIFICATION_MEDIA_TYPE = Object.freeze({
LEADER_MESSAGE: 'leader_message', LEADER_MESSAGE: 'leader_message',
}) })
export const COURSE_CONTENT_TYPE = Object.freeze({
video: 'ویدئو',
voice: 'صوت',
text: 'متن',
})
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
video: 'video/*',
voice: 'audio/*',
text: '.pdf,.doc,.docx,.txt',
})
export const SESSION_TYPE = Object.freeze({ export const SESSION_TYPE = Object.freeze({
in_person: 'حضوری', in_person: 'حضوری',
online: 'آنلاین', online: 'آنلاین',
offline: 'آفلاین',
video: 'ویدئو', video: 'ویدئو',
audio: 'صوتی', audio: 'صوتی',
text: 'متن', text: 'متن',
@@ -156,16 +169,14 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
}) })
export const TICKET_STATUS = Object.freeze({ export const TICKET_STATUS = Object.freeze({
pending: 'در انتظار پاسخ', open: 'باز',
answered: 'پاسخ داده شده', answered: 'پاسخ داده شده',
closed: 'بسته شده', closed: 'بسته شده',
}) })
export const STUDENT_COURSE_STATUS = Object.freeze({ export const STUDENT_COURSE_STATUS = Object.freeze({
watching: 'در حال گذراندن', active: 'در حال گذراندن',
waitForExam: 'در انتظار آزمون',
completed: 'تکمیل شده', completed: 'تکمیل شده',
failed: 'عدم قبولی',
}) })
export const PRIORITY_STATUS = Object.freeze({ export const PRIORITY_STATUS = Object.freeze({
@@ -189,7 +200,7 @@ export const SERVICE_TYPE = Object.freeze({
}) })
export const CONSULTATION_STATUS = Object.freeze({ export const CONSULTATION_STATUS = Object.freeze({
in_progress: 'در حال گفتگو', open: 'در حال گفتگو',
answered: 'پاسخ داده شده', answered: 'پاسخ داده شده',
closed: 'بسته شده', closed: 'بسته شده',
}) })
@@ -5,7 +5,7 @@
<div class="assignment-item__sub"> <div class="assignment-item__sub">
<span class="assignment-item__sub-label">دوره:</span> <span class="assignment-item__sub-label">دوره:</span>
<span class="assignment-item__sub-value"> <span class="assignment-item__sub-value">
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }} {{ assignment.course?.title || assignment.courseTitle || '—' }}
</span> </span>
<span class="assignment-item__dot">|</span> <span class="assignment-item__dot">|</span>
<span class="assignment-item__sub-label">جلسه:</span> <span class="assignment-item__sub-label">جلسه:</span>
@@ -76,11 +76,10 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({ const props = defineProps({
assignment: { type: Object, required: true }, assignment: { type: Object, required: true },
@@ -7,9 +7,9 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.courseTemplateId" v-model="form.courseId"
name="courseTemplateId" name="courseId"
label="دوره الگو" label="دوره"
:options="templateOptions" :options="templateOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
@@ -59,22 +59,21 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useDebounce from '@/composables/useDebounce'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions' import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import useDebounce from '@/composables/useDebounce'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ default: () => ({
title: '', title: '',
courseTemplateId: '', courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -86,7 +85,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ const emptyForm = () => ({
title: '', title: '',
courseTemplateId: '', courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -105,19 +104,16 @@ const todayIso = new Date().toISOString()
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({ title: templateSearch.value }))
const templatePagination = ref({ page: 1, perPage: 30 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
templateFilters,
templatePagination
)
const templateOptions = computed(() => templatesResponse.value?.data ?? []) const templateOptions = computed(() => templatesResponse.value?.data ?? [])
const sessionSearch = ref('') const sessionSearch = ref('')
const sessionListFilters = computed(() => ({ const sessionListFilters = computed(() => ({
title: sessionSearch.value, title: sessionSearch.value,
courseTemplateId: form.value.courseTemplateId || undefined, courseId: form.value.courseId || undefined,
})) }))
const sessionPagination = ref({ page: 1, perPage: 30 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination) const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination)
const sessionOptions = computed(() => sessionsResponse.value?.data ?? []) const sessionOptions = computed(() => sessionsResponse.value?.data ?? [])
@@ -1,12 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true"> <BasicModal
:title="isEditMode ? 'ویرایش تکلیف' : 'افزودن تکلیف جدید'"
:title-en="isEditMode ? 'Edit Assignment' : 'Add Assignment'"
width="95%"
max-width="64rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }"> <template #default="{ close }">
<form class="add-assignment" @submit.prevent="onSubmit(close)"> <form class="add-assignment" @submit.prevent="onSubmit(close)">
<LineTitleBlock
:title="isEditMode ? 'ویرایش تکلیف' : 'افزودن تکلیف جدید'"
:title-en="isEditMode ? 'Edit Assignment' : 'Add Assignment'"
/>
<div class="add-assignment__grid"> <div class="add-assignment__grid">
<TextField <TextField
v-model="form.title" v-model="form.title"
@@ -20,15 +22,15 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.courseTemplateId" v-model="form.courseId"
name="courseTemplateId" name="courseId"
label="دوره مرتبط" label="دوره مرتبط"
:options="templateOptions" :options="templateOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTemplates" :on-search="searchTemplates"
:error="errors.courseTemplateId" :error="errors.courseId"
@update:model-value="onCourseChange" @update:model-value="onCourseChange"
/> />
<SelectField <SelectField
@@ -40,7 +42,7 @@
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchSessions" :on-search="searchSessions"
:disabled="!form.courseTemplateId" :disabled="!form.courseId"
:error="errors.sessionId" :error="errors.sessionId"
/> />
<DatePickerField <DatePickerField
@@ -115,30 +117,30 @@
</template> </template>
<script setup> <script setup>
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import { ASSIGNMENT_PRIORITY } from '@/enums'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import TextField from '@/components/form/TextField.vue'
import useYup from '@/composables/useYup' import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { assignmentSchema } from '@/features/admin/assignments/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import { import {
adminAssignmentsKeys, adminAssignmentsKeys,
useAddAdminAssignmentMutation, useAddAdminAssignmentMutation,
useAdminAssignmentQuery, useAdminAssignmentQuery,
useUpdateAdminAssignmentMutation, useUpdateAdminAssignmentMutation,
} from '@/services/query/admin-assignments' } from '@/services/query/admin-assignments'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import useDebounce from '@/composables/useDebounce'
import { ASSIGNMENT_PRIORITY } from '@/enums'
import { assignmentSchema } from '@/features/admin/assignments/schema'
defineOptions({ name: 'AddAssignmentModal' }) defineOptions({ name: 'AddAssignmentModal' })
@@ -156,7 +158,7 @@ const priorityOptions = Object.entries(ASSIGNMENT_PRIORITY).map(([value, label])
const form = ref({ const form = ref({
title: '', title: '',
courseTemplateId: '', courseId: '',
sessionId: '', sessionId: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
@@ -172,11 +174,8 @@ const { validate, validateAt, errors, resetErrors } = useYup(schema)
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({ title: templateSearch.value }))
const templatePagination = ref({ page: 1, perPage: 30 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
templateFilters,
templatePagination
)
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templateOptions = computed(() => { const templateOptions = computed(() => {
const base = templatesResponse.value?.data ?? [] const base = templatesResponse.value?.data ?? []
@@ -189,11 +188,11 @@ const templateOptions = computed(() => {
const sessionSearch = ref('') const sessionSearch = ref('')
const sessionFilters = computed(() => ({ const sessionFilters = computed(() => ({
title: sessionSearch.value, title: sessionSearch.value,
courseTemplateId: form.value.courseTemplateId, courseId: form.value.courseId,
})) }))
const sessionPagination = ref({ page: 1, perPage: 30 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, { const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, {
enabled: () => !!form.value.courseTemplateId, enabled: () => !!form.value.courseId,
}) })
const selectedSession = ref(null) const selectedSession = ref(null)
const sessionOptions = computed(() => { const sessionOptions = computed(() => {
@@ -212,7 +211,7 @@ const searchSessions = useDebounce((q) => {
}, 400) }, 400)
const onCourseChange = (value) => { const onCourseChange = (value) => {
form.value.courseTemplateId = value form.value.courseId = value
form.value.sessionId = '' form.value.sessionId = ''
selectedSession.value = null selectedSession.value = null
} }
@@ -223,13 +222,13 @@ const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
watch(existingAssignment, (assignment) => { watch(existingAssignment, (assignment) => {
if (!assignment) return if (!assignment) return
const tpl = assignment.courseTemplate const tpl = assignment.course
const sessionEntity = assignment.session const sessionEntity = assignment.session
if (tpl) selectedTemplate.value = tpl if (tpl) selectedTemplate.value = tpl
if (sessionEntity) selectedSession.value = sessionEntity if (sessionEntity) selectedSession.value = sessionEntity
form.value = { form.value = {
title: assignment.title || '', title: assignment.title || '',
courseTemplateId: tpl?.id || assignment.courseTemplateId || '', courseId: tpl?.id || assignment.courseId || '',
sessionId: sessionEntity?.id || assignment.sessionId || '', sessionId: sessionEntity?.id || assignment.sessionId || '',
startDate: assignment.startDate || '', startDate: assignment.startDate || '',
endDate: assignment.endDate || '', endDate: assignment.endDate || '',
@@ -310,8 +309,8 @@ const onSubmit = async (close) => {
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
gap: 0.625rem; justify-content: space-between;
} }
&__btn-cancel { &__btn-cancel {
@@ -16,7 +16,7 @@
<p class="assignment-details__sub"> <p class="assignment-details__sub">
<span> <span>
دوره: دوره:
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }} {{ assignment.course?.title || assignment.courseTitle || '—' }}
</span> </span>
<span class="assignment-details__sep">|</span> <span class="assignment-details__sep">|</span>
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span> <span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span>
@@ -90,18 +90,17 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import useModal from '@/composables/useModal'
import { ASSIGNMENT_PRIORITY } from '@/enums'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal'
import { useAdminAssignmentQuery } from '@/services/query/admin-assignments' import { useAdminAssignmentQuery } from '@/services/query/admin-assignments'
import { ASSIGNMENT_PRIORITY } from '@/enums' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'AssignmentDetailsModal' }) defineOptions({ name: 'AssignmentDetailsModal' })
@@ -35,7 +35,7 @@
<div class="submission-details__assignment-meta"> <div class="submission-details__assignment-meta">
<span>ترم: {{ submission.termTitle || '—' }}</span> <span>ترم: {{ submission.termTitle || '—' }}</span>
<span class="submission-details__sep">|</span> <span class="submission-details__sep">|</span>
<span>دوره: {{ submission.courseTemplateTitle || '—' }}</span> <span>دوره: {{ submission.courseTitle || '—' }}</span>
<span class="submission-details__sep">|</span> <span class="submission-details__sep">|</span>
<span>جلسه: {{ submission.sessionTitle || '—' }}</span> <span>جلسه: {{ submission.sessionTitle || '—' }}</span>
<span class="submission-details__sep">|</span> <span class="submission-details__sep">|</span>
@@ -125,28 +125,28 @@
</template> </template>
<script setup> <script setup>
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import useYup from '@/composables/useYup' import { formatJalaaliDate } from '@/utils/date-utils'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { assignmentSubmissionReviewSchema } from '@/features/admin/assignments/schema'
import { import {
adminAssignmentsKeys, adminAssignmentsKeys,
useAdminAssignmentSubmissionQuery, useAdminAssignmentSubmissionQuery,
useReviewAdminAssignmentSubmissionMutation, useReviewAdminAssignmentSubmissionMutation,
} from '@/services/query/admin-assignments' } from '@/services/query/admin-assignments'
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
import { assignmentSubmissionReviewSchema } from '@/features/admin/assignments/schema'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'AssignmentSubmissionDetailsModal' }) defineOptions({ name: 'AssignmentSubmissionDetailsModal' })
@@ -66,18 +66,17 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import { useAdminAssignmentSubmissionsQuery } from '@/services/query/admin-assignments'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import { usePagination } from '@/composables/usePagination'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useAdminAssignmentSubmissionsQuery } from '@/services/query/admin-assignments'
defineOptions({ name: 'AssignmentSubmissionsModal' }) defineOptions({ name: 'AssignmentSubmissionsModal' })
@@ -50,35 +50,34 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import AssignmentsFilters from '@/features/admin/assignments/components/AssignmentsFilters.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import AssignmentItem from '@/features/admin/assignments/components/AssignmentItem.vue' import AssignmentItem from '@/features/admin/assignments/components/AssignmentItem.vue'
import AssignmentsFilters from '@/features/admin/assignments/components/AssignmentsFilters.vue'
import AddAssignmentModal from '@/features/admin/assignments/components/modals/AddAssignmentModal.vue' import AddAssignmentModal from '@/features/admin/assignments/components/modals/AddAssignmentModal.vue'
import AssignmentDetailsModal from '@/features/admin/assignments/components/modals/AssignmentDetailsModal.vue'
import AssignmentSubmissionsModal from '@/features/admin/assignments/components/modals/AssignmentSubmissionsModal.vue' import AssignmentSubmissionsModal from '@/features/admin/assignments/components/modals/AssignmentSubmissionsModal.vue'
import AssignmentSubmissionDetailsModal from '@/features/admin/assignments/components/modals/AssignmentSubmissionDetailsModal.vue' import AssignmentSubmissionDetailsModal from '@/features/admin/assignments/components/modals/AssignmentSubmissionDetailsModal.vue'
import AssignmentDetailsModal from '@/features/admin/assignments/components/modals/AssignmentDetailsModal.vue'
import { import {
adminAssignmentsKeys, adminAssignmentsKeys,
useAdminAssignmentsListQuery, useAdminAssignmentsListQuery,
useDeleteAdminAssignmentMutation, useDeleteAdminAssignmentMutation,
} from '@/services/query/admin-assignments' } from '@/services/query/admin-assignments'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal'
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const filters = ref({ const filters = ref({
title: '', title: '',
courseTemplateId: '', courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -2,7 +2,7 @@ import { number, object, string } from 'yup'
export const assignmentSchema = object().shape({ export const assignmentSchema = object().shape({
title: string().required().min(3), title: string().required().min(3),
courseTemplateId: string().required(), courseId: string().required(),
sessionId: string().required(), sessionId: string().required(),
startDate: string().required(), startDate: string().required(),
endDate: string().required(), endDate: string().required(),
@@ -1,8 +1,8 @@
<template> <template>
<div class="consultation-item"> <div class="consultation-item">
<div class="consultation-item__user"> <div class="consultation-item__user">
<div v-if="consultation.user?.avatarUrl" class="consultation-item__avatar"> <div v-if="consultation.student?.avatarUrl" class="consultation-item__avatar">
<img :src="consultation.user.avatarUrl" :alt="userName" /> <img :src="consultation.student.avatarUrl" :alt="userName" />
</div> </div>
<div v-else class="consultation-item__avatar consultation-item__avatar--placeholder"> <div v-else class="consultation-item__avatar consultation-item__avatar--placeholder">
<SvgIcon name="user" :size="24" color="#bcbcbc" /> <SvgIcon name="user" :size="24" color="#bcbcbc" />
@@ -11,9 +11,7 @@
<p class="consultation-item__name">{{ userName }}</p> <p class="consultation-item__name">{{ userName }}</p>
<p class="consultation-item__request-id"> <p class="consultation-item__request-id">
<span class="consultation-item__request-id-label">شماره درخواست :</span> <span class="consultation-item__request-id-label">شماره درخواست :</span>
<span class="consultation-item__request-id-value"> <span class="consultation-item__request-id-value">{{ consultation.id || '—' }}</span>
{{ consultation.requestId || consultation.id || '—' }}
</span>
</p> </p>
</div> </div>
</div> </div>
@@ -26,7 +24,7 @@
<button <button
type="button" type="button"
class="consultation-item__status" class="consultation-item__status"
:class="`consultation-item__status--${consultation.status || 'in_progress'}`" :class="`consultation-item__status--${consultation.status || 'open'}`"
@click="onStatusClick" @click="onStatusClick"
> >
<span class="consultation-item__status-dot" /> <span class="consultation-item__status-dot" />
@@ -57,12 +55,11 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { CONSULTATION_STATUS } from '@/enums' import { CONSULTATION_STATUS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import DropdownMenu from '@/components/DropdownMenu.vue'
const props = defineProps({ const props = defineProps({
consultation: { type: Object, required: true }, consultation: { type: Object, required: true },
@@ -70,24 +67,11 @@ const props = defineProps({
const emit = defineEmits(['show-details', 'change-status']) const emit = defineEmits(['show-details', 'change-status'])
const userName = computed(() => { const userName = computed(() => props.consultation.student?.name || '—')
const u = props.consultation.user
if (!u) return '—'
return `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.fullName || '—'
})
const statusLabel = computed( const statusLabel = computed(() => CONSULTATION_STATUS[props.consultation.status] || '—')
() => props.consultation.statusLabel || CONSULTATION_STATUS[props.consultation.status] || '—'
)
const createdAt = computed(() => { const createdAt = computed(() => formatJalaaliDate(props.consultation.createdAt) || '—')
if (props.consultation.faCreatedAt) {
return props.consultation.faCreatedTime
? `${props.consultation.faCreatedTime}، ${props.consultation.faCreatedAt}`
: props.consultation.faCreatedAt
}
return formatJalaaliDate(props.consultation.createdAt) || '—'
})
const menuOpen = ref(false) const menuOpen = ref(false)
const menuTrigger = ref(null) const menuTrigger = ref(null)
@@ -247,7 +231,7 @@ const menuItems = computed(() =>
color: #009a12; color: #009a12;
} }
&--in_progress { &--open {
background: rgba(0, 112, 116, 10%); background: rgba(0, 112, 116, 10%);
color: #007074; color: #007074;
} }
@@ -47,13 +47,12 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { CONSULTATION_STATUS } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { CONSULTATION_STATUS } from '@/enums'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -17,7 +17,7 @@
:class="`consultation-details__row--${senderClass(message)}`" :class="`consultation-details__row--${senderClass(message)}`"
> >
<div class="consultation-details__bubble"> <div class="consultation-details__bubble">
<p class="consultation-details__text">{{ message.text }}</p> <p class="consultation-details__text">{{ message.message }}</p>
<span class="consultation-details__time">{{ messageTime(message) }}</span> <span class="consultation-details__time">{{ messageTime(message) }}</span>
</div> </div>
</div> </div>
@@ -48,21 +48,20 @@
</template> </template>
<script setup> <script setup>
import useModal from '@/composables/useModal'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { import {
adminConsultationsKeys, adminConsultationsKeys,
useAdminConsultationQuery, useAdminConsultationQuery,
useSendAdminConsultationMessageMutation, useSendAdminConsultationMessageMutation,
} from '@/services/query/admin-consultations' } from '@/services/query/admin-consultations'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'ConsultationDetailsModal' }) defineOptions({ name: 'ConsultationDetailsModal' })
@@ -78,13 +77,19 @@ const { data: consultation, isLoading } = useAdminConsultationQuery(consultation
const messages = computed(() => consultation.value?.messages ?? []) const messages = computed(() => consultation.value?.messages ?? [])
const consultationDate = computed( const consultationDate = computed(() => formatJalaaliDate(consultation.value?.createdAt) || '—')
() => consultation.value?.faCreatedAt || formatJalaaliDate(consultation.value?.createdAt) || '—'
)
const senderClass = (message) => (message.sender === 'admin' ? 'admin' : 'user') // Student is "the user"; everyone else (counselor, admin) renders on the
// opposite side of the thread. Same rule as the admin tickets modal.
const senderClass = (message) =>
message.senderId === consultation.value?.studentId ? 'user' : 'admin'
const messageTime = (message) => message.time || message.faSentAt || message.sentAt || '—' const messageTime = (message) => {
if (!message.createdAt) return '—'
const d = new Date(message.createdAt)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
}
const text = ref('') const text = ref('')
const canSend = computed(() => text.value.trim().length > 0) const canSend = computed(() => text.value.trim().length > 0)
@@ -95,7 +100,8 @@ const onSend = async () => {
if (!canSend.value || !consultationId.value) return if (!canSend.value || !consultationId.value) return
const value = text.value.trim() const value = text.value.trim()
text.value = '' text.value = ''
await sendMutation.mutateAsync({ id: consultationId.value, payload: { text: value } }) // Backend POST /counselor/tickets/:id/messages body is { message: string }.
await sendMutation.mutateAsync({ id: consultationId.value, payload: { message: value } })
await queryClient.invalidateQueries({ queryKey: adminConsultationsKeys.all }) await queryClient.invalidateQueries({ queryKey: adminConsultationsKeys.all })
} }
@@ -41,24 +41,23 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import ConsultationsFilters from '@/features/admin/consultations/components/ConsultationsFilters.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import ConsultationItem from '@/features/admin/consultations/components/ConsultationItem.vue' import ConsultationItem from '@/features/admin/consultations/components/ConsultationItem.vue'
import ConsultationsFilters from '@/features/admin/consultations/components/ConsultationsFilters.vue'
import ConsultationDetailsModal from '@/features/admin/consultations/components/modals/ConsultationDetailsModal.vue' import ConsultationDetailsModal from '@/features/admin/consultations/components/modals/ConsultationDetailsModal.vue'
import { import {
adminConsultationsKeys, adminConsultationsKeys,
useAdminConsultationsListQuery, useAdminConsultationsListQuery,
useChangeAdminConsultationStatusMutation, useChangeAdminConsultationStatusMutation,
} from '@/services/query/admin-consultations' } from '@/services/query/admin-consultations'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal'
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -21,18 +21,13 @@
</div> </div>
<div class="course-item__meta"> <div class="course-item__meta">
<div v-if="course.term?.title" class="course-item__pill"> <Badge v-if="course.term?.title" label="مختص به:" :value="course.term.title" />
<span class="course-item__pill-label">مختص به:</span> <Badge label="ظرفیت:" :value="capacity || '—'" />
<span class="course-item__pill-value">{{ course.term.title }}</span> <Badge
</div> v-if="course.prerequisitesCount"
<div class="course-item__pill"> label="پیش‌نیاز:"
<span class="course-item__pill-label">ظرفیت:</span> :value="course.prerequisitesCount"
<span class="course-item__pill-value">{{ capacity || '—' }}</span> />
</div>
<div v-if="course.prerequisitesCount" class="course-item__pill">
<span class="course-item__pill-label">پیشنیاز:</span>
<span class="course-item__pill-value">{{ course.prerequisitesCount }}</span>
</div>
</div> </div>
<div v-if="!isActive" class="course-item__status"> <div v-if="!isActive" class="course-item__status">
@@ -82,11 +77,11 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue' import CircleButton from '@/components/CircleButton.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue' import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
course: { type: Object, required: true }, course: { type: Object, required: true },
@@ -95,14 +90,14 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'change-status', 'show-details']) const emit = defineEmits(['edit', 'delete', 'change-status', 'show-details'])
const teacherName = computed(() => { const teacherName = computed(() => {
const t = props.course.teacher || props.course.defaultTeacher const t = props.course.teacher
if (!t) return '—' if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—' return t.name
}) })
const capacity = computed(() => props.course.capacity ?? props.course.defaultCapacity ?? '') const capacity = computed(() => props.course.capacity ?? '')
const isActive = computed(() => props.course.isActive ?? props.course.isActiveByDefault ?? false) const isActive = computed(() => props.course.isActive ?? false)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -194,28 +189,7 @@ const isActive = computed(() => props.course.isActive ?? props.course.isActiveBy
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.375rem; gap: 0.375rem;
flex: 1 1 33%; flex: 1 1 33%;
justify-content: flex-end; justify-content: flex-start;
}
&__pill {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
} }
&__status { &__status {
@@ -37,7 +37,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" :size="20" color="black" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -56,12 +56,11 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useAdminTermsListQuery } from '@/services/query/admin-terms' import { useAdminTermsListQuery } from '@/services/query/admin-terms'
const props = defineProps({ const props = defineProps({
@@ -1,307 +0,0 @@
<template>
<BasicModal width="95%" max-width="42rem" min-width="auto" :show-close-button="true">
<template #default="{ close }">
<div class="add-course-student">
<LineTitleBlock title="افزودن دانشجو" title-en="Add Student" />
<div class="add-course-student__search">
<SvgIcon name="user" :size="18" color="var(--color-thd-gray)" />
<input
v-model="searchInput"
type="text"
class="add-course-student__search-input"
placeholder="اسم دانشجو را وارد نمایید"
@input="onSearchInput"
/>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
<div v-else-if="users.length > 0" class="add-course-student__list">
<div v-for="user in users" :key="user.id" class="add-course-student__row">
<div class="add-course-student__main">
<div v-if="user.avatarUrl" class="add-course-student__avatar">
<img :src="user.avatarUrl" :alt="userLabel(user)" />
</div>
<div
v-else
class="add-course-student__avatar add-course-student__avatar--placeholder"
>
<SvgIcon name="user" :size="20" color="#bcbcbc" />
</div>
<div class="add-course-student__text">
<p class="add-course-student__name">{{ userLabel(user) }}</p>
<p class="add-course-student__meta">
<span>{{ user.address?.province?.name || '—' }}</span>
<span class="add-course-student__sep">،</span>
<span>{{ user.address?.city?.name || '—' }}</span>
</p>
</div>
</div>
<CircleButton
v-if="isAttached(user.id)"
tooltip="حذف از دوره"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
type="button"
:loading="pendingId === user.id"
@click="onDetach(user)"
>
<template #icon>
<SvgIcon name="trash" :size="16" color="var(--color-error)" />
</template>
</CircleButton>
<CircleButton
v-else
tooltip="افزودن به دوره"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
type="button"
:loading="pendingId === user.id"
@click="onAttach(user)"
>
<template #icon>
<SvgIcon name="plus" :size="16" color="var(--color-sec-gray)" />
</template>
</CircleButton>
</div>
</div>
<NoItems v-else title="بدون نتیجه" desc="دانشجویی یافت نشد." />
<div class="add-course-student__divider" />
<div class="add-course-student__footer">
<BaseButton text="تایید" custom-class="add-course-student__close-btn" @click="close">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal'
import useDebounce from '@/composables/useDebounce'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import {
adminCourseTemplatesKeys,
useAddAdminTemplateStudentMutation,
useAdminTemplateStudentsQuery,
useRemoveAdminTemplateStudentMutation,
} from '@/services/query/admin-course-templates'
defineOptions({ name: 'AddCourseStudentModal' })
const queryClient = useQueryClient()
const { getModal } = useModal()
const modalData = computed(() => getModal('AddCourseStudentModal')?.data ?? {})
const templateId = computed(() => modalData.value.templateId ?? null)
const searchInput = ref('')
const searchQuery = ref('')
const userFilters = computed(() => ({ name: searchQuery.value }))
const userPagination = ref({ page: 1, perPage: 20 })
const { data: usersResponse, isLoading } = useAdminUsersListQuery(userFilters, userPagination)
const users = computed(() => usersResponse.value?.data ?? [])
const attachedFilters = computed(() => ({}))
const attachedPagination = ref({ page: 1, perPage: 200 })
const { data: attachedResponse } = useAdminTemplateStudentsQuery(
templateId,
attachedFilters,
attachedPagination,
{ enabled: () => !!templateId.value }
)
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((u) => u.id)))
const isAttached = (id) => attachedIds.value.has(id)
const userLabel = (user) =>
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.fullName ||
user.phoneNumber ||
'—'
const onSearchInput = useDebounce(() => {
searchQuery.value = searchInput.value || ''
userPagination.value = { ...userPagination.value, page: 1 }
}, 400)
const pendingId = ref(null)
const addMutation = useAddAdminTemplateStudentMutation()
const removeMutation = useRemoveAdminTemplateStudentMutation()
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const onAttach = async (user) => {
if (!templateId.value) return
pendingId.value = user.id
try {
await addMutation.mutateAsync({
templateId: templateId.value,
payload: { userIds: [user.id] },
})
invalidate()
} finally {
pendingId.value = null
}
}
const onDetach = async (user) => {
if (!templateId.value) return
pendingId.value = user.id
try {
await removeMutation.mutateAsync({ templateId: templateId.value, userId: user.id })
invalidate()
} finally {
pendingId.value = null
}
}
watch(templateId, () => {
searchInput.value = ''
searchQuery.value = ''
pendingId.value = null
})
</script>
<style lang="scss" scoped>
.add-course-student {
width: 100%;
text-align: start;
padding: 0.5rem 0.25rem;
&__search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-block: 1rem;
padding: 0 1rem;
height: 2.75rem;
border: 1px solid var(--color-thd-gray);
border-radius: 9999px;
background: #fff;
}
&__search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #4b4b4b;
text-align: start;
&::placeholder {
color: var(--color-prim-gray);
}
}
&__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 22rem;
overflow-y: auto;
padding-inline-end: 0.25rem;
}
&__row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
background: rgba(255, 255, 255, 85%);
border-radius: 0.875rem;
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
}
&__main {
flex: 1;
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
&__avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
overflow: hidden;
border: 1px solid #eee;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--placeholder {
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
}
&__text {
min-width: 0;
}
&__name {
font-family: var(--font-family-fa);
font-size: 0.95rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
}
&__meta {
font-family: var(--font-family-fa);
font-size: 0.75rem;
font-weight: 300;
color: #848484;
margin: 0;
}
&__sep {
color: #c4c4c4;
margin-inline: 0.25rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1rem;
}
&__footer {
display: flex;
justify-content: flex-start;
}
&__close-btn {
min-width: 12rem;
}
}
</style>
@@ -104,31 +104,31 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue'
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue' import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import ImageCropper from '@/components/form/ImageCropper.vue' import ImageCropper from '@/components/form/ImageCropper.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import { objectToFormData } from '@/utils/object-to-formdata'
import useModal from '@/composables/useModal' import { useUploadMediaMutation } from '@/services/query/auth'
import useYup from '@/composables/useYup' import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { offeredCourseSchema } from '@/features/admin/courses/schema'
import { import {
adminCoursesKeys, adminCoursesKeys,
useAddAdminCourseMutation, useAddAdminCourseMutation,
useAdminCourseQuery, useAdminCourseQuery,
useAdminCoursesListQuery,
useUpdateAdminCourseMutation, useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses' } from '@/services/query/admin-courses'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import useDebounce from '@/composables/useDebounce'
import { offeredCourseSchema } from '@/features/admin/courses/schema'
import { objectToFormData } from '@/utils/object-to-formdata'
defineOptions({ name: 'AddOfferedCourseModal' }) defineOptions({ name: 'AddOfferedCourseModal' })
@@ -139,19 +139,24 @@ const modalData = computed(() => getModal('AddOfferedCourseModal')?.data ?? {})
const mode = computed(() => modalData.value.mode || 'add') const mode = computed(() => modalData.value.mode || 'add')
const courseId = computed(() => modalData.value.courseId ?? null) const courseId = computed(() => modalData.value.courseId ?? null)
const isEditMode = computed(() => mode.value === 'edit') const isEditMode = computed(() => mode.value === 'edit')
const presetTermId = computed(() => modalData.value.termId ?? '')
const modeTitle = computed(() => const modeTitle = computed(() =>
isEditMode.value ? 'ویرایش دوره ارائه شده' : 'افزودن دوره ارائه شده' isEditMode.value ? 'ویرایش دوره ارائه شده' : 'افزودن دوره ارائه شده'
) )
const form = ref({ const form = ref({
termId: '', termId: presetTermId.value,
templateId: '', templateId: '',
title: '', title: '',
capacity: '', capacity: '',
imageId: null, imageId: null,
isActive: false, isActive: false,
}) })
watch(presetTermId, (val) => {
if (val && !form.value.termId) form.value.termId = val
})
const image = ref(null) const image = ref(null)
const schema = offeredCourseSchema const schema = offeredCourseSchema
@@ -160,7 +165,7 @@ const { validate, validateAt, errors, resetErrors } = useYup(schema)
const termSearch = ref('') const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value })) const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 30 }) const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination) const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const selectedTerm = ref(null) const selectedTerm = ref(null)
const termOptions = computed(() => { const termOptions = computed(() => {
@@ -172,12 +177,9 @@ const termOptions = computed(() => {
}) })
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({ title: templateSearch.value, termId: 'null' }))
const templatePagination = ref({ page: 1, perPage: 30 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
templateFilters,
templatePagination
)
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templateOptions = computed(() => { const templateOptions = computed(() => {
const base = templatesResponse.value?.data ?? [] const base = templatesResponse.value?.data ?? []
@@ -201,10 +203,9 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
watch(existingCourse, (course) => { watch(existingCourse, (course) => {
if (!course) return if (!course) return
if (course.term) selectedTerm.value = course.term if (course.term) selectedTerm.value = course.term
if (course.template) selectedTemplate.value = course.template
form.value = { form.value = {
termId: course.term?.id || course.termId || '', termId: course.term?.id || course.termId || '',
templateId: course.template?.id || course.templateId || '', templateId: '',
title: course.title || '', title: course.title || '',
capacity: course.capacity ?? '', capacity: course.capacity ?? '',
imageId: course.imageId || null, imageId: course.imageId || null,
@@ -213,11 +214,11 @@ watch(existingCourse, (course) => {
if (course.image) image.value = { url: course.image } if (course.image) image.value = { url: course.image }
}) })
const uploadMutation = useUploadTemporaryMutation() const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData) const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload } image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
@@ -237,10 +238,11 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
const onSubmit = async (close) => { const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
const { templateId: _ignored, ...submitPayload } = payload
if (isEditMode.value) { if (isEditMode.value) {
await updateMutation.mutateAsync({ id: courseId.value, payload }) await updateMutation.mutateAsync({ id: courseId.value, payload: submitPayload })
} else { } else {
await addMutation.mutateAsync(payload) await addMutation.mutateAsync(submitPayload)
} }
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all }) await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
resetErrors() resetErrors()
@@ -1,9 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="42rem" min-width="auto" :show-close-button="true"> <BasicModal
title="افزودن جلسه"
title-en="Add"
width="95%"
max-width="42rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }"> <template #default="{ close }">
<div class="add-session"> <div class="add-session">
<LineTitleBlock title="افزودن جلسه" title-en="Add" />
<div class="add-session__search"> <div class="add-session__search">
<SvgIcon name="book" :size="18" color="var(--color-thd-gray)" /> <SvgIcon name="book" :size="18" color="var(--color-thd-gray)" />
<input <input
@@ -37,20 +42,6 @@
</div> </div>
<CircleButton <CircleButton
v-if="isAttached(session.id)"
tooltip="حذف از دوره"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
type="button"
:loading="pendingId === session.id"
@click="onDetach(session)"
>
<template #icon>
<SvgIcon name="trash" :size="16" color="var(--color-error)" />
</template>
</CircleButton>
<CircleButton
v-else
tooltip="افزودن به دوره" tooltip="افزودن به دوره"
bg-color="rgba(104, 104, 104, 0.05)" bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem" size="2.25rem"
@@ -83,24 +74,22 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import useDebounce from '@/composables/useDebounce' import useDebounce from '@/composables/useDebounce'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import CircleButton from '@/components/CircleButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { adminCoursesKeys } from '@/services/query/admin-courses'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { import {
adminCourseTemplatesKeys, adminSessionsKeys,
useAdminTemplateSessionsQuery, useAdminSessionsListQuery,
useAttachAdminTemplateSessionMutation, useUpdateAdminSessionMutation,
useDetachAdminTemplateSessionMutation, } from '@/services/query/admin-sessions'
} from '@/services/query/admin-course-templates'
defineOptions({ name: 'AddSessionToCourseModal' }) defineOptions({ name: 'AddSessionToCourseModal' })
@@ -108,12 +97,12 @@ const queryClient = useQueryClient()
const { getModal } = useModal() const { getModal } = useModal()
const modalData = computed(() => getModal('AddSessionToCourseModal')?.data ?? {}) const modalData = computed(() => getModal('AddSessionToCourseModal')?.data ?? {})
const templateId = computed(() => modalData.value.templateId ?? null) const courseId = computed(() => modalData.value.courseId ?? null)
const searchInput = ref('') const searchInput = ref('')
const searchQuery = ref('') const searchQuery = ref('')
const sessionFilters = computed(() => ({ title: searchQuery.value })) const sessionFilters = computed(() => ({ search: searchQuery.value }))
const sessionPagination = ref({ page: 1, perPage: 20 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse, isLoading } = useAdminSessionsListQuery( const { data: sessionsResponse, isLoading } = useAdminSessionsListQuery(
sessionFilters, sessionFilters,
@@ -121,20 +110,8 @@ const { data: sessionsResponse, isLoading } = useAdminSessionsListQuery(
) )
const sessions = computed(() => sessionsResponse.value?.data ?? []) const sessions = computed(() => sessionsResponse.value?.data ?? [])
const attachedFilters = computed(() => ({}))
const attachedPagination = ref({ page: 1, perPage: 200 })
const { data: attachedResponse } = useAdminTemplateSessionsQuery(
templateId,
attachedFilters,
attachedPagination,
{ enabled: () => !!templateId.value }
)
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((s) => s.id)))
const isAttached = (id) => attachedIds.value.has(id)
const teacherName = (session) => { const teacherName = (session) => {
const t = session.teacher || session.defaultTeacher const t = session.teacher
if (!t) return '—' if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—' return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
} }
@@ -146,18 +123,20 @@ const onSearchInput = useDebounce(() => {
const pendingId = ref(null) const pendingId = ref(null)
const attachMutation = useAttachAdminTemplateSessionMutation() const updateSessionMutation = useUpdateAdminSessionMutation()
const detachMutation = useDetachAdminTemplateSessionMutation()
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all }) const invalidate = () => {
queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
queryClient.invalidateQueries({ queryKey: adminSessionsKeys.all })
}
const onAttach = async (session) => { const onAttach = async (session) => {
if (!templateId.value) return if (!courseId.value) return
pendingId.value = session.id pendingId.value = session.id
try { try {
await attachMutation.mutateAsync({ await updateSessionMutation.mutateAsync({
templateId: templateId.value, id: session.id,
payload: { sessionIds: [session.id] }, payload: { courseId: courseId.value },
}) })
invalidate() invalidate()
} finally { } finally {
@@ -165,18 +144,7 @@ const onAttach = async (session) => {
} }
} }
const onDetach = async (session) => { watch(courseId, () => {
if (!templateId.value) return
pendingId.value = session.id
try {
await detachMutation.mutateAsync({ templateId: templateId.value, sessionId: session.id })
invalidate()
} finally {
pendingId.value = null
}
}
watch(templateId, () => {
searchInput.value = '' searchInput.value = ''
searchQuery.value = '' searchQuery.value = ''
pendingId.value = null pendingId.value = null
@@ -1,9 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="80rem" min-width="auto" :show-close-button="true"> <BasicModal
title="جزئیات دوره"
title-en="details"
width="95%"
max-width="80rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }"> <template #default="{ close }">
<div class="course-details"> <div class="course-details">
<LineTitleBlock title="جزئیات دوره" title-en="details" />
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<template v-else-if="course"> <template v-else-if="course">
@@ -14,14 +19,6 @@
</div> </div>
<div class="course-details__grid"> <div class="course-details__grid">
<LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" /> <LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" />
<LineInfoBlock
title="تاریخ شروع"
:numeric-desc="course.faStartDate || formatJalaaliDate(course.startDate) || '—'"
/>
<LineInfoBlock
title="تاریخ پایان"
:numeric-desc="course.faEndDate || formatJalaaliDate(course.endDate) || '—'"
/>
<LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" /> <LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" />
<LineInfoBlock title="پیش‌نیاز دوره" :desc="teacherName" /> <LineInfoBlock title="پیش‌نیاز دوره" :desc="teacherName" />
</div> </div>
@@ -30,12 +27,8 @@
<div v-if="course.description" class="course-details__description"> <div v-if="course.description" class="course-details__description">
<LineInfoBlock title="توضیحات دوره" :desc="course.description" /> <LineInfoBlock title="توضیحات دوره" :desc="course.description" />
</div> </div>
</template>
<NoItems v-else title="یافت نشد" desc="اطلاعات این دوره در دسترس نیست." /> <section class="course-details__sessions">
<TabsBlock v-if="course" :tabs="tabs" v-model="activeTab">
<template #sessions>
<div class="course-details__panel-header"> <div class="course-details__panel-header">
<LineTitleBlock title="لیست جلسات" title-en="Sessions" /> <LineTitleBlock title="لیست جلسات" title-en="Sessions" />
<BaseButton <BaseButton
@@ -60,79 +53,10 @@
:pagination="sessionsPaginationMeta" :pagination="sessionsPaginationMeta"
@update:page="setSessionsPage" @update:page="setSessionsPage"
/> />
</template> </section>
</template>
<template #students> <NoItems v-else title="یافت نشد" desc="اطلاعات این دوره در دسترس نیست." />
<div class="course-details__panel-header">
<LineTitleBlock title="دانشجویان" title-en="Students" />
<BaseButton
text="افزودن دانشجو به این دوره"
custom-class="course-details__panel-btn"
@click="onOpenAddStudent"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="studentsPending" :rows="4" :cols-per-row="1" />
<div v-else-if="students.length > 0">
<div
v-for="student in students"
:key="student.id"
class="course-details__student-row"
>
<div class="course-details__student-main">
<div v-if="student.avatarUrl" class="course-details__avatar">
<img :src="student.avatarUrl" :alt="studentName(student)" />
</div>
<div v-else class="course-details__avatar course-details__avatar--placeholder">
<SvgIcon name="user" :size="20" color="#bcbcbc" />
</div>
<div>
<p class="course-details__student-name">{{ studentName(student) }}</p>
<p class="course-details__student-meta">
<span>{{ student.address?.province?.name || '—' }}</span>
<span class="course-details__sep">،</span>
<span>{{ student.address?.city?.name || '—' }}</span>
</p>
</div>
</div>
<div class="course-details__student-actions">
<CircleButton
v-if="student.phoneNumber"
tooltip="ارسال پیام"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
@click="onMessageStudent(student)"
>
<template #icon>
<SvgIcon name="chat" :size="16" color="var(--color-sec-gray)" />
</template>
</CircleButton>
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
@click="onAskRemoveStudent(student)"
>
<template #icon>
<SvgIcon name="trash" :size="16" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</div>
<NoItems v-else title="بدون دانشجو" desc="دانشجویی به این دوره اضافه نشده است." />
<PaginationBlock
v-if="studentsPaginationMeta.lastPage > 1"
:pagination="studentsPaginationMeta"
@update:page="setStudentsPage"
/>
</template>
</TabsBlock>
<div class="course-details__divider" /> <div class="course-details__divider" />
@@ -149,67 +73,49 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useQueryClient } from '@tanstack/vue-query' import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue' import { useAdminCourseQuery } from '@/services/query/admin-courses'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue' import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue' import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue' import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue'
import useModal from '@/composables/useModal'
import { usePagination } from '@/composables/usePagination'
import {
adminCourseTemplatesKeys,
useAdminCourseTemplateQuery,
useAdminTemplateSessionsQuery,
useAdminTemplateStudentsQuery,
useRemoveAdminTemplateStudentMutation,
} from '@/services/query/admin-course-templates'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'CourseDetailsModal' }) defineOptions({ name: 'CourseDetailsModal' })
const queryClient = useQueryClient()
const { openModal, getModal } = useModal() const { openModal, getModal } = useModal()
const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {}) const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {})
const templateId = computed(() => modalData.value.id ?? null) const courseId = computed(() => modalData.value.id ?? null)
const { data: course, isLoading } = useAdminCourseTemplateQuery(templateId, { const { data: course, isLoading } = useAdminCourseQuery(courseId, {
enabled: () => !!templateId.value, enabled: () => !!courseId.value,
}) })
const teacherName = computed(() => { const teacherName = computed(() => {
const t = course.value?.defaultTeacher || course.value?.teacher const t = course.value?.teacher
if (!t) return '—' if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—' return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
}) })
const tabs = [ const sessionsFilters = computed(() => ({ courseId: courseId.value }))
{ name: 'sessions', label: 'لیست جلسات', icon: 'list-bullets' },
{ name: 'students', label: 'دانشجویان', icon: 'users' },
]
const activeTab = ref('sessions')
const sessionsFilters = ref({})
const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({ const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({
page: 1, page: 1,
perPage: 10, perPage: 10,
}) })
const { data: sessionsData, isLoading: sessionsPending } = useAdminTemplateSessionsQuery( const { data: sessionsData, isLoading: sessionsPending } = useAdminSessionsListQuery(
templateId,
sessionsFilters, sessionsFilters,
sessionsPagination, sessionsPagination,
{ {
enabled: () => !!templateId.value && activeTab.value === 'sessions', enabled: () => !!courseId.value,
keepPreviousData: true, keepPreviousData: true,
} }
) )
@@ -221,62 +127,8 @@ const sessionsPaginationMeta = computed(() => ({
...sessionsData.value?.meta, ...sessionsData.value?.meta,
})) }))
const studentsFilters = ref({})
const { pagination: studentsPagination, setPage: setStudentsPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: studentsData, isLoading: studentsPending } = useAdminTemplateStudentsQuery(
templateId,
studentsFilters,
studentsPagination,
{
enabled: () => !!templateId.value && activeTab.value === 'students',
keepPreviousData: true,
}
)
const students = computed(() => studentsData.value?.data ?? [])
const studentsPaginationMeta = computed(() => ({
page: studentsPagination.value.page,
perPage: studentsPagination.value.perPage,
...studentsData.value?.meta,
}))
const studentName = (student) =>
`${student.firstName || ''} ${student.lastName || ''}`.trim() ||
student.fullName ||
student.phoneNumber ||
'—'
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const removeStudentMutation = useRemoveAdminTemplateStudentMutation()
const onOpenAddSession = () => { const onOpenAddSession = () => {
openModal('AddSessionToCourseModal', { templateId: templateId.value }) openModal('AddSessionToCourseModal', { courseId: courseId.value })
}
const onOpenAddStudent = () => {
openModal('AddCourseStudentModal', { templateId: templateId.value })
}
const onAskRemoveStudent = (student) => {
openModal('ConfirmModal', {
title: `حذف ${studentName(student)}`,
message: `آیا از حذف <strong>${studentName(student)}</strong> از این دوره اطمینان دارید؟`,
onConfirm: () =>
removeStudentMutation.mutate(
{ templateId: templateId.value, userId: student.id },
{ onSuccess: invalidate }
),
})
}
const onMessageStudent = (student) => {
if (!student?.phoneNumber) return
window.location.href = `sms:${student.phoneNumber}`
} }
</script> </script>
@@ -333,6 +185,10 @@ const onMessageStudent = (student) => {
margin-block: 0.5rem 1rem; margin-block: 0.5rem 1rem;
} }
&__sessions {
margin-top: 0.5rem;
}
&__panel-header { &__panel-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -346,74 +202,6 @@ const onMessageStudent = (student) => {
margin-inline-start: auto; margin-inline-start: auto;
} }
&__student-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: rgba(255, 255, 255, 85%);
border-radius: 0.875rem;
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
margin-bottom: 0.5rem;
}
&__student-main {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1;
min-width: 0;
}
&__avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
overflow: hidden;
border: 1px solid #eee;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--placeholder {
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
}
&__student-name {
font-family: var(--font-family-fa);
font-size: 0.95rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
}
&__student-meta {
font-family: var(--font-family-fa);
font-size: 0.75rem;
font-weight: 300;
color: #848484;
margin: 0;
}
&__sep {
color: #c4c4c4;
margin-inline: 0.25rem;
}
&__student-actions {
display: flex;
align-items: center;
gap: 0.375rem;
}
&__divider { &__divider {
border-block-end: 1px solid var(--color-thd-gray); border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1.5rem; margin-block: 1.5rem;
@@ -2,11 +2,9 @@
<div class="course-form"> <div class="course-form">
<BoxedIconTitleBlock <BoxedIconTitleBlock
class="course-form__heading" class="course-form__heading"
:title="isEditMode ? 'ویرایش دوره الگو' : 'افزودن دوره الگوی جدید'" :title="isEditMode ? 'ویرایش دوره' : 'افزودن دوره جدید'"
:desc=" :desc="
isEditMode isEditMode ? 'اطلاعات دوره را به‌روز کنید' : 'در این قسمت می‌توانید دوره جدید اضافه کنید'
? 'اطلاعات دوره الگو را به‌روز کنید'
: 'در این قسمت می‌توانید دوره الگوی جدید اضافه کنید'
" "
> >
<template #icon> <template #icon>
@@ -27,7 +25,6 @@
</div> </div>
<div class="course-form__main-col"> <div class="course-form__main-col">
<LineTitleBlock title="اطلاعات دوره" title-en="Course Details" />
<div class="course-form__row"> <div class="course-form__row">
<div class="course-form__cell course-form__cell--third"> <div class="course-form__cell course-form__cell--third">
<TextField <TextField
@@ -44,15 +41,26 @@
</div> </div>
<div class="course-form__cell course-form__cell--third"> <div class="course-form__cell course-form__cell--third">
<SelectField <SelectField
v-model="form.defaultTeacherId" v-model="form.teacherId"
name="defaultTeacherId" name="teacherId"
label="استاد" label="استاد"
:options="teacherOptions" :options="teacherOptions"
option-label="label" option-label="name"
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTeachers" :on-search="searchTeachers"
:error="errors.defaultTeacherId" :error="errors.teacherId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.capacity"
name="capacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.capacity"
@blur="validateAt('capacity', form.capacity)"
/> />
</div> </div>
<div class="course-form__cell course-form__cell--third"> <div class="course-form__cell course-form__cell--third">
@@ -69,25 +77,23 @@
/> />
</div> </div>
<div class="course-form__cell course-form__cell--third"> <div class="course-form__cell course-form__cell--third">
<TextField <SelectField
v-model="form.defaultCapacity" v-model="form.contentType"
name="defaultCapacity" name="contentType"
label="ظرفیت (نفر)" label="نوع فایل دوره"
inputmode="numeric" :options="contentTypeOptions"
:convert-digits="true" option-label="label"
:error="errors.defaultCapacity" option-value="value"
@blur="validateAt('defaultCapacity', form.defaultCapacity)" :error="errors.contentType"
/> />
</div> </div>
<div class="course-form__cell course-form__cell--third course-form__toggle-cell">
<ToggleSwitch v-model="form.isActiveByDefault" label="دوره فعال باشد" />
</div>
<div class="course-form__cell course-form__cell--full"> <div class="course-form__cell course-form__cell--full">
<TextareaField <TextareaField
v-model="form.description" v-model="form.description"
name="description" name="description"
label="توضیحات" label="توضیحات دوره"
:row="5" :row="5"
:error="errors.description"
/> />
</div> </div>
</div> </div>
@@ -123,59 +129,66 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { COURSE_CONTENT_TYPE } from '@/enums'
import { useRoute, useRouter } from 'vue-router'
import useDebounce from '@/composables/useDebounce' import useDebounce from '@/composables/useDebounce'
import { import { useQueryClient } from '@tanstack/vue-query'
adminCourseTemplatesKeys, import BaseButton from '@/components/BaseButton.vue'
useAddAdminCourseTemplateMutation, import SvgIcon from '@/components/icons/SvgIcon.vue'
useAdminCourseTemplateQuery, import TextField from '@/components/form/TextField.vue'
useAdminCourseTemplatesListQuery, import SelectField from '@/components/form/SelectField.vue'
useUpdateAdminCourseTemplateMutation, import ImageCropper from '@/components/form/ImageCropper.vue'
} from '@/services/query/admin-course-templates'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { courseTemplateSchema } from '@/features/admin/courses/schema'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import { courseSchema } from '@/features/admin/courses/schema'
import TextareaField from '@/components/form/TextareaField.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCoursesKeys,
useAddAdminCourseMutation,
useAdminCourseQuery,
useAdminCoursesListQuery,
useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const courseId = computed(() => (route.params.id ? Number(route.params.id) : null)) const courseId = computed(() => (route.params.id ? Number(route.params.id) : null))
const termId = computed(() => (route.query.termId ? Number(route.query.termId) : null))
const isEditMode = computed(() => !!courseId.value) const isEditMode = computed(() => !!courseId.value)
const form = ref({ const form = ref({
title: '', title: '',
defaultTeacherId: '', teacherId: '',
capacity: '',
prerequisites: [], prerequisites: [],
defaultCapacity: '', contentType: '',
description: '', description: '',
imageId: null, coverMediaId: null,
isActiveByDefault: false, termId: termId.value,
}) })
const image = ref(null) const image = ref(null)
const schema = courseTemplateSchema const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const { validate, validateAt, errors } = useYup(schema) const { validate, validateAt, errors } = useYup(courseSchema)
const teacherSearch = ref('') const teacherSearch = ref('')
const teacherFilters = computed(() => ({ name: teacherSearch.value })) const teacherFilters = computed(() => ({
const teacherPagination = ref({ page: 1, perPage: 30 }) search: teacherSearch.value,
roles: 'teacher',
}))
const teacherPagination = ref({ page: 1, perPage: 10 })
const { data: teachersResponse } = useAdminUsersListQuery(teacherFilters, teacherPagination) const { data: teachersResponse } = useAdminUsersListQuery(teacherFilters, teacherPagination)
const selectedTeacher = ref(null) const selectedTeacher = ref(null)
const teacherOptions = computed(() => { const teacherOptions = computed(() => {
@@ -192,8 +205,8 @@ const teacherOptions = computed(() => {
const prereqSearch = ref('') const prereqSearch = ref('')
const prereqFilters = computed(() => ({ title: prereqSearch.value })) const prereqFilters = computed(() => ({ title: prereqSearch.value }))
const prereqPagination = ref({ page: 1, perPage: 30 }) const prereqPagination = ref({ page: 1, perPage: 10 })
const { data: prereqsResponse } = useAdminCourseTemplatesListQuery(prereqFilters, prereqPagination) const { data: prereqsResponse } = useAdminCoursesListQuery(prereqFilters, prereqPagination)
const selectedPrereqs = ref([]) const selectedPrereqs = ref([])
const prerequisiteOptions = computed(() => { const prerequisiteOptions = computed(() => {
const base = prereqsResponse.value?.data ?? [] const base = prereqsResponse.value?.data ?? []
@@ -208,38 +221,39 @@ const searchPrerequisites = useDebounce((q) => {
prereqSearch.value = q || '' prereqSearch.value = q || ''
}, 400) }, 400)
const { data: existingCourse } = useAdminCourseTemplateQuery(courseId, { const { data: existingCourse } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value, enabled: () => !!courseId.value,
}) })
watch(existingCourse, (course) => { watch(existingCourse, (course) => {
if (!course) return if (!course) return
const teacher = course.defaultTeacher || course.teacher const teacher = course.teacher
if (teacher) selectedTeacher.value = teacher if (teacher) selectedTeacher.value = teacher
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : [] const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : []
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id) selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
form.value = { form.value = {
title: course.title || '', title: course.title || '',
defaultTeacherId: teacher?.id || course.defaultTeacherId || '', teacherId: teacher?.id || course.teacherId || '',
capacity: course.capacity ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean), prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '', contentType: course.contentType || '',
description: course.description || '', description: course.description || '',
imageId: course.imageId || null, coverMediaId: course.coverMediaId || null,
isActiveByDefault: course.isActiveByDefault ?? course.isActive ?? false, termId: course.termId ?? termId.value,
} }
if (course.image) image.value = { url: course.image } if (course.coverUrl) image.value = { url: course.coverUrl }
}) })
const uploadMutation = useUploadTemporaryMutation() const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData) const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload } image.value = { url: payload?.url, ...payload }
form.value.imageId = payload?.uploadId || payload?.id form.value.coverMediaId = payload?.id
} catch { } catch {
/* handled globally */ /* handled globally */
} }
@@ -247,8 +261,8 @@ const onImageCropped = async (file) => {
const onImageError = (msg) => toast.error(msg) const onImageError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseTemplateMutation() const addMutation = useAddAdminCourseMutation()
const updateMutation = useUpdateAdminCourseTemplateMutation() const updateMutation = useUpdateAdminCourseMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value) const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
@@ -260,7 +274,7 @@ const onSubmit = async () => {
} else { } else {
await addMutation.mutateAsync(payload) await addMutation.mutateAsync(payload)
} }
await queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all }) await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
router.push({ name: 'admin-courses' }) router.push({ name: 'admin-courses' })
} }
@@ -345,9 +359,14 @@ const onCancel = () => router.push({ name: 'admin-courses' })
} }
} }
&__toggle-cell { &__uploader-label {
display: flex; display: block;
align-items: center; margin-bottom: 0.5rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
} }
&__divider { &__divider {
@@ -358,6 +377,7 @@ const onCancel = () => router.push({ name: 'admin-courses' })
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
justify-content: space-between;
gap: 0.625rem; gap: 0.625rem;
} }
@@ -17,26 +17,6 @@
@reset="onFilterReset" @reset="onFilterReset"
/> />
<div class="courses-page__list-header">
<SimpleTitleIconBlock
:title="activeTab === 'templates' ? 'لیست دوره‌های الگو' : 'لیست دوره‌های ارائه شده'"
class="courses-page__list-title"
>
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton
:text="activeTab === 'templates' ? 'افزودن دوره الگو' : 'افزودن دوره ارائه شده'"
custom-class="courses-page__add-btn"
@click="onAdd"
>
<template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange"> <TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
<template #templates> <template #templates>
<SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" />
@@ -45,9 +25,9 @@
v-for="course in templates" v-for="course in templates"
:key="course.id" :key="course.id"
:course="course" :course="course"
@edit="onEditTemplate" @edit="onEditCourse"
@delete="onAskDeleteTemplate" @delete="onAskDelete"
@change-status="onChangeTemplateStatus" @change-status="onChangeStatus"
@show-details="onShowDetails" @show-details="onShowDetails"
/> />
</div> </div>
@@ -56,76 +36,100 @@
</template> </template>
<template #offered> <template #offered>
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" /> <NoItems
<div v-else-if="offered.length > 0"> v-if="!hasOfferedTerm"
<CourseItem title="ترم را انتخاب کنید"
v-for="course in offered" desc="برای نمایش دوره‌های ارائه شده، ابتدا ترم را از فیلترها انتخاب کنید."
:key="course.id" />
:course="course" <template v-else>
@edit="onEditOffered" <SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
@delete="onAskDeleteOffered" <div v-else-if="offered.length > 0">
@change-status="onChangeOfferedStatus" <CourseItem
@show-details="onShowDetails" v-for="course in offered"
/> :key="course.id"
</div> :course="course"
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." /> @edit="onEditCourse"
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" /> @delete="onAskDelete"
@change-status="onChangeStatus"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
</template>
</template> </template>
</TabsBlock> </TabsBlock>
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" /> <AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
<CourseDetailsModal v-if="isModal('CourseDetailsModal')" /> <CourseDetailsModal v-if="isModal('CourseDetailsModal')" />
<AddCourseStudentModal v-if="isModal('AddCourseStudentModal')" />
<AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" /> <AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import useModal from '@/composables/useModal'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue' import { computed, onMounted, ref, watch } from 'vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import CourseItem from '@/features/admin/courses/components/CourseItem.vue' import CourseItem from '@/features/admin/courses/components/CourseItem.vue'
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue'
import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue' import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue'
import AddCourseStudentModal from '@/features/admin/courses/components/modals/AddCourseStudentModal.vue' import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
import AddSessionToCourseModal from '@/features/admin/courses/components/modals/AddSessionToCourseModal.vue' import AddSessionToCourseModal from '@/features/admin/courses/components/modals/AddSessionToCourseModal.vue'
import {
adminCourseTemplatesKeys,
useAdminCourseTemplatesListQuery,
useChangeAdminCourseTemplateStatusMutation,
useDeleteAdminCourseTemplateMutation,
} from '@/services/query/admin-course-templates'
import { import {
adminCoursesKeys, adminCoursesKeys,
useAdminCoursesListQuery, useAdminCoursesListQuery,
useChangeAdminCourseStatusMutation,
useDeleteAdminCourseMutation, useDeleteAdminCourseMutation,
useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses' } from '@/services/query/admin-courses'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal'
const route = useRoute()
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const tabs = [ const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
{ name: 'templates', label: 'دوره‌های الگو', icon: 'list-bullets' },
{ name: 'offered', label: 'دوره‌های ارائه شده', icon: 'list-bullets' },
]
const activeTab = ref('templates')
const templateFilters = ref({ title: '', status: '', fromDate: '', toDate: '' }) const onAdd = () => {
const offeredFilters = ref({ title: '', termId: '', status: '', fromDate: '', toDate: '' }) if (activeTab.value === 'templates') {
router.push({ name: 'admin-add-course' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', {
mode: 'add',
termId: routeTermId.value ?? undefined,
})
}
}
const tabs = [
{
name: 'templates',
label: 'دوره‌های الگو',
icon: 'list-bullets',
hasButton: true,
textButton: 'افزودن دوره الگوی جدید',
buttonAction: onAdd,
},
{
name: 'offered',
label: 'دوره‌های ارائه شده',
icon: 'list-bullets',
hasButton: true,
textButton: 'افزودن دوره ارائه شده جدید',
buttonAction: onAdd,
},
]
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
const templateFilters = ref({})
const offeredFilters = ref({})
const currentFilters = computed({ const currentFilters = computed({
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value), get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
@@ -147,7 +151,7 @@ const {
reset: resetOfferedPagination, reset: resetOfferedPagination,
} = usePagination({ page: 1, perPage: 10 }) } = usePagination({ page: 1, perPage: 10 })
const { data: templatesData, isLoading: templatesPending } = useAdminCourseTemplatesListQuery( const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
templateFilters, templateFilters,
templatesPagination, templatesPagination,
{ {
@@ -156,11 +160,13 @@ const { data: templatesData, isLoading: templatesPending } = useAdminCourseTempl
} }
) )
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery( const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
offeredFilters, offeredFilters,
offeredPagination, offeredPagination,
{ {
enabled: () => activeTab.value === 'offered', enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
keepPreviousData: true, keepPreviousData: true,
} }
) )
@@ -189,20 +195,8 @@ const onFilterApply = () => {
} }
const onFilterReset = onFilterApply const onFilterReset = onFilterApply
const onAdd = () => { const onEditCourse = (course) => {
if (activeTab.value === 'templates') { router.push({ name: 'admin-edit-course', params: { id: course.id } }).catch(() => {})
router.push({ name: 'admin-add-course-template' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', { mode: 'add' })
}
}
const onEditTemplate = (course) => {
router.push({ name: 'admin-edit-course-template', params: { id: course.id } }).catch(() => {})
}
const onEditOffered = (course) => {
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
} }
const onShowDetails = (course) => { const onShowDetails = (course) => {
@@ -212,45 +206,33 @@ const onShowDetails = (course) => {
}) })
} }
const invalidateTemplates = () => const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const invalidateOffered = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const deleteTemplateMutation = useDeleteAdminCourseTemplateMutation() const deleteMutation = useDeleteAdminCourseMutation()
const changeTemplateStatusMutation = useChangeAdminCourseTemplateStatusMutation() // Backend has no dedicated status endpoint PATCH /courses/:id with { isActive }.
const updateMutation = useUpdateAdminCourseMutation()
const deleteOfferedMutation = useDeleteAdminCourseMutation() const onAskDelete = (course) => {
const changeOfferedStatusMutation = useChangeAdminCourseStatusMutation()
const onAskDeleteTemplate = (course) => {
openModal('ConfirmModal', { openModal('ConfirmModal', {
title: `حذف ${course.title}`, title: `حذف ${course.title}`,
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید <strong>${course.title}</strong> را حذف کنید؟`, message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید <strong>${course.title}</strong> را حذف کنید؟`,
onConfirm: () => deleteTemplateMutation.mutate(course.id, { onSuccess: invalidateTemplates }), onConfirm: () => deleteMutation.mutate(course.id, { onSuccess: invalidate }),
}) })
} }
const onAskDeleteOffered = (course) => { const onChangeStatus = ({ id, isActive }) => {
openModal('ConfirmModal', { updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
title: `حذف ${course.title}`,
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید <strong>${course.title}</strong> را حذف کنید؟`,
onConfirm: () => deleteOfferedMutation.mutate(course.id, { onSuccess: invalidateOffered }),
})
} }
const onChangeTemplateStatus = ({ id, isActive }) => { const syncRouteTermId = (termId) => {
changeTemplateStatusMutation.mutate( if (!termId) return
{ id, payload: { isActiveByDefault: isActive } }, activeTab.value = 'offered'
{ onSuccess: invalidateTemplates } offeredFilters.value = { ...offeredFilters.value, termId }
) resetOfferedPagination()
} }
const onChangeOfferedStatus = ({ id, isActive }) => { onMounted(() => syncRouteTermId(routeTermId.value))
changeOfferedStatusMutation.mutate( watch(routeTermId, (val) => syncRouteTermId(val))
{ id, payload: { isActive } },
{ onSuccess: invalidateOffered }
)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -262,22 +244,5 @@ const onChangeOfferedStatus = ({ id, isActive }) => {
&__heading { &__heading {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
&__list-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.375rem;
}
&__list-title {
flex: 1;
margin-bottom: 0;
}
&__add-btn {
min-width: fit-content;
padding: 0 1.25rem;
}
} }
</style> </style>
+8 -5
View File
@@ -1,16 +1,19 @@
import { array, boolean, object, string } from 'yup' import { array, boolean, mixed, number, object, string } from 'yup'
export const courseTemplateSchema = object().shape({ export const courseSchema = object().shape({
title: string().required().min(3).max(255), title: string().required().min(3).max(255),
defaultTeacherId: string().required(), teacherId: mixed().required(),
defaultCapacity: string().required(), capacity: number().required().min(1),
prerequisites: array().nullable().default([]), prerequisites: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
termId: mixed().nullable(),
isActive: boolean().nullable().notRequired(),
}) })
export const offeredCourseSchema = object().shape({ export const offeredCourseSchema = object().shape({
termId: string().required(), termId: string().required(),
templateId: string().required(),
title: string().required(), title: string().required(),
capacity: string().required(), capacity: string().required(),
imageId: string().nullable().notRequired(), imageId: string().nullable().notRequired(),
@@ -5,7 +5,7 @@
<div class="exam-item__sub"> <div class="exam-item__sub">
<span class="exam-item__sub-label">دوره:</span> <span class="exam-item__sub-label">دوره:</span>
<span class="exam-item__sub-value"> <span class="exam-item__sub-value">
{{ exam.courseTemplate?.title || exam.courseTemplateTitle || '—' }} {{ exam.course?.title || exam.courseTitle || '—' }}
</span> </span>
<span class="exam-item__dot">|</span> <span class="exam-item__dot">|</span>
<span class="exam-item__sub-label">جلسه:</span> <span class="exam-item__sub-label">جلسه:</span>
@@ -76,11 +76,10 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({ const props = defineProps({
exam: { type: Object, required: true }, exam: { type: Object, required: true },
@@ -4,12 +4,16 @@
v-for="(question, questionIndex) in questions" v-for="(question, questionIndex) in questions"
:key="question.id" :key="question.id"
class="exam-question-builder__card" class="exam-question-builder__card"
:class="{ 'exam-question-builder__card--readonly': isLocked(question) }"
> >
<div class="exam-question-builder__head"> <div class="exam-question-builder__head">
<p class="exam-question-builder__title">سوال شماره {{ questionIndex + 1 }}</p> <p class="exam-question-builder__title">
سوال شماره {{ questionIndex + 1 }}
<span v-if="isLocked(question)" class="exam-question-builder__lock">(ذخیره شده)</span>
</p>
<button <button
type="button" type="button"
:disabled="disabled" :disabled="disabled || isLocked(question)"
class="exam-question-builder__remove" class="exam-question-builder__remove"
aria-label="حذف سوال" aria-label="حذف سوال"
@click="removeQuestion(question.id)" @click="removeQuestion(question.id)"
@@ -22,24 +26,24 @@
<div class="exam-question-builder__col exam-question-builder__col--main"> <div class="exam-question-builder__col exam-question-builder__col--main">
<label class="exam-question-builder__label">متن سوال</label> <label class="exam-question-builder__label">متن سوال</label>
<textarea <textarea
:value="question.title" :value="question.questionText"
:disabled="disabled" :disabled="disabled || isLocked(question)"
rows="1" rows="1"
placeholder="لطفا سوال خود را وارد کنید" placeholder="لطفا سوال خود را وارد کنید"
class="exam-question-builder__textarea" class="exam-question-builder__textarea"
@input="updateQuestion(question.id, { title: $event.target.value })" @input="updateQuestion(question.id, { questionText: $event.target.value })"
/> />
</div> </div>
<div class="exam-question-builder__col exam-question-builder__col--score"> <div class="exam-question-builder__col exam-question-builder__col--score">
<label class="exam-question-builder__label">بارم نمره</label> <label class="exam-question-builder__label">ترتیب</label>
<input <input
:value="question.score" :value="question.position"
:disabled="disabled" :disabled="disabled || isLocked(question)"
type="text" type="text"
inputmode="numeric" inputmode="numeric"
placeholder="15" placeholder="1"
class="exam-question-builder__input" class="exam-question-builder__input"
@input="updateQuestion(question.id, { score: $event.target.value })" @input="updateQuestion(question.id, { position: $event.target.value })"
/> />
</div> </div>
</div> </div>
@@ -49,31 +53,31 @@
<p class="exam-question-builder__label">گزینهها</p> <p class="exam-question-builder__label">گزینهها</p>
<div class="exam-question-builder__answers-list"> <div class="exam-question-builder__answers-list">
<div <div
v-for="answer in question.answers" v-for="option in question.options"
:key="answer.id" :key="option.id"
class="exam-question-builder__answer" class="exam-question-builder__answer"
> >
<input <input
:value="answer.title" :value="option.optionText"
:disabled="disabled" :disabled="disabled || isLocked(question)"
type="text" type="text"
placeholder="متن گزینه" placeholder="متن گزینه"
class="exam-question-builder__answer-input" class="exam-question-builder__answer-input"
@input="updateOption(question.id, answer.id, $event.target.value)" @input="updateOption(question.id, option.id, $event.target.value)"
/> />
<button <button
type="button" type="button"
:disabled="disabled" :disabled="disabled || isLocked(question)"
class="exam-question-builder__answer-remove" class="exam-question-builder__answer-remove"
aria-label="حذف گزینه" aria-label="حذف گزینه"
@click="removeOption(question.id, answer.id)" @click="removeOption(question.id, option.id)"
> >
<SvgIcon name="close" :size="14" color="#b1b1b1" /> <SvgIcon name="close" :size="14" color="#b1b1b1" />
</button> </button>
</div> </div>
<button <button
type="button" type="button"
:disabled="disabled" :disabled="disabled || isLocked(question)"
class="exam-question-builder__answer-add" class="exam-question-builder__answer-add"
aria-label="افزودن گزینه" aria-label="افزودن گزینه"
@click="addOption(question.id)" @click="addOption(question.id)"
@@ -85,16 +89,14 @@
<div class="exam-question-builder__correct"> <div class="exam-question-builder__correct">
<SelectField <SelectField
:model-value="question.correctAnswerId" :model-value="correctOptionId(question)"
:name="`correctAnswer-${question.id}`" :name="`correctAnswer-${question.id}`"
:options="correctAnswerOptions(question)" :options="correctOptions(question)"
option-label="label" option-label="label"
option-value="value" option-value="value"
label="گزینه صحیح" label="گزینه صحیح"
:disabled="disabled || question.answers.length === 0" :disabled="disabled || isLocked(question) || question.options.length === 0"
@update:model-value=" @update:model-value="(value) => setCorrectOption(question.id, value)"
(value) => updateQuestion(question.id, { correctAnswerId: value || null })
"
/> />
</div> </div>
</div> </div>
@@ -114,9 +116,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import SelectField from '@/components/form/SelectField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] },
@@ -129,22 +130,26 @@ const defaultLabels = ['گزینه اول', 'گزینه دوم', 'گزینه س
const questions = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : [])) const questions = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
// Backend has no PATCH/DELETE for questions/options once a question came
// from the server (numeric id, no `__local`), the form locks editing it.
const isLocked = (question) => question?.__local !== true
const cloneQuestions = () => const cloneQuestions = () =>
questions.value.map((q) => ({ questions.value.map((q) => ({
...q, ...q,
answers: Array.isArray(q.answers) ? q.answers.map((a) => ({ ...a })) : [], options: Array.isArray(q.options) ? q.options.map((o) => ({ ...o })) : [],
})) }))
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}` const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
const createOption = () => ({ id: createId('answer'), title: '' }) const createOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
const createQuestion = () => ({ const createQuestion = () => ({
id: createId('question'), id: createId('question'),
title: '', questionText: '',
score: '', position: questions.value.length + 1,
correctAnswerId: null, options: [createOption(), createOption()],
answers: [createOption(), createOption()], __local: true,
}) })
const emitQuestions = (next) => emit('update:modelValue', next) const emitQuestions = (next) => emit('update:modelValue', next)
@@ -164,39 +169,48 @@ const updateQuestion = (questionId, patch) => {
const addOption = (questionId) => { const addOption = (questionId) => {
const next = cloneQuestions().map((q) => { const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q if (q.id !== questionId) return q
return { ...q, answers: [...q.answers, createOption()] } return { ...q, options: [...q.options, createOption()] }
}) })
emitQuestions(next) emitQuestions(next)
} }
const removeOption = (questionId, answerId) => { const removeOption = (questionId, optionId) => {
const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q
const options = q.options.filter((o) => o.id !== optionId)
return { ...q, options }
})
emitQuestions(next)
}
const updateOption = (questionId, optionId, optionText) => {
const next = cloneQuestions().map((q) => { const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q if (q.id !== questionId) return q
const answers = q.answers.filter((a) => a.id !== answerId)
return { return {
...q, ...q,
answers, options: q.options.map((o) => (o.id === optionId ? { ...o, optionText } : o)),
correctAnswerId: String(q.correctAnswerId) === String(answerId) ? null : q.correctAnswerId,
} }
}) })
emitQuestions(next) emitQuestions(next)
} }
const updateOption = (questionId, answerId, title) => { const setCorrectOption = (questionId, optionId) => {
const next = cloneQuestions().map((q) => { const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q if (q.id !== questionId) return q
return { return {
...q, ...q,
answers: q.answers.map((a) => (a.id === answerId ? { ...a, title } : a)), options: q.options.map((o) => ({ ...o, isCorrect: String(o.id) === String(optionId) })),
} }
}) })
emitQuestions(next) emitQuestions(next)
} }
const correctOptionId = (question) => question.options.find((o) => o.isCorrect)?.id ?? null
const optionLabel = (index) => defaultLabels[index] || `گزینه ${index + 1}` const optionLabel = (index) => defaultLabels[index] || `گزینه ${index + 1}`
const correctAnswerOptions = (question) => const correctOptions = (question) =>
question.answers.map((a, idx) => ({ value: a.id, label: optionLabel(idx) })) question.options.map((o, idx) => ({ value: o.id, label: optionLabel(idx) }))
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -210,6 +224,10 @@ const correctAnswerOptions = (question) =>
border-radius: 1.5rem; border-radius: 1.5rem;
padding: 0.875rem; padding: 0.875rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%); box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
&--readonly {
background: rgba(0, 0, 0, 2%);
}
} }
&__head { &__head {
@@ -228,6 +246,12 @@ const correctAnswerOptions = (question) =>
margin: 0; margin: 0;
} }
&__lock {
font-size: 0.7rem;
color: #9c9c9c;
margin-inline-start: 0.25rem;
}
&__remove { &__remove {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@@ -7,9 +7,9 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.courseTemplateId" v-model="form.courseId"
name="courseTemplateId" name="courseId"
label="دوره الگو" label="دوره"
:options="templateOptions" :options="templateOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
@@ -30,7 +30,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -49,25 +49,24 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useDebounce from '@/composables/useDebounce'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import useDebounce from '@/composables/useDebounce'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ title: '', courseTemplateId: '', fromDate: '', toDate: '' }), default: () => ({ title: '', courseId: '', fromDate: '', toDate: '' }),
}, },
}) })
const emit = defineEmits(['update:modelValue', 'apply', 'reset']) const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ title: '', courseTemplateId: '', fromDate: '', toDate: '' }) const emptyForm = () => ({ title: '', courseId: '', fromDate: '', toDate: '' })
const form = ref({ ...emptyForm(), ...props.modelValue }) const form = ref({ ...emptyForm(), ...props.modelValue })
watch( watch(
@@ -82,11 +81,8 @@ const todayIso = new Date().toISOString()
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({ title: templateSearch.value }))
const templatePagination = ref({ page: 1, perPage: 30 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
templateFilters,
templatePagination
)
const templateOptions = computed(() => templatesResponse.value?.data ?? []) const templateOptions = computed(() => templatesResponse.value?.data ?? [])
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
@@ -1,68 +1,54 @@
<template> <template>
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true"> <BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
<template #default="{ close }"> <template #default="{ close }">
<div class="exam-details"> <div class="exam-details">
<LineTitleBlock title="جزئیات آزمون" title-en="Exam Details" /> <LineTitleBlock title="جزئیات آزمون" title-en="Details" />
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<template v-else-if="exam"> <template v-else-if="exam">
<div class="exam-details__head"> <div class="exam-details__summary">
<p class="exam-details__title">{{ exam.title || '—' }}</p> <div class="exam-details__summary-grid">
<p class="exam-details__sub"> <div v-for="summary in summaryItems" :key="summary.title" class="exam-details__cell">
<span>دوره: {{ exam.courseTemplate?.title || exam.courseTemplateTitle || '—' }}</span> <LineInfoBlock
<span class="exam-details__sep">|</span> :title="summary.title"
<span>جلسه: {{ exam.session?.title || exam.sessionTitle || '—' }}</span> :desc="summary.numeric ? '' : summary.value"
</p> :numeric-desc="summary.numeric ? summary.value : ''"
/>
</div>
</div>
<div class="exam-details__description">
<LineInfoBlock title="توضیحات آزمون" :desc="exam.description || '—'" />
</div>
</div> </div>
<div class="exam-details__grid"> <div class="exam-details__questions">
<LineInfoBlock <div
title="مدت آزمون" v-for="question in displayQuestions"
:numeric-desc="exam.durationMinutes != null ? `${exam.durationMinutes} دقیقه` : '—'" :key="question.id"
/> class="exam-details__question"
<LineInfoBlock title="حداقل نمره قبولی" :numeric-desc="exam.passingScore ?? ''" /> >
<LineInfoBlock <div class="exam-details__question-head">
title="تعداد سوالات" <p class="exam-details__question-title">
:numeric-desc="exam.questionsCount ?? questionsLength" <span class="exam-details__question-order">{{ question.order }} :</span>
/> {{ question.title }}
<LineInfoBlock title="رندوم" :desc="exam.randomize ? 'بله' : 'خیر'" /> </p>
<LineInfoBlock <span v-if="question.score" class="exam-details__question-score">
title="تاریخ اعتبار" {{ question.score }} نمره
:numeric-desc="formatJalaaliDate(exam.endDate) || '—'"
/>
<LineInfoBlock
title="تاریخ ثبت"
:numeric-desc="exam.faCreatedAt || formatJalaaliDate(exam.createdAt) || '—'"
/>
</div>
<div v-if="exam.description" class="exam-details__desc">
<LineInfoBlock title="توضیحات" :desc="exam.description" />
</div>
<div v-if="exam.questions?.length" class="exam-details__questions">
<LineTitleBlock title="سوالات" title-en="Questions" />
<div v-for="(q, index) in exam.questions" :key="q.id" class="exam-details__question">
<p class="exam-details__question-title">
<span class="exam-details__question-index">{{ index + 1 }}.</span>
{{ q.title }}
<span v-if="q.score" class="exam-details__question-score">
({{ q.score }} نمره)
</span> </span>
</p> </div>
<ul class="exam-details__answers"> <div class="exam-details__answers">
<li <div
v-for="(a, ai) in q.answers || []" v-for="answer in question.answers"
:key="a.id" :key="answer.id"
class="exam-details__answer" class="exam-details__answer"
:class="{
'exam-details__answer--correct': String(q.correctAnswerId) === String(a.id),
}"
> >
<span class="exam-details__answer-index">{{ optionLabel(ai) }}:</span> <span
{{ a.title }} class="exam-details__answer-dot"
</li> :class="{ 'exam-details__answer-dot--correct': answer.isCorrect }"
</ul> />
<p class="exam-details__answer-text">{{ answer.title }}</p>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -71,7 +57,7 @@
<div class="exam-details__divider" /> <div class="exam-details__divider" />
<div class="exam-details__footer"> <div class="exam-details__footer">
<BaseButton text="بستن" custom-class="exam-details__close-btn" @click="close"> <BaseButton text="تایید" custom-class="exam-details__confirm-btn" @click="close">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
@@ -84,17 +70,15 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useAdminExamQuery } from '@/services/query/admin-exams' import { useAdminExamQuery } from '@/services/query/admin-exams'
import { formatJalaaliDate } from '@/utils/date-utils' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
defineOptions({ name: 'ExamDetailsModal' }) defineOptions({ name: 'ExamDetailsModal' })
@@ -107,10 +91,57 @@ const { data: exam, isLoading } = useAdminExamQuery(examId, {
enabled: () => !!examId.value, enabled: () => !!examId.value,
}) })
const questionsLength = computed(() => exam.value?.questions?.length ?? 0) const summaryItems = computed(() => {
const e = exam.value || {}
return [
{ title: 'عنوان آزمون', value: e.title || '—', numeric: false },
{
title: 'جلسه مرتبط',
value: e.session?.title || e.sessionTitle || '—',
numeric: false,
},
{
title: 'دوره مرتبط',
value: e.course?.title || e.courseTitle || '—',
numeric: false,
},
{ title: 'وضعیت', value: e.statusLabel || e.faStatus || e.status || '—', numeric: false },
{
title: 'حد نصاب قبولی',
value: e.passScore == null ? '—' : `${e.passScore} نمره`,
numeric: true,
},
{
title: 'تعداد سوالات',
value:
e.questionsCount == null
? e.questions?.length
? `${e.questions.length} سوال`
: '—'
: `${e.questionsCount} سوال`,
numeric: true,
},
]
})
const labels = ['گزینه اول', 'گزینه دوم', 'گزینه سوم', 'گزینه چهارم'] const displayQuestions = computed(() => {
const optionLabel = (index) => labels[index] || `گزینه ${index + 1}` const raw = exam.value?.questions || []
return raw.map((question, index) => {
const optionsSource = Array.isArray(question.options) ? question.options : []
const answers = optionsSource.map((option, ai) => ({
id: option?.id ?? `${question?.id || index + 1}-${ai + 1}`,
title: option?.optionText || '—',
isCorrect: !!option?.isCorrect,
}))
return {
id: question?.id ?? `question-${index + 1}`,
order: question?.position ?? index + 1,
title: question?.questionText || '—',
score: '',
answers,
}
})
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -118,105 +149,122 @@ const optionLabel = (index) => labels[index] || `گزینه ${index + 1}`
width: 100%; width: 100%;
text-align: start; text-align: start;
&__head { &__summary {
margin: 1rem 0 0.5rem; margin-block: 0.5rem 0.75rem;
padding: 1rem 0.875rem;
border-radius: 1.25rem;
} }
&__title { &__summary-grid {
font-family: var(--font-family-fa);
font-size: 1.1rem;
font-weight: 500;
color: #4b4b4b;
margin: 0 0 0.25rem;
}
&__sub {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #535353;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__sep {
color: #c4c4c4;
}
&__grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.25rem; gap: 0.5rem;
@media (min-width: 640px) { @media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@media (min-width: 1024px) { @media (min-width: 1280px) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(6, 1fr);
}
}
&__cell :deep(.line-info__title),
&__cell :deep(.line-info__value) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__description {
margin-top: 0.75rem;
padding-top: 0.75rem;
:deep(.line-info__title),
:deep(.line-info__desc) {
white-space: normal;
word-break: break-word;
} }
} }
&__desc,
&__questions { &__questions {
margin-top: 1rem; display: flex;
flex-direction: column;
gap: 0.75rem;
margin-block: 1rem;
border-block-start: 1px solid #eaeaea;
padding-block-start: 1.25rem;
} }
&__question { &__question {
padding: 0.625rem 0; background: #fff;
border-block-end: 1px solid #f0f0f0; border-radius: 1.25rem;
padding: 0.875rem 1rem;
}
&:last-child { &__question-head {
border: none; display: flex;
} align-items: flex-start;
justify-content: space-between;
gap: 0.625rem;
margin-bottom: 0.75rem;
} }
&__question-title { &__question-title {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-size: 0.9rem; font-weight: 500;
color: #4b4b4b; font-size: 0.875rem;
margin: 0 0 0.375rem; line-height: 1.6;
color: #a7a7a7;
margin: 0;
flex: 1;
min-width: 0;
} }
&__question-index { &__question-order {
color: #b3b3b3;
margin-inline-end: 0.25rem; margin-inline-end: 0.25rem;
} }
&__question-score { &__question-score {
margin-inline-start: 0.25rem; flex-shrink: 0;
color: #007074; font-family: var(--font-family-en);
font-size: 0.75rem; font-size: 0.75rem;
color: #9c9c9c;
} }
&__answers { &__answers {
list-style: none;
padding: 0;
margin: 0;
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
gap: 0.25rem; gap: 0.625rem 3rem;
} }
&__answer { &__answer {
font-family: var(--font-family-fa); display: inline-flex;
font-size: 0.85rem; align-items: baseline;
color: #4b4b4b; gap: 0.375rem;
padding: 0.25rem 0.625rem; }
border-radius: 0.5rem;
background: #fafafa; &__answer-dot {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border-radius: 0.25rem;
border: 1px solid #b1b1b1;
background: transparent;
flex-shrink: 0;
&--correct { &--correct {
background: rgba(0, 154, 18, 8%); border-color: #007074;
color: #009a12; background: #007074;
font-weight: 500;
} }
} }
&__answer-index { &__answer-text {
color: #9c9c9c; font-family: var(--font-family-fa);
margin-inline-end: 0.375rem; font-size: 0.875rem;
line-height: 1.6;
color: #4b4b4b;
margin: 0;
} }
&__divider { &__divider {
@@ -229,8 +277,9 @@ const optionLabel = (index) => labels[index] || `گزینه ${index + 1}`
justify-content: flex-end; justify-content: flex-end;
} }
&__close-btn { &__confirm-btn {
min-width: 10rem; min-width: 10rem;
padding: 0 2rem;
} }
} }
</style> </style>
@@ -1,77 +1,95 @@
<template> <template>
<BasicModal width="95%" max-width="80rem" min-width="auto" :show-close-button="true"> <BasicModal width="95%" max-width="76rem" min-width="auto" :show-close-button="true">
<template #default="{ close }"> <template #default="{ close }">
<div class="participant-details"> <div class="participant-details">
<LineTitleBlock title="جزئیات شرکتکننده" title-en="Participant Details" /> <LineTitleBlock title="شرکت کنندگان" title-en="Participants" />
<p v-if="participant" class="participant-details__name">
{{ participantName }}
</p>
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" /> <p v-if="participantName" class="participant-details__name">{{ participantName }}</p>
<div v-else-if="attempts.length > 0" class="participant-details__list">
<div v-for="attempt in attempts" :key="attempt.id" class="participant-details__attempt">
<button
type="button"
class="participant-details__attempt-head"
@click="toggle(attempt.id)"
>
<SvgIcon
:name="isOpen(attempt.id) ? 'caret-down' : 'caret-left'"
:size="18"
color="#d8d8d8"
/>
<div class="participant-details__attempt-info">
<p class="participant-details__attempt-title">{{ attempt.title || 'تلاش' }}</p>
<div class="participant-details__attempt-meta">
<span>دوره: {{ attempt.courseTitle || '—' }}</span>
<span class="participant-details__sep">|</span>
<span>جلسه: {{ attempt.sessionTitle || '—' }}</span>
<span class="participant-details__sep">|</span>
<span>تاریخ: {{ attemptDate(attempt) }}</span>
<span
class="participant-details__score"
:class="`participant-details__score--${attempt.scoreTone || 'neutral'}`"
>
نمره: {{ attempt.score ?? '—' }}
</span>
</div>
</div>
</button>
<div v-if="isOpen(attempt.id)" class="participant-details__body"> <div class="participant-details__card">
<div <SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
v-for="(question, qIdx) in attempt.questions || []" <div v-else-if="attempts.length > 0" class="participant-details__list">
:key="question.id" <div v-for="attempt in attempts" :key="attempt.id" class="participant-details__attempt">
class="participant-details__question" <button
type="button"
class="participant-details__attempt-head"
@click="toggle(attempt.id)"
> >
<p class="participant-details__question-title"> <div class="participant-details__caret">
{{ qIdx + 1 }}- {{ question.title }} <SvgIcon
</p> :name="isOpen(attempt.id) ? 'caret-down' : 'caret-left'"
<div class="participant-details__answers"> :size="24"
<span color="#d8d8d8"
v-for="(answer, aIdx) in question.answers || []" />
:key="answer.id" </div>
class="participant-details__answer" <div class="participant-details__attempt-info">
:class="answerClass(question, answer)" <p class="participant-details__attempt-title">{{ attempt.title }}</p>
> <div class="participant-details__attempt-meta">
<span class="participant-details__answer-index">{{ aIdx + 1 }}.</span> <div class="participant-details__meta-item">
{{ answer.title }} <span class="participant-details__meta-label">دوره :</span>
</span> <span class="participant-details__meta-value">{{ attempt.courseTitle }}</span>
</div>
<div class="participant-details__meta-item">
<span class="participant-details__meta-label">جلسه :</span>
<span class="participant-details__meta-value">
{{ attempt.sessionTitle }}
</span>
</div>
<div class="participant-details__meta-item">
<span class="participant-details__meta-label">تاریخ :</span>
<span
class="participant-details__meta-value participant-details__meta-value--en"
>
{{ attempt.faDate }}
</span>
</div>
<div
class="participant-details__score"
:class="`participant-details__score--${attempt.tone}`"
>
<span class="participant-details__meta-label">نمره :</span>
<span class="participant-details__score-value">{{ attempt.score }}</span>
</div>
</div>
</div>
</button>
<div v-if="isOpen(attempt.id)" class="participant-details__body">
<div
v-for="(question, qIdx) in attempt.questions"
:key="question.id"
class="participant-details__question"
>
<p class="participant-details__question-title">
{{ qIdx + 1 }}- {{ question.title }}
</p>
<div class="participant-details__answers">
<div
v-for="answer in question.answers"
:key="answer.id"
class="participant-details__answer"
>
<span
class="participant-details__answer-dot"
:class="answerStateClass(answer.id, question)"
/>
<p class="participant-details__answer-text">{{ answer.title }}</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <NoItems v-else title="موردی یافت نشد" desc="تلاشی برای این شرکت‌کننده ثبت نشده است." />
<NoItems v-else title="موردی یافت نشد" desc="تلاشی برای این شرکت‌کننده ثبت نشده است." />
<div class="participant-details__divider" /> <div class="participant-details__divider" />
<div class="participant-details__footer">
<div class="participant-details__footer"> <BaseButton text="تایید" custom-class="participant-details__confirm-btn" @click="close">
<BaseButton text="بستن" custom-class="participant-details__close-btn" @click="close"> <template #appendIcon>
<template #appendIcon> <SvgIcon name="arrow-left" :size="18" color="#fff" />
<SvgIcon name="arrow-left" :size="18" color="#fff" /> </template>
</template> </BaseButton>
</BaseButton> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -79,20 +97,27 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import { useAdminExamParticipantQuery } from '@/services/query/admin-exams'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useAdminExamParticipantQuery } from '@/services/query/admin-exams'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
defineOptions({ name: 'ExamParticipantDetailsModal' }) defineOptions({ name: 'ExamParticipantDetailsModal' })
const TONE_MAP = {
good: 'success',
success: 'success',
bad: 'danger',
danger: 'danger',
neutral: 'neutral',
}
const { getModal } = useModal() const { getModal } = useModal()
const modalData = computed(() => getModal('ExamParticipantDetailsModal')?.data ?? {}) const modalData = computed(() => getModal('ExamParticipantDetailsModal')?.data ?? {})
@@ -104,16 +129,22 @@ const { data: detail, isLoading } = useAdminExamParticipantQuery(examId, partici
enabled: () => !!examId.value && !!participantId.value, enabled: () => !!examId.value && !!participantId.value,
}) })
const attempts = computed(() => detail.value?.attempts || detail.value?.data?.attempts || []) const rawAttempts = computed(
() => detail.value?.attempts || detail.value?.data?.attempts || participant.value?.attempts || []
)
const opened = ref(new Set()) const attempts = computed(() =>
const isOpen = (id) => opened.value.has(id) rawAttempts.value.map((attempt, index) => ({
const toggle = (id) => { id: attempt.id ?? index + 1,
const next = new Set(opened.value) title: attempt.title || `آزمون مرتبه ${index + 1}`,
if (next.has(id)) next.delete(id) courseTitle: attempt.courseTitle || '—',
else next.add(id) sessionTitle: attempt.sessionTitle || '—',
opened.value = next faDate: attempt.faDate || formatJalaaliDate(attempt.date) || '—',
} score: attempt.score ?? '—',
tone: TONE_MAP[attempt.scoreTone] || 'neutral',
questions: attempt.questions || [],
}))
)
const participantName = computed(() => { const participantName = computed(() => {
const p = participant.value const p = participant.value
@@ -121,14 +152,26 @@ const participantName = computed(() => {
return `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.fullName || '' return `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.fullName || ''
}) })
const attemptDate = (attempt) => attempt.faDate || formatJalaaliDate(attempt.date) || '—' const openId = ref(null)
const isOpen = (id) => openId.value === id
const toggle = (id) => {
openId.value = openId.value === id ? null : id
}
const answerClass = (question, answer) => { watch(
const isCorrect = String(question.correctAnswerId) === String(answer.id) attempts,
const isUserChoice = String(question.userAnswerId) === String(answer.id) (list) => {
if (isUserChoice && isCorrect) return 'participant-details__answer--correct' if (openId.value == null && list.length > 0) openId.value = list[0].id
if (isUserChoice && !isCorrect) return 'participant-details__answer--wrong' },
if (isCorrect) return 'participant-details__answer--correct-outline' { immediate: true }
)
const answerStateClass = (answerId, question) => {
const isSelected = String(question.userAnswerId ?? question.selectedAnswerId) === String(answerId)
const isCorrect = String(question.correctAnswerId) === String(answerId)
if (isSelected && isCorrect) return 'participant-details__answer-dot--selected-correct'
if (isCorrect) return 'participant-details__answer-dot--correct'
if (isSelected) return 'participant-details__answer-dot--wrong'
return '' return ''
} }
</script> </script>
@@ -145,15 +188,26 @@ const answerClass = (question, answer) => {
margin: 0.5rem 0 1rem; margin: 0.5rem 0 1rem;
} }
&__card {
border: 1px solid #e7e2dd;
border-radius: 1.75rem;
background: #fff;
padding: 1rem;
@media (min-width: 640px) {
padding: 1.5rem;
}
}
&__list { &__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1.25rem;
} }
&__attempt { &__attempt {
border: 1px solid #e7e2dd; border: 1px solid #e7e2dd;
border-radius: 1.5rem; border-radius: 1.75rem;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
} }
@@ -161,129 +215,184 @@ const answerClass = (question, answer) => {
&__attempt-head { &__attempt-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 1rem;
width: 100%; width: 100%;
text-align: start;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
text-align: start;
@media (min-width: 640px) {
padding: 1.25rem 1.5rem;
}
}
&__caret {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 0.25rem;
} }
&__attempt-info { &__attempt-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
} }
&__attempt-title { &__attempt-title {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 500; font-weight: 500;
font-size: 1rem; font-size: 1.125rem;
line-height: 1.7;
color: #494949; color: #494949;
margin: 0 0 0.5rem; margin: 0;
} }
&__attempt-meta { &__attempt-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.625rem 1.25rem; align-items: center;
font-family: var(--font-family-fa); column-gap: 1.25rem;
font-size: 0.75rem; row-gap: 0.5rem;
color: #8e8e8e; color: #8e8e8e;
} }
&__sep { &__meta-item {
color: #d6d6d6; white-space: nowrap;
font-size: 0.875rem;
line-height: 1.5;
}
&__meta-label {
font-family: var(--font-family-fa);
font-weight: 300;
margin-inline-end: 0.25rem;
}
&__meta-value {
font-family: var(--font-family-fa);
color: #7c7c7c;
}
&__meta-value--en {
font-family: var(--font-family-en);
} }
&__score { &__score {
padding: 0.25rem 0.875rem; padding: 0.375rem 0.75rem;
border-radius: 0.55rem; border-radius: 0.55rem;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-size: 0.75rem; font-size: 0.875rem;
line-height: 1.3;
&--good { &--success {
background: rgba(0, 154, 18, 8%); background: #e8f8ee;
color: #009a12; color: #169c56;
}
&--danger {
background: #fdebeb;
color: #cc2831;
} }
&--neutral { &--neutral {
background: rgba(180, 180, 180, 8%); background: #f4f4f4;
color: #8e8e8e; color: #8e8e8e;
} }
}
&--bad {
background: rgba(204, 40, 49, 8%); &__score-value {
color: var(--color-error); font-family: var(--font-family-en);
} font-weight: 500;
} }
&__body { &__body {
padding: 1rem 1.25rem 1.5rem; padding: 1.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 2rem;
@media (min-width: 640px) {
padding: 1.25rem 1.5rem 1.5rem;
}
} }
&__question-title { &__question-title {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.75;
color: #bdbdbd; color: #bdbdbd;
margin: 0 0 0.75rem; margin: 0 0 1.25rem;
} }
&__answers { &__answers {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.625rem 1rem; column-gap: 3rem;
row-gap: 1.25rem;
} }
&__answer { &__answer {
border: 1px solid #e7e2dd;
border-radius: 0.875rem;
padding: 0.375rem 0.875rem;
font-family: var(--font-family-fa);
font-size: 0.85rem;
color: #4b4b4b;
display: inline-flex; display: inline-flex;
align-items: center; align-items: baseline;
gap: 0.375rem; gap: 0.375rem;
}
&__answer-dot {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border-radius: 0.25rem;
border: 1px solid #d4d4d4;
background: transparent;
flex-shrink: 0;
&--correct { &--correct {
background: rgba(0, 154, 18, 8%); border-color: #007074;
border-color: rgba(0, 154, 18, 40%); background: #007074;
color: #009a12;
}
&--correct-outline {
border-color: rgba(0, 154, 18, 40%);
color: #009a12;
} }
&--wrong { &--wrong {
background: rgba(204, 40, 49, 8%); border-color: #cc2831;
border-color: rgba(204, 40, 49, 40%); background: #cc2831;
color: var(--color-error); }
&--selected-correct {
border-color: #9a9a9a;
background: #9a9a9a;
} }
} }
&__answer-index { &__answer-text {
font-family: var(--font-family-en); font-family: var(--font-family-fa);
color: #b3b3b3; font-size: 1rem;
line-height: 1.7;
color: #5b5b5b;
margin: 0;
} }
&__divider { &__divider {
border-block-end: 1px solid var(--color-thd-gray); border-block-end: 1px solid #e7e2dd;
margin-block: 1.25rem; margin-block: 1.5rem 1rem;
} }
&__footer { &__footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 0.75rem;
} }
&__close-btn { &__confirm-btn {
min-width: 12rem; min-width: 11.5rem;
padding: 0 2rem;
} }
} }
</style> </style>
@@ -63,18 +63,17 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import { useAdminExamParticipantsQuery } from '@/services/query/admin-exams'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import { usePagination } from '@/composables/usePagination'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { useAdminExamParticipantsQuery } from '@/services/query/admin-exams'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
defineOptions({ name: 'ExamParticipantsModal' }) defineOptions({ name: 'ExamParticipantsModal' })
+100 -93
View File
@@ -31,32 +31,17 @@
:error="errors.title" :error="errors.title"
@blur="validateAt('title', form.title)" @blur="validateAt('title', form.title)"
/> />
<TextField
v-model="form.durationMinutes"
name="durationMinutes"
label="مدت آزمون"
inputmode="numeric"
:convert-digits="true"
:error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)"
/>
<TextField <TextField
v-model="form.passingScore" v-model="form.passingScore"
name="passingScore" name="passingScore"
label="حداقل نمره قبولی" label="حد نصاب قبولی"
inputmode="numeric" inputmode="numeric"
:convert-digits="true" :convert-digits="true"
:error="errors.passingScore" :error="errors.passingScore"
@blur="validateAt('passingScore', form.passingScore)" @blur="validateAt('passingScore', form.passingScore)"
/> />
<DatePickerField
v-model="form.endDate"
name="endDate"
label="تاریخ اعتبار"
:error="errors.endDate"
/>
<div class="exam-form__toggle-cell"> <div class="exam-form__toggle-cell">
<ToggleSwitch v-model="form.randomize" label="به صورت رندوم باشد" /> <ToggleSwitch v-model="form.isActive" label="آزمون فعال باشد" />
</div> </div>
</div> </div>
</div> </div>
@@ -104,28 +89,28 @@
</template> </template>
<script setup> <script setup>
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import { examSchema } from '@/features/admin/exams/schema'
import SelectField from '@/components/form/SelectField.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import ExamQuestionBuilder from '@/features/admin/exams/components/ExamQuestionBuilder.vue' import ExamQuestionBuilder from '@/features/admin/exams/components/ExamQuestionBuilder.vue'
import useYup from '@/composables/useYup'
import { import {
adminExamsKeys, adminExamsKeys,
useAddAdminExamMutation, useAddAdminExamMutation,
useAddAdminExamQuestionMutation,
useAdminExamQuery, useAdminExamQuery,
useUpdateAdminExamMutation, useUpdateAdminExamMutation,
} from '@/services/query/admin-exams' } from '@/services/query/admin-exams'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import useDebounce from '@/composables/useDebounce'
import { examSchema } from '@/features/admin/exams/schema'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -137,36 +122,31 @@ const isEditMode = computed(() => !!examId.value)
const form = ref({ const form = ref({
title: '', title: '',
sessionId: '', sessionId: '',
endDate: '',
durationMinutes: '',
passingScore: '', passingScore: '',
randomize: true, isActive: true,
description: '', description: '',
}) })
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}` const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
const blankOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
const blankQuestion = () => ({ const blankQuestion = () => ({
id: createId('question'), id: createId('question'),
title: '', questionText: '',
score: '', position: 1,
correctAnswerId: null, options: [blankOption(), { ...blankOption(), id: createId('option') }],
answers: [ __local: true,
{ id: createId('answer'), title: '' },
{ id: createId('answer'), title: '' },
],
}) })
const questions = ref([blankQuestion()]) const questions = ref([blankQuestion()])
const questionError = ref('') const questionError = ref('')
const schema = examSchema const { validate, validateAt, errors } = useYup(examSchema)
const { validate, validateAt, errors } = useYup(schema)
const sessionSearch = ref('') const sessionSearch = ref('')
const sessionFilters = computed(() => ({ title: sessionSearch.value })) const sessionFilters = computed(() => ({ title: sessionSearch.value }))
const sessionPagination = ref({ page: 1, perPage: 30 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination) const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination)
const selectedSession = ref(null) const selectedSession = ref(null)
const sessionOptions = computed(() => { const sessionOptions = computed(() => {
@@ -185,20 +165,20 @@ const { data: existingExam } = useAdminExamQuery(examId, {
enabled: () => !!examId.value, enabled: () => !!examId.value,
}) })
const normalizeQuestions = (raw = []) => { const normalizeExistingQuestions = (raw = []) => {
if (!Array.isArray(raw) || raw.length === 0) return [blankQuestion()] if (!Array.isArray(raw) || raw.length === 0) return [blankQuestion()]
return raw.map((q, qIdx) => { return raw.map((q, qIdx) => {
const answersSrc = q.answers || q.options || q.choices || [] const options = Array.isArray(q.options) ? q.options : []
const answers = (Array.isArray(answersSrc) ? answersSrc : []).map((a, aIdx) => ({
id: a?.id || createId(`answer-${qIdx}-${aIdx}`),
title: typeof a === 'string' ? a : a?.title || a?.text || a?.label || '',
}))
return { return {
id: q.id || createId(`question-${qIdx}`), id: q.id ?? createId(`question-${qIdx}`),
title: q.title || q.question || q.text || '', questionText: q.questionText || '',
score: q.score ?? q.barom ?? '', position: q.position ?? qIdx + 1,
correctAnswerId: q.correctAnswerId || q.correctOptionId || q.correctAnswer?.id || null, options: options.map((o, oIdx) => ({
answers: answers.length > 0 ? answers : blankQuestion().answers, id: o.id ?? createId(`option-${qIdx}-${oIdx}`),
optionText: o.optionText || '',
isCorrect: !!o.isCorrect,
})),
// No `__local` flag these came from the server, so the builder will lock them.
} }
}) })
} }
@@ -209,62 +189,89 @@ watch(existingExam, (exam) => {
form.value = { form.value = {
title: exam.title || '', title: exam.title || '',
sessionId: exam.session?.id || exam.sessionId || '', sessionId: exam.session?.id || exam.sessionId || '',
endDate: exam.endDate || '', passingScore: exam.passScore ?? '',
durationMinutes: exam.durationMinutes ?? '', isActive: exam.isActive ?? true,
passingScore: exam.passingScore ?? '',
randomize: exam.randomize ?? true,
description: exam.description || '', description: exam.description || '',
} }
questions.value = normalizeQuestions(exam.questions) questions.value = normalizeExistingQuestions(exam.questions)
}) })
const validateQuestionList = () => { const validateLocalQuestions = () => {
const list = questions.value const localOnes = questions.value.filter((q) => q.__local === true)
if (list.some((q) => !String(q.title || '').trim() || !String(q.score || '').trim())) { if (!isEditMode.value && localOnes.length === 0) {
questionError.value = 'لطفا متن سوال و بارم هر سوال را وارد کنید.' questionError.value = 'حداقل یک سوال اضافه کنید.'
return null return null
} }
if (list.some((q) => q.answers.filter((a) => a.title?.trim()).length < 2)) { for (const q of localOnes) {
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.' if (!String(q.questionText || '').trim()) {
return null questionError.value = 'متن همه سوالات را وارد کنید.'
} return null
if ( }
list.some((q) => { const validOptions = q.options.filter((o) => String(o.optionText || '').trim())
if (!q.correctAnswerId) return true if (validOptions.length < 2) {
return !q.answers.some((a) => String(a.id) === String(q.correctAnswerId) && a.title?.trim()) questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
}) return null
) { }
questionError.value = 'گزینه صحیح هر سوال را از گزینه‌های موجود انتخاب کنید.' if (!validOptions.some((o) => o.isCorrect)) {
return null questionError.value = 'گزینه صحیح هر سوال را انتخاب کنید.'
return null
}
} }
questionError.value = '' questionError.value = ''
return list.map((q) => ({ return localOnes.map((q, idx) => ({
id: q.id, questionText: q.questionText.trim(),
title: q.title.trim(), position: Number(q.position) || idx + 1,
score: q.score, options: q.options
correctAnswerId: q.correctAnswerId, .filter((o) => String(o.optionText || '').trim())
answers: q.answers.filter((a) => a.title?.trim()).map((a) => ({ id: a.id, title: a.title })), .map((o) => ({
optionText: o.optionText.trim(),
isCorrect: !!o.isCorrect,
})),
})) }))
} }
const addMutation = useAddAdminExamMutation() const buildExamPayload = (values) => ({
const updateMutation = useUpdateAdminExamMutation() sessionId: values.sessionId,
title: values.title,
description: values.description,
passingScore: Number(values.passingScore) || 0,
isActive: values.isActive,
})
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value) const addExamMutation = useAddAdminExamMutation()
const updateExamMutation = useUpdateAdminExamMutation()
const addQuestionMutation = useAddAdminExamQuestionMutation()
const submitting = computed(
() =>
addExamMutation.isPending.value ||
updateExamMutation.isPending.value ||
addQuestionMutation.isPending.value
)
const postQuestionsSequentially = async (id, list) => {
for (const payload of list) {
// Sequential so question position ordering is preserved on the backend.
// eslint-disable-next-line no-await-in-loop
await addQuestionMutation.mutateAsync({ examId: id, payload })
}
}
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid } = await validate(form.value)
const cleanQuestions = validateQuestionList() const newQuestions = validateLocalQuestions()
if (!isValid || !cleanQuestions) return if (!isValid || !newQuestions) return
const finalPayload = {
...payload, const examPayload = buildExamPayload(form.value)
questions: cleanQuestions, let targetExamId = examId.value
questionsCount: cleanQuestions.length,
}
if (isEditMode.value) { if (isEditMode.value) {
await updateMutation.mutateAsync({ id: examId.value, payload: finalPayload }) await updateExamMutation.mutateAsync({ id: targetExamId, payload: examPayload })
} else { } else {
await addMutation.mutateAsync(finalPayload) const created = await addExamMutation.mutateAsync(examPayload)
targetExamId = created?.data?.id ?? created?.id ?? targetExamId
}
if (targetExamId && newQuestions.length > 0) {
await postQuestionsSequentially(targetExamId, newQuestions)
} }
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all }) await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
router.push({ name: 'admin-exams' }) router.push({ name: 'admin-exams' })
@@ -306,7 +313,7 @@ const onCancel = () => router.push({ name: 'admin-exams' })
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(4, 1fr);
} }
} }
@@ -50,33 +50,32 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import ExamsFilters from '@/features/admin/exams/components/ExamsFilters.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import ExamItem from '@/features/admin/exams/components/ExamItem.vue' import ExamItem from '@/features/admin/exams/components/ExamItem.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ExamsFilters from '@/features/admin/exams/components/ExamsFilters.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import ExamDetailsModal from '@/features/admin/exams/components/modals/ExamDetailsModal.vue'
import ExamParticipantsModal from '@/features/admin/exams/components/modals/ExamParticipantsModal.vue' import ExamParticipantsModal from '@/features/admin/exams/components/modals/ExamParticipantsModal.vue'
import ExamParticipantDetailsModal from '@/features/admin/exams/components/modals/ExamParticipantDetailsModal.vue' import ExamParticipantDetailsModal from '@/features/admin/exams/components/modals/ExamParticipantDetailsModal.vue'
import ExamDetailsModal from '@/features/admin/exams/components/modals/ExamDetailsModal.vue'
import { import {
adminExamsKeys, adminExamsKeys,
useAdminExamsListQuery, useAdminExamsListQuery,
useDeleteAdminExamMutation, useDeleteAdminExamMutation,
} from '@/services/query/admin-exams' } from '@/services/query/admin-exams'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal'
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const filters = ref({ title: '', courseTemplateId: '', fromDate: '', toDate: '' }) const filters = ref({ title: '', courseId: '', fromDate: '', toDate: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 }) const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminExamsListQuery(filters, pagination, { const { data, isLoading } = useAdminExamsListQuery(filters, pagination, {
+2 -3
View File
@@ -1,10 +1,9 @@
import { number, object, string } from 'yup' import { boolean, number, object, string } from 'yup'
export const examSchema = object().shape({ export const examSchema = object().shape({
title: string().required().min(3), title: string().required().min(3),
sessionId: string().required(), sessionId: string().required(),
endDate: string().required(),
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(), passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
isActive: boolean().nullable().notRequired(),
}) })
@@ -1,23 +1,20 @@
<template> <template>
<div class="ticket-item"> <div class="ticket-item">
<div class="ticket-item__user"> <div class="ticket-item__user">
<div v-if="ticket.user?.avatarUrl" class="ticket-item__avatar"> <div v-if="ticket.student?.avatarUrl" class="ticket-item__avatar">
<img :src="ticket.user.avatarUrl" :alt="userName" /> <img :src="ticket.student.avatarUrl" :alt="userName" />
</div> </div>
<div v-else class="ticket-item__avatar ticket-item__avatar--placeholder"> <div v-else class="ticket-item__avatar ticket-item__avatar--placeholder">
<SvgIcon name="user" :size="24" color="#bcbcbc" /> <SvgIcon name="user" :size="24" color="#bcbcbc" />
</div> </div>
<div class="ticket-item__info"> <div class="ticket-item__info">
<p class="ticket-item__name">{{ userName }}</p> <p class="ticket-item__name">{{ userName }}</p>
<p class="ticket-item__title">{{ ticket.title || '—' }}</p> <p class="ticket-item__title">{{ ticket.subject || '—' }}</p>
</div> </div>
</div> </div>
<div class="ticket-item__meta"> <div class="ticket-item__meta">
<span <span class="ticket-item__status" :class="`ticket-item__status--${ticket.status || 'open'}`">
class="ticket-item__status"
:class="`ticket-item__status--${ticket.status || 'pending'}`"
>
{{ statusLabel }} {{ statusLabel }}
</span> </span>
<div class="ticket-item__pill"> <div class="ticket-item__pill">
@@ -46,10 +43,9 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { TICKET_STATUS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { TICKET_STATUS } from '@/enums'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
const props = defineProps({ const props = defineProps({
@@ -58,21 +54,19 @@ const props = defineProps({
const emit = defineEmits(['show-details']) const emit = defineEmits(['show-details'])
const userName = computed(() => { const userName = computed(() => props.ticket.student?.name || '—')
const u = props.ticket.user
if (!u) return '—' const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
return `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.fullName || '—'
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
const createdTime = computed(() => {
const iso = props.ticket.createdAt
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
}) })
const statusLabel = computed(
() => props.ticket.statusLabel || TICKET_STATUS[props.ticket.status] || '—'
)
const createdAt = computed(
() => props.ticket.faCreatedAt || formatJalaaliDate(props.ticket.createdAt) || '—'
)
const createdTime = computed(() => props.ticket.faCreatedTime || '')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -161,7 +155,7 @@ const createdTime = computed(() => props.ticket.faCreatedTime || '')
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
&--pending { &--open {
background: rgba(204, 154, 40, 8%); background: rgba(204, 154, 40, 8%);
color: #cc6f00; color: #cc6f00;
} }
@@ -46,14 +46,13 @@
</template> </template>
<script setup> <script setup>
import { TICKET_STATUS } from '@/enums'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { TICKET_STATUS } from '@/enums'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -1,6 +1,6 @@
<template> <template>
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true"> <BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
<template #default="{ close }"> <template #default>
<div class="ticket-details"> <div class="ticket-details">
<LineTitleBlock title="جزئیات تیکت" title-en="Ticket Details" /> <LineTitleBlock title="جزئیات تیکت" title-en="Ticket Details" />
@@ -17,7 +17,7 @@
:class="`ticket-details__row--${senderClass(message)}`" :class="`ticket-details__row--${senderClass(message)}`"
> >
<div class="ticket-details__bubble"> <div class="ticket-details__bubble">
<p class="ticket-details__text">{{ message.text }}</p> <p class="ticket-details__text">{{ message.message }}</p>
<span class="ticket-details__time">{{ messageTime(message) }}</span> <span class="ticket-details__time">{{ messageTime(message) }}</span>
</div> </div>
</div> </div>
@@ -26,19 +26,6 @@
<div class="ticket-details__divider" /> <div class="ticket-details__divider" />
<div v-if="attachment" class="ticket-details__attachment">
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
<span class="ticket-details__attachment-name">{{ attachment.name }}</span>
<button
type="button"
class="ticket-details__attachment-remove"
aria-label="حذف فایل"
@click="removeAttachment"
>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</button>
</div>
<form class="ticket-details__compose" @submit.prevent="onSend"> <form class="ticket-details__compose" @submit.prevent="onSend">
<button <button
type="submit" type="submit"
@@ -65,20 +52,6 @@
> >
<SvgIcon name="mood" :size="30" color="currentColor" /> <SvgIcon name="mood" :size="30" color="currentColor" />
</button> </button>
<button
type="button"
class="ticket-details__compose-btn"
aria-label="پیوست فایل"
@click="triggerFilePicker"
>
<SvgIcon name="attach-file" :size="30" color="currentColor" />
</button>
<input
ref="fileInput"
type="file"
class="ticket-details__file-input"
@change="onFileSelected"
/>
</div> </div>
</form> </form>
</div> </div>
@@ -103,8 +76,15 @@
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { import {
autoUpdate, autoUpdate,
computePosition, computePosition,
@@ -112,20 +92,11 @@ import {
offset as offsetMiddleware, offset as offsetMiddleware,
shift, shift,
} from '@floating-ui/dom' } from '@floating-ui/dom'
import BasicModal from '@/components/BasicModal.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal'
import { import {
adminTicketsKeys, adminTicketsKeys,
useAdminTicketQuery, useAdminTicketQuery,
useSendAdminTicketMessageMutation, useSendAdminTicketMessageMutation,
} from '@/services/query/admin-tickets' } from '@/services/query/admin-tickets'
import { formatJalaaliDate } from '@/utils/date-utils'
import { objectToFormData } from '@/utils/object-to-formdata'
defineOptions({ name: 'TicketDetailsModal' }) defineOptions({ name: 'TicketDetailsModal' })
@@ -168,28 +139,21 @@ const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
const messages = computed(() => ticket.value?.messages ?? []) const messages = computed(() => ticket.value?.messages ?? [])
const ticketDate = computed( const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
() => ticket.value?.faCreatedAt || formatJalaaliDate(ticket.value?.createdAt) || '—'
)
const senderClass = (message) => (message.sender === 'admin' ? 'admin' : 'user') // Anyone whose id matches the ticket's student is "the student"; everyone else
// (admin, counselor) renders on the opposite side of the thread.
const senderClass = (message) => (message.senderId === ticket.value?.studentId ? 'user' : 'admin')
const messageTime = (message) => message.time || message.faSentAt || message.sentAt || '—' const messageTime = (message) => {
if (!message.createdAt) return '—'
const d = new Date(message.createdAt)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
}
const text = ref('') const text = ref('')
const attachment = ref(null) const canSend = computed(() => text.value.trim().length > 0)
const canSend = computed(() => text.value.trim().length > 0 || !!attachment.value)
const fileInput = ref(null)
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (file) attachment.value = file
if (event.target) event.target.value = ''
}
const removeAttachment = () => {
attachment.value = null
}
const emojiOpen = ref(false) const emojiOpen = ref(false)
const emojiButton = ref(null) const emojiButton = ref(null)
@@ -245,12 +209,10 @@ const sendMutation = useSendAdminTicketMessageMutation()
const onSend = async () => { const onSend = async () => {
if (!canSend.value || !ticketId.value) return if (!canSend.value || !ticketId.value) return
const value = text.value.trim() const value = text.value.trim()
const file = attachment.value
text.value = '' text.value = ''
attachment.value = null
emojiOpen.value = false emojiOpen.value = false
const payload = file ? objectToFormData({ text: value, attachment: file }) : { text: value } // Backend POST /admin/tickets/:id/messages body is { message: string }.
await sendMutation.mutateAsync({ id: ticketId.value, payload }) await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all }) await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
} }
@@ -37,19 +37,18 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TicketsFilters from '@/features/admin/messages/components/TicketsFilters.vue'
import TicketItem from '@/features/admin/messages/components/TicketItem.vue'
import TicketDetailsModal from '@/features/admin/messages/components/modals/TicketDetailsModal.vue'
import { useAdminTicketsListQuery } from '@/services/query/admin-tickets'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { useAdminTicketsListQuery } from '@/services/query/admin-tickets'
import TicketItem from '@/features/admin/messages/components/TicketItem.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import TicketsFilters from '@/features/admin/messages/components/TicketsFilters.vue'
import TicketDetailsModal from '@/features/admin/messages/components/modals/TicketDetailsModal.vue'
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
+20 -8
View File
@@ -54,16 +54,28 @@ export default [
meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' }, meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' },
}, },
{ {
path: '/add-course-template', path: '/add-course',
name: 'admin-add-course-template', name: 'admin-add-course',
component: () => import('@/features/admin/courses/pages/CourseTemplateFormPage.vue'), component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره الگو' }, meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره' },
}, },
{ {
path: '/edit-course-template/:id', path: '/edit-course/:id',
name: 'admin-edit-course-template', name: 'admin-edit-course',
component: () => import('@/features/admin/courses/pages/CourseTemplateFormPage.vue'), component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره الگو' }, meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره' },
},
{
path: '/terms/:termId/courses',
name: 'admin-term-courses',
component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'دوره‌های ترم' },
},
{
path: '/courses/:courseId/sessions',
name: 'admin-course-sessions',
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
meta: { layout: 'admin', role: 'admin', title: 'جلسات دوره' },
}, },
{ {
path: '/sessions', path: '/sessions',
@@ -61,12 +61,11 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums' import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import DropdownMenu from '@/components/DropdownMenu.vue'
const props = defineProps({ const props = defineProps({
service: { type: Object, required: true }, service: { type: Object, required: true },
@@ -46,14 +46,13 @@
</template> </template>
<script setup> <script setup>
import { SERVICE_STATUS } from '@/enums'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS } from '@/enums'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -66,13 +66,12 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import { SERVICE_STATUS } from '@/enums'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS } from '@/enums'
defineOptions({ name: 'SendMessageModal' }) defineOptions({ name: 'SendMessageModal' })
@@ -37,12 +37,12 @@
</template> </template>
<script setup> <script setup>
import BasicModal from '@/components/BasicModal.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_TYPE } from '@/enums' import { SERVICE_TYPE } from '@/enums'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'ServiceDetailsModal' }) defineOptions({ name: 'ServiceDetailsModal' })
@@ -36,16 +36,15 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
import ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.vue'
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.vue'
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
@@ -13,7 +13,7 @@
<div class="session-item__meta"> <div class="session-item__meta">
<div class="session-item__pill"> <div class="session-item__pill">
<span class="session-item__pill-label">متعلق به دوره:</span> <span class="session-item__pill-label">متعلق به دوره:</span>
<span class="session-item__pill-value">{{ session.courseTemplate?.title || '—' }}</span> <span class="session-item__pill-value">{{ session.course?.title || '—' }}</span>
</div> </div>
<div class="session-item__pill"> <div class="session-item__pill">
<span class="session-item__pill-label">مدت جلسه:</span> <span class="session-item__pill-label">مدت جلسه:</span>
@@ -72,11 +72,10 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SESSION_TYPE } from '@/enums' import { SESSION_TYPE } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({ const props = defineProps({
session: { type: Object, required: true }, session: { type: Object, required: true },
@@ -7,9 +7,9 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.courseTemplateId" v-model="form.courseId"
name="courseTemplateId" name="courseId"
label="دوره الگو" label="دوره"
:options="templateOptions" :options="templateOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
@@ -56,23 +56,22 @@
</template> </template>
<script setup> <script setup>
import { SESSION_TYPE } from '@/enums'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useDebounce from '@/composables/useDebounce'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import useDebounce from '@/composables/useDebounce'
import { SESSION_TYPE } from '@/enums'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ default: () => ({
title: '', title: '',
courseTemplateId: '', courseId: '',
sessionType: '', sessionType: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -84,7 +83,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ const emptyForm = () => ({
title: '', title: '',
courseTemplateId: '', courseId: '',
sessionType: '', sessionType: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -109,11 +108,8 @@ const sessionTypeOptions = Object.entries(SESSION_TYPE).map(([value, label]) =>
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({ title: templateSearch.value }))
const templatePagination = ref({ page: 1, perPage: 30 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
templateFilters,
templatePagination
)
const templateOptions = computed(() => templatesResponse.value?.data ?? []) const templateOptions = computed(() => templatesResponse.value?.data ?? [])
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
@@ -1,9 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true"> <BasicModal
<template #default="{ data, close }"> title="حضور و غیاب"
title-en="Attendance"
width="95%"
max-width="60rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="session-attendance"> <div class="session-attendance">
<LineTitleBlock title="حضور و غیاب" title-en="Attendance" />
<div class="session-attendance__filters"> <div class="session-attendance__filters">
<SelectField <SelectField
v-model="filters.termId" v-model="filters.termId"
@@ -77,21 +82,20 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import useDebounce from '@/composables/useDebounce'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SelectField from '@/components/form/SelectField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import SelectField from '@/components/form/SelectField.vue'
import { usePagination } from '@/composables/usePagination' import { usePagination } from '@/composables/usePagination'
import { useAdminSessionAttendanceQuery } from '@/services/query/admin-sessions' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { useAdminTermsListQuery } from '@/services/query/admin-terms' import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import useDebounce from '@/composables/useDebounce' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useAdminSessionAttendanceQuery } from '@/services/query/admin-sessions'
defineOptions({ name: 'SessionAttendanceModal' }) defineOptions({ name: 'SessionAttendanceModal' })
@@ -121,7 +125,7 @@ const paginationMeta = computed(() => ({
const termSearch = ref('') const termSearch = ref('')
const termListFilters = computed(() => ({ title: termSearch.value })) const termListFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 30 }) const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termListFilters, termPagination) const { data: termsResponse } = useAdminTermsListQuery(termListFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? []) const termOptions = computed(() => termsResponse.value?.data ?? [])
@@ -1,96 +1,59 @@
<template> <template>
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true"> <BasicModal
title="جزئیات جلسه"
title-en="details"
width="95%"
max-width="60rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }"> <template #default="{ close }">
<div class="session-details"> <div class="session-details">
<LineTitleBlock title="جزئیات جلسه" title-en="Session Details" />
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<template v-else-if="session"> <template v-else-if="session">
<div class="session-details__hero">
<div class="session-details__image">
<img v-if="session.image" :src="session.image" :alt="session.title" />
<SvgIcon v-else name="book" :size="36" color="#bcbcbc" />
</div>
<div class="session-details__hero-info">
<p class="session-details__title">{{ session.title || '—' }}</p>
<p v-if="session.courseTemplate?.title" class="session-details__sub">
دوره: {{ session.courseTemplate.title }}
</p>
</div>
</div>
<div class="session-details__grid"> <div class="session-details__grid">
<LineInfoBlock title="نوع جلسه" :desc="sessionTypeLabel" /> <LineInfoBlock title="عنوان جلسه" :desc="session.title || '—'" />
<LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" /> <LineInfoBlock
<LineInfoBlock title=رتیب" :numeric-desc="session.order ?? ''" /> title=اریخ شروع"
<LineInfoBlock title="درس‌های مرتبط" :numeric-desc="usedInTermsCount" /> :numeric-desc="formatJalaaliDate(session.startsAt) || '—'"
</div> />
<div v-if="hasConfig" class="session-details__config"> <LineInfoBlock title="مدت زمان جلسه" :numeric-desc="durationLabel" />
<LineTitleBlock title="تنظیمات جلسه" title-en="Session Config" />
<div class="session-details__grid"> <LineInfoBlock title="نوع جلسه" :desc="sessionTypeLabel" />
<LineInfoBlock <LineInfoBlock title="دوره مرتبط" :desc="courseTitle" />
v-if="session.sessionConfig?.startTime" <LineInfoBlock title="محتوای جلسه" :desc="contentTypeLabel" />
title="تاریخ و ساعت شروع"
:numeric-desc="
formatJalaaliDate(session.sessionConfig.startTime) ||
session.sessionConfig.startTime
"
/>
<LineInfoBlock
v-if="session.sessionConfig?.location"
title="مکان"
:desc="session.sessionConfig.location"
/>
<LineInfoBlock
v-if="session.sessionConfig?.meetingLink"
title="لینک جلسه"
:desc="session.sessionConfig.meetingLink"
/>
<LineInfoBlock
v-if="session.sessionConfig?.platform"
title="پلتفرم"
:desc="
SESSION_PLATFORM[session.sessionConfig.platform] || session.sessionConfig.platform
"
/>
<LineInfoBlock
v-if="session.sessionConfig?.minWatchedPercent != null"
title="حداقل درصد مشاهده"
:numeric-desc="`${session.sessionConfig.minWatchedPercent}%`"
/>
<LineInfoBlock
v-if="session.sessionConfig?.minReadPercent != null"
title="حداقل درصد مطالعه"
:numeric-desc="`${session.sessionConfig.minReadPercent}%`"
/>
<LineInfoBlock
v-if="session.sessionConfig?.mustCompleteBeforeNext != null"
title="الزام تکمیل قبل از جلسه بعدی"
:desc="session.sessionConfig.mustCompleteBeforeNext ? 'بله' : 'خیر'"
/>
</div>
</div> </div>
<div v-if="session.description" class="session-details__desc"> <div v-if="session.description" class="session-details__desc">
<LineInfoBlock title="توضیحات" :desc="session.description" /> <LineInfoBlock title="توضیحات جلسه" :desc="session.description" />
</div> </div>
<div v-if="session.materials?.length" class="session-details__materials"> <div v-if="isOnline && session.link" class="session-details__link-row">
<LineTitleBlock title="فایل‌های جلسه" title-en="Materials" /> <LineInfoBlock title="لینک جلسه" :desc="session.link" />
<ul class="session-details__materials-list"> </div>
<li v-for="material in session.materials" :key="material.id">
<a <div v-if="mediaUrl || contentType" class="session-details__media">
:href="material.filePath || material.fileUrl || '#'" <VideoPlayerBlock
target="_blank" v-if="contentType === 'video'"
rel="noopener noreferrer" :src="mediaUrl"
class="session-details__file" :video-id="session.id"
> />
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" /> <VoiceRecorder
<span>{{ material.title || `فایل ${material.id}` }}</span> v-else-if="contentType === 'voice'"
</a> :model-value="mediaUrl"
</li> :disabled="true"
</ul> />
<a
v-else-if="mediaUrl"
:href="mediaUrl"
target="_blank"
rel="noopener noreferrer"
class="session-details__file"
>
<SvgIcon name="file" :size="18" color="var(--color-primary)" />
<span>{{ mediaFileName }}</span>
</a>
</div> </div>
</template> </template>
<NoItems v-else title="یافت نشد" desc="اطلاعات این جلسه در دسترس نیست." /> <NoItems v-else title="یافت نشد" desc="اطلاعات این جلسه در دسترس نیست." />
@@ -98,7 +61,7 @@
<div class="session-details__divider" /> <div class="session-details__divider" />
<div class="session-details__footer"> <div class="session-details__footer">
<BaseButton text="بستن" custom-class="session-details__close-btn" @click="close"> <BaseButton text="تایید" custom-class="session-details__close-btn" @click="close">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
@@ -111,18 +74,18 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import { useAdminSessionQuery } from '@/services/query/admin-sessions'
import { SESSION_PLATFORM, SESSION_TYPE } from '@/enums'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import { COURSE_CONTENT_TYPE, SESSION_TYPE } from '@/enums'
import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { useAdminSessionQuery } from '@/services/query/admin-sessions'
import VideoPlayerBlock from '@/components/blocks/VideoPlayerBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
defineOptions({ name: 'SessionDetailsModal' }) defineOptions({ name: 'SessionDetailsModal' })
@@ -136,20 +99,37 @@ const { data: session, isLoading } = useAdminSessionQuery(sessionId, {
}) })
const sessionTypeLabel = computed( const sessionTypeLabel = computed(
() => session.value?.sessionTypeFa || SESSION_TYPE[session.value?.sessionType] || '—' () => SESSION_TYPE[session.value?.type] || session.value?.typeFa || '—'
) )
const durationLabel = computed(() => const durationLabel = computed(() =>
session.value?.durationMinutes == null ? '—' : `${session.value.durationMinutes} دقیقه` session.value?.durationMinutes == null ? '—' : `${session.value.durationMinutes} دقیقه`
) )
const usedInTermsCount = computed(() => session.value?.usedInTerms?.length ?? 0) const courseTitle = computed(() => session.value?.course?.title || '—')
const hasConfig = computed(() => { const isOnline = computed(() => session.value?.type === 'online')
const c = session.value?.sessionConfig
if (!c) return false const collectionToContentType = (collectionName) => {
return Object.values(c).some((v) => v !== '' && v !== null && v !== undefined) if (collectionName === 'videos') return 'video'
}) if (collectionName === 'voices') return 'voice'
if (collectionName === 'pdfs') return 'text'
return ''
}
const media = computed(() => (Array.isArray(session.value?.media) ? session.value.media : []))
const contentMedia = computed(() => media.value.find((m) => m.collectionName !== 'cover'))
const contentType = computed(() => collectionToContentType(contentMedia.value?.collectionName))
const contentTypeLabel = computed(() => COURSE_CONTENT_TYPE[contentType.value] || '—')
const mediaUrl = computed(
() => contentMedia.value?.url || contentMedia.value?.downloadUrl || ''
)
const mediaFileName = computed(() => contentMedia.value?.fileName || 'فایل پیوست')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -157,51 +137,6 @@ const hasConfig = computed(() => {
width: 100%; width: 100%;
text-align: start; text-align: start;
&__hero {
display: flex;
align-items: center;
gap: 1rem;
margin: 1rem 0;
}
&__image {
width: 5rem;
height: 5rem;
border-radius: 0.5rem;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__hero-info {
flex: 1;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1.1rem;
font-weight: 500;
color: #4b4b4b;
margin: 0 0 0.375rem;
}
&__sub {
font-family: var(--font-family-fa);
font-size: 0.8rem;
color: #838383;
margin: 0;
}
&__grid { &__grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -212,33 +147,43 @@ const hasConfig = computed(() => {
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
} }
} }
&__config,
&__desc, &__desc,
&__materials { &__link-row {
margin-top: 1rem; margin-top: 0.5rem;
} }
&__materials-list { &__link-row {
list-style: none; display: grid;
padding: 0; grid-template-columns: 1fr;
margin: 0;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
}
}
&__media {
margin-top: 1rem;
display: flex; display: flex;
flex-direction: column; justify-content: center;
gap: 0.25rem;
} }
&__file { &__file {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.5rem;
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-size: 0.85rem; font-size: 0.875rem;
padding: 0.5rem 0;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
@@ -24,166 +24,115 @@
<div class="session-form__main-col"> <div class="session-form__main-col">
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" /> <LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
<div class="session-form__row">
<div class="session-form__cell session-form__cell--third">
<TextField
v-model="form.title"
name="title"
label="عنوان جلسه"
:error="errors.title"
@blur="validateAt('title', form.title)"
>
<template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="session-form__cell session-form__cell--third">
<SelectField
v-model="form.courseTemplateId"
name="courseTemplateId"
label="دوره الگو"
:options="templateOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTemplates"
:error="errors.courseTemplateId"
/>
</div>
<div class="session-form__cell session-form__cell--third">
<SelectField
v-model="form.sessionType"
name="sessionType"
label="نوع جلسه"
:options="sessionTypeOptions"
option-label="label"
option-value="value"
:error="errors.sessionType"
/>
</div>
<div class="session-form__cell session-form__cell--third">
<TextField
v-model="form.durationMinutes"
name="durationMinutes"
label="مدت زمان (دقیقه)"
inputmode="numeric"
:convert-digits="true"
:error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)"
/>
</div>
<div class="session-form__cell session-form__cell--third">
<TextField
v-model="form.order"
name="order"
label="ترتیب"
inputmode="numeric"
:convert-digits="true"
:error="errors.order"
/>
</div>
<template v-if="form.sessionType === 'online'"> <!-- row 1 title / startsAt / endsAt -->
<div class="session-form__cell session-form__cell--third"> <div class="session-form__row session-form__row--three">
<TextField <TextField
v-model="form.sessionConfig.meetingLink" v-model="form.title"
name="meetingLink" name="title"
label="لینک جلسه" label="عنوان جلسه"
/> :error="errors.title"
</div> @blur="validateAt('title', form.title)"
<div class="session-form__cell session-form__cell--third"> >
<SelectField <template #appendIcon>
v-model="form.sessionConfig.platform" <SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
name="platform" </template>
label="پلتفرم" </TextField>
:options="platformOptions" <DatePickerField
option-label="label" v-model="form.startsAt"
option-value="value" name="startsAt"
/> label="زمان شروع"
</div> type="datetime"
<div class="session-form__cell session-form__cell--third"> :error="errors.startsAt"
<DatePickerField @blur="validateAt('startsAt', form.startsAt)"
v-model="form.sessionConfig.startTime" />
name="startTime" <DatePickerField
label="تاریخ و ساعت شروع" v-model="form.endsAt"
type="datetime" name="endsAt"
/> label="زمان پایان"
</div> type="datetime"
</template> :error="errors.endsAt"
/>
<template v-else-if="form.sessionType === 'in_person'">
<div class="session-form__cell session-form__cell--third">
<DatePickerField
v-model="form.sessionConfig.startTime"
name="startTime"
label="تاریخ و ساعت شروع"
type="datetime"
/>
</div>
<div class="session-form__cell session-form__cell--full">
<TextField
v-model="form.sessionConfig.location"
name="location"
label="مکان جلسه"
/>
</div>
</template>
<template v-else-if="['video', 'audio'].includes(form.sessionType)">
<div class="session-form__cell session-form__cell--third">
<TextField
v-model="form.sessionConfig.minWatchedPercent"
name="minWatchedPercent"
label="حداقل درصد مشاهده (%)"
inputmode="numeric"
:convert-digits="true"
/>
</div>
<div class="session-form__cell session-form__cell--third session-form__toggle-cell">
<ToggleSwitch
v-model="form.sessionConfig.mustCompleteBeforeNext"
label="الزام تکمیل قبل از جلسه بعدی"
/>
</div>
</template>
<template v-else-if="['text', 'slide', 'pdf'].includes(form.sessionType)">
<div class="session-form__cell session-form__cell--third">
<TextField
v-model="form.sessionConfig.minReadPercent"
name="minReadPercent"
label="حداقل درصد مطالعه (%)"
inputmode="numeric"
:convert-digits="true"
/>
</div>
<div class="session-form__cell session-form__cell--third session-form__toggle-cell">
<ToggleSwitch
v-model="form.sessionConfig.mustCompleteBeforeNext"
label="الزام تکمیل قبل از جلسه بعدی"
/>
</div>
</template>
<div class="session-form__cell session-form__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات"
:row="5"
/>
</div>
</div> </div>
<div class="session-form__materials"> <!-- row 2 durationMinutes / courseId / contentType -->
<LineTitleBlock title="فایل‌های جلسه" title-en="Session Materials" /> <div class="session-form__row session-form__row--three">
<TextField
v-model="form.durationMinutes"
name="durationMinutes"
label="مدت زمان جلسه (دقیقه)"
inputmode="numeric"
:convert-digits="true"
:error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره"
:options="courseOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchCourses"
:error="errors.courseId"
/>
<SelectField
v-model="form.contentType"
name="contentType"
label="محتوای جلسه"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<!-- row 3 type / link -->
<div class="session-form__row session-form__row--two">
<SelectField
v-model="form.type"
name="type"
label="نوع جلسه"
:options="sessionTypeOptions"
option-label="label"
option-value="value"
:error="errors.type"
@change="onSessionTypeChange"
/>
<TextField
v-model="form.link"
name="link"
label="لینک جلسه"
:disabled="form.type !== 'online'"
:error="errors.link"
@blur="validateAt('link', form.link)"
/>
</div>
<!-- row 4 description -->
<div class="session-form__row">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات جلسه"
:row="5"
/>
</div>
<!-- row 5 content uploader -->
<div class="session-form__row">
<label class="session-form__uploader-label">محتوای جلسه</label>
<FileUploader <FileUploader
v-model="materials" v-model="contentFiles"
accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx" :accept="contentAccept"
:multiple="true" :multiple="false"
:max-files="10" :max-files="1"
context="session" :disabled="!form.contentType"
@select="onContentSelect"
@remove="onContentRemove"
@error="onContentError"
/> />
</div> </div>
</div> </div>
@@ -218,34 +167,33 @@
</template> </template>
<script setup> <script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import BaseButton from '@/components/BaseButton.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup'
import useDebounce from '@/composables/useDebounce' import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
import { import {
adminSessionsKeys, adminSessionsKeys,
useAddAdminSessionMutation, useAddAdminSessionMutation,
useAdminSessionQuery, useAdminSessionQuery,
useUpdateAdminSessionMutation, useUpdateAdminSessionMutation,
} from '@/services/query/admin-sessions' } from '@/services/query/admin-sessions'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import { SESSION_PLATFORM, SESSION_TYPE } from '@/enums'
import { sessionSchema } from '@/features/admin/sessions/schema'
import { objectToFormData } from '@/utils/object-to-formdata'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -254,102 +202,152 @@ const queryClient = useQueryClient()
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null)) const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEditMode = computed(() => !!sessionId.value) const isEditMode = computed(() => !!sessionId.value)
const sessionTypeOptions = Object.entries(SESSION_TYPE).map(([value, label]) => ({ const sessionTypeOptions = [
value, { value: 'offline', label: SESSION_TYPE.offline },
label, { value: 'online', label: SESSION_TYPE.online },
})) ]
const platformOptions = Object.entries(SESSION_PLATFORM).map(([value, label]) => ({
value,
label,
}))
const emptySessionConfig = () => ({ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
meetingLink: '', value,
platform: '', label,
startTime: '', }))
location: '',
minWatchedPercent: '',
minReadPercent: '',
mustCompleteBeforeNext: false,
})
const form = ref({ const form = ref({
title: '', title: '',
courseTemplateId: '', startsAt: '',
sessionType: '', endsAt: '',
durationMinutes: '', durationMinutes: '',
order: '', courseId: '',
contentType: '',
type: '',
link: '',
description: '', description: '',
imageId: null,
sessionConfig: emptySessionConfig(),
}) })
const image = ref(null) const image = ref(null)
const materials = ref([]) const contentFiles = ref([])
const coverMediaId = ref(null)
const contentMediaId = ref(null)
const schema = sessionSchema const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const { validate, validateAt, errors } = useYup(schema) const { validate, validateAt, errors } = useYup(sessionSchema)
const templateSearch = ref('') const courseSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const courseFilters = computed(() => ({ title: courseSearch.value }))
const templatePagination = ref({ page: 1, perPage: 30 }) const coursePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
templateFilters, const selectedCourse = ref(null)
templatePagination const courseOptions = computed(() => {
) const base = coursesResponse.value?.data ?? []
const selectedTemplate = ref(null) if (selectedCourse.value && !base.some((c) => c.id === selectedCourse.value.id)) {
const templateOptions = computed(() => { return [...base, selectedCourse.value]
const base = templatesResponse.value?.data ?? []
if (selectedTemplate.value && !base.some((t) => t.id === selectedTemplate.value.id)) {
return [...base, selectedTemplate.value]
} }
return base return base
}) })
const searchTemplates = useDebounce((q) => { const searchCourses = useDebounce((q) => {
templateSearch.value = q || '' courseSearch.value = q || ''
}, 400) }, 400)
const onContentTypeChange = () => {
contentFiles.value = []
contentMediaId.value = null
}
const onSessionTypeChange = () => {
if (form.value.type !== 'online') form.value.link = ''
}
const { data: existingSession } = useAdminSessionQuery(sessionId, { const { data: existingSession } = useAdminSessionQuery(sessionId, {
enabled: () => !!sessionId.value, enabled: () => !!sessionId.value,
}) })
watch(existingSession, (session) => { watch(
if (!session) return existingSession,
if (session.courseTemplate) { (session) => {
selectedTemplate.value = session.courseTemplate if (!session) return
} if (session.course) selectedCourse.value = session.course
form.value = { const media = Array.isArray(session.media) ? session.media : []
title: session.title || '', const coverMedia = media.find((m) => m.collectionName === 'cover')
courseTemplateId: session.courseTemplate?.id || session.courseTemplateId || '', const contentMedia = media.find((m) => m.collectionName !== 'cover')
sessionType: session.sessionType || '', const contentType = collectionToContentType(contentMedia?.collectionName)
durationMinutes: session.durationMinutes ?? '', form.value = {
order: session.order ?? '', title: session.title ?? '',
description: session.description || '', startsAt: session.startsAt ?? '',
imageId: session.imageId || null, endsAt: session.endsAt ?? '',
sessionConfig: { ...emptySessionConfig(), ...session.sessionConfig }, durationMinutes: session.durationMinutes ?? '',
} courseId: session.course?.id ?? '',
if (session.image) image.value = { url: session.image } contentType,
if (Array.isArray(session.materials)) { type: session.type ?? '',
materials.value = session.materials.map((m) => ({ link: session.link ?? '',
id: m.id, description: session.description ?? '',
name: m.title || `فایل ${m.id}`, }
url: m.filePath || '', coverMediaId.value = coverMedia?.id ?? null
type: m.type, contentMediaId.value = contentMedia?.id ?? null
})) if (contentMedia) {
} contentFiles.value = [
}) {
id: contentMedia.id,
name: contentMedia.fileName ?? 'file',
size: contentMedia.fileSize ?? 0,
url: contentMedia.url,
},
]
}
if (coverMedia?.url) image.value = { url: coverMedia.url }
},
{ immediate: true }
)
const uploadMutation = useUploadTemporaryMutation() const uploadMutation = useUploadMediaMutation()
const purposeForContentType = (contentType) => {
if (contentType === 'video') return 'video'
if (contentType === 'voice') return 'voice'
return 'pdf'
}
const collectionToContentType = (collectionName) => {
if (collectionName === 'videos') return 'video'
if (collectionName === 'voices') return 'voice'
if (collectionName === 'pdf') return 'text'
return ''
}
const onContentSelect = async (files) => {
const file = files?.[0]
if (!file) return
try {
const formData = objectToFormData({
file,
purpose: purposeForContentType(form.value.contentType),
context: 'session',
})
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
contentMediaId.value = id
} catch {
contentFiles.value = []
}
}
const onContentRemove = () => {
contentFiles.value = []
contentMediaId.value = null
}
const onContentError = (msg) => toast.error(msg)
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'session' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
const response = await uploadMutation.mutateAsync(formData) const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload } image.value = { url: payload?.url, ...payload }
form.value.imageId = payload?.uploadId || payload?.id coverMediaId.value = payload?.id ?? null
} catch { } catch {
/* handled globally */ /* handled globally */
} }
@@ -357,46 +355,24 @@ const onImageCropped = async (file) => {
const onImageError = (msg) => toast.error(msg) const onImageError = (msg) => toast.error(msg)
const cleanSessionConfig = (config, type) => {
const result = {}
const allow = (key) => {
if (type === 'online') return ['meetingLink', 'platform', 'startTime'].includes(key)
if (type === 'in_person') return ['startTime', 'location'].includes(key)
if (['video', 'audio'].includes(type))
return ['minWatchedPercent', 'mustCompleteBeforeNext'].includes(key)
if (['text', 'slide', 'pdf'].includes(type))
return ['minReadPercent', 'mustCompleteBeforeNext'].includes(key)
return false
}
Object.entries(config || {}).forEach(([k, v]) => {
if (!allow(k)) return
if (v === '' || v === null || v === undefined) return
result[k] = v
})
return result
}
const buildPayload = (values) => { const buildPayload = (values) => {
const sessionConfig = cleanSessionConfig(values.sessionConfig, values.sessionType) const mediaIds = [coverMediaId.value, contentMediaId.value].filter((id) => id != null)
const payload = { const payload = {
title: values.title, title: values.title,
courseTemplateId: values.courseTemplateId, startsAt: values.startsAt,
sessionType: values.sessionType, endsAt: values.endsAt,
durationMinutes: values.durationMinutes, durationMinutes: values.durationMinutes,
order: values.order, courseId: values.courseId,
type: values.type,
link: values.type === 'online' ? values.link : undefined,
description: values.description, description: values.description,
imageId: values.imageId, mediaIds,
} }
if (Object.keys(sessionConfig).length > 0) payload.sessionConfig = sessionConfig
payload.materials = materials.value.map((m, index) => ({
fileId: m.id,
isRequired: false,
type: m.type,
title: m.name,
order: index + 1,
}))
Object.keys(payload).forEach((key) => { Object.keys(payload).forEach((key) => {
if (payload[key] === undefined || payload[key] === '') delete payload[key] const value = payload[key]
if (value === undefined || value === '' || value === null) {
delete payload[key]
}
}) })
return payload return payload
} }
@@ -436,6 +412,9 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
background: rgba(255, 255, 255, 60%); background: rgba(255, 255, 255, 60%);
padding: 1rem; padding: 1rem;
border-radius: 1rem; border-radius: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
} }
&__grid { &__grid {
@@ -463,6 +442,9 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
&__main-col { &__main-col {
order: 1; order: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 2; order: 2;
@@ -471,53 +453,71 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
} }
&__row { &__row {
display: flex; display: grid;
flex-flow: column wrap; grid-template-columns: 1fr;
gap: 0.5rem; gap: 0.5rem;
align-items: stretch;
margin-bottom: 0.5rem;
@media (min-width: 768px) { &--two {
flex-direction: row;
}
}
&__cell {
width: 100%;
&--third {
@media (min-width: 768px) { @media (min-width: 768px) {
width: 49%; grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1280px) {
width: 32.3%;
} }
} }
&--full { &--three {
width: 100%; @media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
} }
} }
&__toggle-cell { &__uploader-label {
display: flex; display: block;
margin-bottom: 0.5rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
}
&__preview {
place-items: center center;
}
&__media {
max-width: 100%;
border-radius: 0.75rem;
background: #000;
}
&__file-link {
display: inline-flex;
align-items: center; align-items: center;
} gap: 0.5rem;
color: var(--color-primary);
text-decoration: none;
font-family: var(--font-family-fa);
font-size: 0.875rem;
padding: 0.5rem 0;
&__materials { &:hover {
margin-top: 1.5rem; text-decoration: underline;
}
} }
&__divider { &__divider {
border-block-end: 1px solid var(--color-thd-gray); border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1.5rem; margin-block: 1rem;
} }
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
gap: 0.625rem; align-items: center;
} }
&__btn-cancel { &__btn-cancel {
@@ -47,42 +47,53 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import useModal from '@/composables/useModal'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SessionsFilters from '@/features/admin/sessions/components/SessionsFilters.vue' import { computed, onMounted, ref, watch } from 'vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SessionItem from '@/features/admin/sessions/components/SessionItem.vue' import SessionItem from '@/features/admin/sessions/components/SessionItem.vue'
import SessionAttendanceModal from '@/features/admin/sessions/components/modals/SessionAttendanceModal.vue' import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import SessionsFilters from '@/features/admin/sessions/components/SessionsFilters.vue'
import SessionDetailsModal from '@/features/admin/sessions/components/modals/SessionDetailsModal.vue' import SessionDetailsModal from '@/features/admin/sessions/components/modals/SessionDetailsModal.vue'
import SessionAttendanceModal from '@/features/admin/sessions/components/modals/SessionAttendanceModal.vue'
import { import {
adminSessionsKeys, adminSessionsKeys,
useAdminSessionsListQuery, useAdminSessionsListQuery,
useDeleteAdminSessionMutation, useDeleteAdminSessionMutation,
} from '@/services/query/admin-sessions' } from '@/services/query/admin-sessions'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal'
const route = useRoute()
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const routeCourseId = computed(() => (route.params.courseId ? Number(route.params.courseId) : null))
const filters = ref({ const filters = ref({
title: '', title: '',
courseTemplateId: '', courseId: routeCourseId.value ?? '',
sessionType: '', sessionType: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
}) })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 }) const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const syncRouteCourseId = (courseId) => {
if (!courseId) return
filters.value = { ...filters.value, courseId }
resetPagination()
}
onMounted(() => syncRouteCourseId(routeCourseId.value))
watch(routeCourseId, (val) => syncRouteCourseId(val))
const { data, isLoading } = useAdminSessionsListQuery(filters, pagination, { const { data, isLoading } = useAdminSessionsListQuery(filters, pagination, {
keepPreviousData: true, keepPreviousData: true,
}) })
+10 -4
View File
@@ -1,10 +1,16 @@
import { number, object, string } from 'yup' import { number, object, string } from 'yup'
export const sessionSchema = object().shape({ export const sessionSchema = object().shape({
courseTemplateId: string().required(),
title: string().required().min(3), title: string().required().min(3),
sessionType: string().required(), startsAt: string().required(),
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(), endsAt: string().nullable().notRequired(),
order: number().typeError('ترتیب باید عدد باشد').nullable().notRequired(), durationMinutes: number().typeError('مدت زمان باید عدد باشد').required().min(1),
courseId: string().required(),
type: string().oneOf(['offline', 'online']).required(),
link: string().when('sessionType', {
is: 'online',
then: (schema) => schema.required(),
otherwise: (schema) => schema.nullable().notRequired(),
}),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
}) })
@@ -0,0 +1,229 @@
<template>
<div class="course-item">
<div class="course-item__main">
<div v-if="course.image" class="course-item__image">
<img :src="course.image" :alt="course.title" />
</div>
<div v-else class="course-item__image course-item__image--placeholder">
<SvgIcon name="book" :size="22" color="#bcbcbc" />
</div>
<div class="course-item__title-block">
<p class="course-item__title">
<span>دوره</span>
<strong>{{ course.title }}</strong>
</p>
<div class="course-item__teacher">
<SvgIcon name="user" :size="11" color="#bcbcbc" />
<span class="course-item__teacher-label">استاد:</span>
<span class="course-item__teacher-name">{{ teacherName }}</span>
</div>
</div>
</div>
<div class="course-item__meta">
<Badge
variant="cyan"
size="sm"
icon="calendar"
:label="`وضعیت دوره :`"
:value="`در حال گذراندن`"
/>
</div>
<div v-if="!isActive" class="course-item__status">
<span class="course-item__status-badge">
<span class="course-item__status-dot" />
غیرفعال
</span>
</div>
<div class="course-item__actions">
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('delete', course)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
course: { type: Object, required: true },
})
const emit = defineEmits(['delete'])
const teacherName = computed(() => {
const t = props.course.teacher
if (!t) return '—'
return t.name
})
const isActive = computed(() => props.course.isActive ?? false)
</script>
<style lang="scss" scoped>
.course-item {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 50%);
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 90%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
@media (min-width: 1280px) {
flex-flow: row wrap;
align-items: center;
}
&__main {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1 1 33%;
min-width: 0;
}
&__image {
width: 3rem;
height: 3rem;
min-width: 3rem;
border-radius: 0.375rem;
border: 1px solid #eee;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--placeholder {
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
}
&__title-block {
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
display: flex;
gap: 0.25rem;
strong {
font-weight: 700;
}
}
&__teacher {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.25rem;
}
&__teacher-label {
font-family: var(--font-family-fa);
font-size: 0.65rem;
color: #838383;
}
&__teacher-name {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #4b4b4b;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
flex: 1 1 33%;
justify-content: flex-start;
}
&__pill {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
}
&__status {
flex: 0 0 auto;
}
&__status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 1rem;
border-radius: 0.875rem;
background: rgba(204, 40, 49, 6%);
color: var(--color-error);
font-family: var(--font-family-fa);
font-size: 0.7rem;
}
&__status-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 9999px;
background: currentcolor;
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
flex: 1 1 100%;
@media (min-width: 1280px) {
flex: 1 1 auto;
}
}
&__details-btn {
min-width: 8rem;
padding: 0 0.875rem;
}
}
</style>
@@ -1,8 +1,8 @@
<template> <template>
<div class="term-item"> <div class="term-item">
<div class="term-item__main"> <div class="term-item__main">
<div v-if="term.image" class="term-item__image"> <div v-if="term.coverUrl" class="term-item__image">
<img :src="term.image" :alt="term.title" /> <img :src="term.coverUrl" :alt="term.title" />
</div> </div>
<div v-else class="term-item__image term-item__image--placeholder"> <div v-else class="term-item__image term-item__image--placeholder">
<SvgIcon name="book" :size="24" color="#bcbcbc" /> <SvgIcon name="book" :size="24" color="#bcbcbc" />
@@ -43,16 +43,6 @@
<SvgIcon name="trash" :size="18" color="var(--color-error)" /> <SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton
tooltip="کپی"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('clone', term)"
>
<template #icon>
<SvgIcon name="copy" :size="18" color="var(--color-sec-gray)" />
</template>
</CircleButton>
<CircleButton <CircleButton
tooltip="ویرایش" tooltip="ویرایش"
bg-color="rgba(104, 104, 104, 0.05)" bg-color="rgba(104, 104, 104, 0.05)"
@@ -78,12 +68,11 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
const props = defineProps({ const props = defineProps({
term: { type: Object, required: true }, term: { type: Object, required: true },
@@ -91,10 +80,8 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details']) const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details'])
const startDate = computed( const startDate = computed(() => formatJalaaliDate(props.term.startsAt) || '')
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || '' const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
)
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -28,7 +28,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -47,12 +47,11 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -1,9 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="42rem" min-width="auto" :show-close-button="true"> <BasicModal
title="افزودن دانشجو"
title-en="Add Student"
width="95%"
max-width="42rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }"> <template #default="{ close }">
<div class="add-term-student"> <div class="add-term-student">
<LineTitleBlock title="افزودن دانشجو" title-en="Add Student" />
<div class="add-term-student__search"> <div class="add-term-student__search">
<SvgIcon name="user" :size="18" color="var(--color-thd-gray)" /> <SvgIcon name="user" :size="18" color="var(--color-thd-gray)" />
<input <input
@@ -37,20 +42,6 @@
</div> </div>
<CircleButton <CircleButton
v-if="isAttached(user.id)"
tooltip="حذف از ترم"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
type="button"
:loading="pendingId === user.id"
@click="onDetach(user)"
>
<template #icon>
<SvgIcon name="trash" :size="16" color="var(--color-error)" />
</template>
</CircleButton>
<CircleButton
v-else
tooltip="افزودن به ترم" tooltip="افزودن به ترم"
bg-color="rgba(104, 104, 104, 0.05)" bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem" size="2.25rem"
@@ -83,24 +74,17 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import useDebounce from '@/composables/useDebounce' import useDebounce from '@/composables/useDebounce'
import { import { useQueryClient } from '@tanstack/vue-query'
adminTermsKeys, import BasicModal from '@/components/BasicModal.vue'
useAddAdminTermStudentsMutation, import BaseButton from '@/components/BaseButton.vue'
useAdminTermStudentsQuery, import SvgIcon from '@/components/icons/SvgIcon.vue'
useRemoveAdminTermStudentMutation, import NoItems from '@/components/blocks/NoItems.vue'
} from '@/services/query/admin-terms' import CircleButton from '@/components/CircleButton.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users' import { useAdminUsersListQuery } from '@/services/query/admin-users'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { adminTermsKeys, useAddAdminTermStudentsMutation } from '@/services/query/admin-terms'
defineOptions({ name: 'AddTermStudentModal' }) defineOptions({ name: 'AddTermStudentModal' })
@@ -112,29 +96,13 @@ const termId = computed(() => modalData.value.termId ?? null)
const searchInput = ref('') const searchInput = ref('')
const searchQuery = ref('') const searchQuery = ref('')
const userFilters = computed(() => ({ name: searchQuery.value })) const userFilters = computed(() => ({ search: searchQuery.value }))
const userPagination = ref({ page: 1, perPage: 20 }) const userPagination = ref({ page: 1, perPage: 10 })
const { data: usersResponse, isLoading } = useAdminUsersListQuery(userFilters, userPagination) const { data: usersResponse, isLoading } = useAdminUsersListQuery(userFilters, userPagination)
const users = computed(() => usersResponse.value?.data ?? []) const users = computed(() => usersResponse.value?.data ?? [])
const attachedFilters = computed(() => ({})) const userLabel = (user) => user.name.trim() || '—'
const attachedPagination = ref({ page: 1, perPage: 200 })
const { data: attachedResponse } = useAdminTermStudentsQuery(
termId,
attachedFilters,
attachedPagination,
{ enabled: () => !!termId.value }
)
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((u) => u.id)))
const isAttached = (id) => attachedIds.value.has(id)
const userLabel = (user) =>
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.fullName ||
user.phoneNumber ||
'—'
const onSearchInput = useDebounce(() => { const onSearchInput = useDebounce(() => {
searchQuery.value = searchInput.value || '' searchQuery.value = searchInput.value || ''
@@ -144,7 +112,6 @@ const onSearchInput = useDebounce(() => {
const pendingId = ref(null) const pendingId = ref(null)
const addMutation = useAddAdminTermStudentsMutation() const addMutation = useAddAdminTermStudentsMutation()
const removeMutation = useRemoveAdminTermStudentMutation()
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all }) const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
@@ -154,7 +121,7 @@ const onAttach = async (user) => {
try { try {
await addMutation.mutateAsync({ await addMutation.mutateAsync({
termId: termId.value, termId: termId.value,
payload: { userIds: [user.id] }, payload: { userId: user.id },
}) })
invalidate() invalidate()
} finally { } finally {
@@ -162,17 +129,6 @@ const onAttach = async (user) => {
} }
} }
const onDetach = async (user) => {
if (!termId.value) return
pendingId.value = user.id
try {
await removeMutation.mutateAsync({ termId: termId.value, userId: user.id })
invalidate()
} finally {
pendingId.value = null
}
}
watch(termId, () => { watch(termId, () => {
searchInput.value = '' searchInput.value = ''
searchQuery.value = '' searchQuery.value = ''
@@ -294,7 +250,7 @@ watch(termId, () => {
&__footer { &__footer {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-end;
} }
&__close-btn { &__close-btn {
@@ -86,25 +86,25 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import useDebounce from '@/composables/useDebounce' import useDebounce from '@/composables/useDebounce'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import CircleButton from '@/components/CircleButton.vue'
import { adminTermsKeys } from '@/services/query/admin-terms'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { import {
adminCoursesKeys, adminCoursesKeys,
useAddAdminCourseMutation,
useAdminCoursesListQuery, useAdminCoursesListQuery,
useDeleteAdminCourseMutation, useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses' } from '@/services/query/admin-courses'
import { adminTermsKeys } from '@/services/query/admin-terms'
// TODO: repurpose this modal as "copy course from another term" the original
// flow (link a stand-alone template to a term) no longer matches the unified
// course model. Until then, this stays a stand-alone-course new-offered-course copy.
defineOptions({ name: 'AttachCourseToTermModal' }) defineOptions({ name: 'AttachCourseToTermModal' })
@@ -118,7 +118,7 @@ const searchQuery = ref('')
const templateFilters = computed(() => ({ title: searchQuery.value })) const templateFilters = computed(() => ({ title: searchQuery.value }))
const templatePagination = ref({ page: 1, perPage: 20 }) const templatePagination = ref({ page: 1, perPage: 20 })
const { data: templatesResponse, isLoading } = useAdminCourseTemplatesListQuery( const { data: templatesResponse, isLoading } = useAdminCoursesListQuery(
templateFilters, templateFilters,
templatePagination templatePagination
) )
@@ -126,18 +126,24 @@ const { data: templatesResponse, isLoading } = useAdminCourseTemplatesListQuery(
const templates = computed(() => templatesResponse.value?.data ?? []) const templates = computed(() => templatesResponse.value?.data ?? [])
const courseFilters = computed(() => ({ termId: termId.value })) const courseFilters = computed(() => ({ termId: termId.value }))
const coursePagination = ref({ page: 1, perPage: 100 }) const coursePagination = ref({ page: 1, perPage: 10 })
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination, { const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination, {
enabled: () => !!termId.value, enabled: () => !!termId.value,
}) })
const attachedCourses = computed(() => coursesResponse.value?.data ?? []) const attachedCourses = computed(() => coursesResponse.value?.data ?? [])
const attachedCourseByTemplate = (templateId) => // Tracks which stand-alone course (termId=null) has already been copied
attachedCourses.value.find((c) => c.template?.id === templateId || c.templateId === templateId) // into this term. We match by title since the unified model no longer
// keeps a back-reference to the source course.
const attachedCourseByTemplate = (sourceId) => {
const source = templates.value.find((t) => t.id === sourceId)
if (!source) return null
return attachedCourses.value.find((c) => c.title === source.title)
}
const teacherName = (template) => { const teacherName = (course) => {
const t = template.defaultTeacher || template.teacher const t = course.teacher
if (!t) return '—' if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—' return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
} }
@@ -149,8 +155,7 @@ const onSearch = useDebounce((event) => {
const pendingId = ref(null) const pendingId = ref(null)
const addMutation = useAddAdminCourseMutation() const updateMutation = useUpdateAdminCourseMutation()
const deleteMutation = useDeleteAdminCourseMutation()
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all }) queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
@@ -158,16 +163,12 @@ const invalidate = () => {
} }
const onAttach = async (template) => { const onAttach = async (template) => {
console.log(template)
if (!termId.value) return if (!termId.value) return
pendingId.value = template.id pendingId.value = template.id
try { try {
await addMutation.mutateAsync({ await updateMutation.mutateAsync({ id: template.id, payload: { termId: termId.value } })
termId: termId.value,
templateId: template.id,
title: template.title,
capacity: template.defaultCapacity ?? null,
isActive: template.isActiveByDefault ?? true,
})
invalidate() invalidate()
} finally { } finally {
pendingId.value = null pendingId.value = null
@@ -179,7 +180,7 @@ const onDetach = async (template) => {
if (!offered) return if (!offered) return
pendingId.value = template.id pendingId.value = template.id
try { try {
await deleteMutation.mutateAsync(offered.id) await updateMutation.mutateAsync({ id: template.id, payload: { termId: null } })
invalidate() invalidate()
} finally { } finally {
pendingId.value = null pendingId.value = null
@@ -305,7 +306,7 @@ watch(termId, () => {
&__footer { &__footer {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-end;
} }
&__close-btn { &__close-btn {
@@ -1,26 +1,32 @@
<template> <template>
<BasicModal width="95%" max-width="80rem" min-width="auto" :show-close-button="true"> <BasicModal
<template #default="{ data, close }"> title="جزئیات ترم"
title-en="Term Details"
width="95%"
max-width="80rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div v-if="!term" class="term-details"> <div v-if="!term" class="term-details">
<p class="term-details__loading">در حال بارگذاری</p> <p class="term-details__loading">در حال بارگذاری</p>
</div> </div>
<div v-else class="term-details"> <div v-else class="term-details">
<section class="term-details__section"> <section class="term-details__section">
<LineTitleBlock title="جزئیات ترم" title-en="Term Details" />
<div class="term-details__overview"> <div class="term-details__overview">
<div class="term-details__image"> <div class="term-details__image">
<img v-if="term.image" :src="term.image" :alt="term.title" /> <img v-if="term.coverUrl" :src="term.coverUrl" :alt="term.title" />
<SvgIcon v-else name="book" :size="32" color="#bcbcbc" /> <SvgIcon v-else name="book" :size="32" color="#bcbcbc" />
</div> </div>
<div class="term-details__overview-grid"> <div class="term-details__overview-grid">
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" /> <LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
<LineInfoBlock <LineInfoBlock
title="تاریخ شروع" title="تاریخ شروع"
:numeric-desc="term.faStartDate || formatJalaaliDate(term.startDate) || '-'" :numeric-desc="formatJalaaliDate(term.startsAt) || '-'"
/> />
<LineInfoBlock <LineInfoBlock
title="تاریخ پایان" title="تاریخ پایان"
:numeric-desc="term.faEndDate || formatJalaaliDate(term.endDate) || '-'" :numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
/> />
<LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" /> <LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" />
<LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" /> <LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" />
@@ -51,37 +57,23 @@
<div v-else-if="students.length > 0"> <div v-else-if="students.length > 0">
<div v-for="student in students" :key="student.id" class="term-details__student-row"> <div v-for="student in students" :key="student.id" class="term-details__student-row">
<div class="term-details__student-main"> <div class="term-details__student-main">
<div v-if="student.avatarUrl" class="term-details__avatar"> <div v-if="studentAvatar(student?.user)" class="term-details__avatar">
<img :src="student.avatarUrl" :alt="studentName(student)" /> <img :src="studentAvatar(student?.user)" :alt="student.name" />
</div> </div>
<div v-else class="term-details__avatar term-details__avatar--placeholder"> <div v-else class="term-details__avatar term-details__avatar--placeholder">
<SvgIcon name="user" :size="20" color="#bcbcbc" /> <SvgIcon name="user" :size="20" color="#bcbcbc" />
</div> </div>
<div> <div>
<p class="term-details__student-name">{{ studentName(student) }}</p> <p class="term-details__student-name">{{ student?.user?.name || '—' }}</p>
<p class="term-details__student-meta"> <p class="term-details__student-meta">
<span>{{ student.address?.province?.name || '—' }}</span> <span dir="ltr">{{ student?.user.phone || '—' }}</span>
<span class="term-details__sep">،</span> <template v-if="student?.user.email">
<span>{{ student.address?.city?.name || '—' }}</span> <span class="term-details__sep">،</span>
<span dir="ltr">{{ student?.user.email }}</span>
</template>
</p> </p>
</div> </div>
</div> </div>
<div class="term-details__student-actions">
<ToggleSwitch
:model-value="!!student.isOnLeave"
@update:model-value="onToggleLeave(student, $event)"
/>
<CircleButton
tooltip="حذف از ترم"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="onAskRemoveStudent(student)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div> </div>
</div> </div>
<NoItems v-else title="متاسفیم" desc="دانشجویی برای نمایش وجود ندارد." /> <NoItems v-else title="متاسفیم" desc="دانشجویی برای نمایش وجود ندارد." />
@@ -109,10 +101,7 @@
v-for="course in termCourses" v-for="course in termCourses"
:key="course.id" :key="course.id"
:course="course" :course="course"
@edit="onEditCourse" @delete="(course) => onAskDeleteCourse(course)"
@delete="onAskDeleteCourse"
@change-status="onChangeCourseStatus"
@show-details="onShowCourseDetails"
/> />
</div> </div>
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." /> <NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." />
@@ -131,43 +120,32 @@
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
<span class="term-details__data-tap" v-if="false">{{ data }}</span>
</template> </template>
</BasicModal> </BasicModal>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import CourseItem from '../CourseItem.vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue' import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue' import { useAdminTermEnrolleesQuery, useAdminTermQuery } from '@/services/query/admin-terms'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal'
import { usePagination } from '@/composables/usePagination'
import CourseItem from '@/features/admin/courses/components/CourseItem.vue'
import {
adminTermsKeys,
useAdminTermQuery,
useAdminTermStudentsQuery,
useRemoveAdminTermStudentMutation,
useToggleAdminTermStudentLeaveMutation,
} from '@/services/query/admin-terms'
import { import {
adminCoursesKeys, adminCoursesKeys,
useAdminCoursesListQuery, useAdminCoursesListQuery,
useChangeAdminCourseStatusMutation, useUpdateAdminCourseMutation,
useDeleteAdminCourseMutation,
} from '@/services/query/admin-courses' } from '@/services/query/admin-courses'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'TermDetailsModal' }) defineOptions({ name: 'TermDetailsModal' })
@@ -182,10 +160,10 @@ const { data: term } = useAdminTermQuery(termId, {
}) })
const tabs = [ const tabs = [
{ name: 'students', label: 'دانشجویان', icon: 'users' },
{ name: 'courses', label: 'دوره‌ها', icon: 'list-bullets' }, { name: 'courses', label: 'دوره‌ها', icon: 'list-bullets' },
{ name: 'students', label: 'دانشجویان', icon: 'users' },
] ]
const activeTab = ref('students') const activeTab = ref('courses')
const studentFilters = ref({}) const studentFilters = ref({})
const { const {
@@ -194,7 +172,7 @@ const {
reset: resetStudentPagination, reset: resetStudentPagination,
} = usePagination({ page: 1, perPage: 10 }) } = usePagination({ page: 1, perPage: 10 })
const { data: studentsData, isLoading: studentsPending } = useAdminTermStudentsQuery( const { data: studentsData, isLoading: studentsPending } = useAdminTermEnrolleesQuery(
termId, termId,
studentFilters, studentFilters,
studentPagination, studentPagination,
@@ -211,36 +189,7 @@ const studentPaginationMeta = computed(() => ({
...studentsData.value?.meta, ...studentsData.value?.meta,
})) }))
const studentName = (student) => const studentAvatar = (student) => student.avatarUrl || student.avatarDownloadUrl || ''
`${student.firstName || ''} ${student.lastName || ''}`.trim() || student.fullName || '—'
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
const toggleLeaveMutation = useToggleAdminTermStudentLeaveMutation()
const removeStudentMutation = useRemoveAdminTermStudentMutation()
const onToggleLeave = (student, isOnLeave) => {
toggleLeaveMutation.mutate(
{
termId: termId.value,
userId: student.id,
payload: { setLeave: isOnLeave },
},
{ onSuccess: invalidate }
)
}
const onAskRemoveStudent = (student) => {
openModal('ConfirmModal', {
title: `حذف ${studentName(student)}`,
message: `آیا از حذف <strong>${studentName(student)}</strong> از این ترم اطمینان دارید؟`,
onConfirm: () =>
removeStudentMutation.mutate(
{ termId: termId.value, userId: student.id },
{ onSuccess: invalidate }
),
})
}
const courseFilters = computed(() => ({ termId: termId.value })) const courseFilters = computed(() => ({ termId: termId.value }))
const { pagination: coursePagination, setPage: setCoursePage } = usePagination({ const { pagination: coursePagination, setPage: setCoursePage } = usePagination({
@@ -266,31 +215,18 @@ const coursePaginationMeta = computed(() => ({
const invalidateCourses = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all }) const invalidateCourses = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const deleteCourseMutation = useDeleteAdminCourseMutation() // Backend has no dedicated status endpoint PATCH /courses/:id with { isActive }.
const changeCourseStatusMutation = useChangeAdminCourseStatusMutation() const updateCourseMutation = useUpdateAdminCourseMutation()
const onOpenAddCourse = () => { const onOpenAddCourse = () => {
openModal('AttachCourseToTermModal', { termId: termId.value }) openModal('AttachCourseToTermModal', { termId: termId.value })
} }
const onEditCourse = (course) => {
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
}
const onShowCourseDetails = (course) => {
openModal('CourseDetailsModal', { id: course.id })
}
const onAskDeleteCourse = (course) => { const onAskDeleteCourse = (course) => {
openModal('ConfirmModal', { updateCourseMutation.mutate(
title: `حذف ${course.title}`, { id: course.id, payload: { termId: null } },
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا از حذف <strong>${course.title}</strong> اطمینان دارید؟`, { onSuccess: invalidateCourses }
onConfirm: () => deleteCourseMutation.mutate(course.id, { onSuccess: invalidateCourses }), )
})
}
const onChangeCourseStatus = ({ id, isActive }) => {
changeCourseStatusMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidateCourses })
} }
const onOpenAddStudent = () => { const onOpenAddStudent = () => {
@@ -353,7 +289,7 @@ const onOpenAddStudent = () => {
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
} }
+41 -37
View File
@@ -42,20 +42,20 @@
</div> </div>
<div class="term-form__cell term-form__cell--third"> <div class="term-form__cell term-form__cell--third">
<DatePickerField <DatePickerField
v-model="form.startDate" v-model="form.startsAt"
name="startDate" name="startsAt"
label="تاریخ شروع" label="تاریخ شروع"
:min="todayIso" :min="todayIso"
:error="errors.startDate" :error="errors.startsAt"
/> />
</div> </div>
<div class="term-form__cell term-form__cell--third"> <div class="term-form__cell term-form__cell--third">
<DatePickerField <DatePickerField
v-model="form.endDate" v-model="form.endsAt"
name="endDate" name="endsAt"
label="تاریخ پایان" label="تاریخ پایان"
:min="todayIso" :min="todayIso"
:error="errors.endDate" :error="errors.endsAt"
/> />
</div> </div>
<div class="term-form__cell term-form__cell--full"> <div class="term-form__cell term-form__cell--full">
@@ -100,28 +100,28 @@
</template> </template>
<script setup> <script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import { termSchema } from '@/features/admin/terms/schema'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import ImageCropper from '@/components/form/ImageCropper.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useYup from '@/composables/useYup'
import { import {
adminTermsKeys, adminTermsKeys,
useAddAdminTermMutation, useAddAdminTermMutation,
useAdminTermQuery, useAdminTermQuery,
useUpdateAdminTermMutation, useUpdateAdminTermMutation,
} from '@/services/query/admin-terms' } from '@/services/query/admin-terms'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { termSchema } from '@/features/admin/terms/schema'
import { objectToFormData } from '@/utils/object-to-formdata'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -134,43 +134,47 @@ const todayIso = new Date().toISOString()
const form = ref({ const form = ref({
title: '', title: '',
startDate: '',
endDate: '',
description: '', description: '',
imageId: null, isActive: true,
startsAt: '',
endsAt: '',
coverMediaId: null,
}) })
const image = ref(null) const image = ref(null)
const schema = termSchema const { validate, validateAt, errors } = useYup(termSchema)
const { validate, validateAt, errors } = useYup(schema)
const { data: existingTerm } = useAdminTermQuery(termId, { const { data: existingTerm } = useAdminTermQuery(termId, {
enabled: () => !!termId.value, enabled: () => !!termId.value,
}) })
watch(existingTerm, (term) => { watch(
if (!term) return existingTerm,
form.value = { (term) => {
title: term.title || '', console.log(term)
startDate: term.startDate || '', if (!term) return
endDate: term.endDate || '', form.value = {
description: term.description || '', title: term.title || '',
imageId: term.imageId || null, description: term.description || '',
} isActive: term.isActive ?? true,
if (term.image) image.value = { url: term.image } startsAt: term.startsAt || '',
}) endsAt: term.endsAt || '',
}
if (term.coverUrl) image.value = { url: term.coverUrl }
},
{ immediate: true }
)
const uploadMutation = useUploadTemporaryMutation() const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'term' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'term' })
const response = await uploadMutation.mutateAsync(formData) const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload } image.value = { url: payload?.url, ...payload }
form.value.imageId = payload?.uploadId || payload?.id form.value.coverMediaId = payload?.id
} catch { } catch {
/* handled globally */ /* handled globally */
} }
@@ -191,7 +195,7 @@ const onSubmit = async () => {
} else { } else {
await addMutation.mutateAsync(payload) await addMutation.mutateAsync(payload)
} }
await queryClient.invalidateQueries({ queryKey: adminTermsKeys.all }) await queryClient.resetQueries({ queryKey: adminTermsKeys.all })
router.push({ name: 'admin-terms' }) router.push({ name: 'admin-terms' })
} }
@@ -53,31 +53,29 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TermsFilters from '@/features/admin/terms/components/TermsFilters.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import TermItem from '@/features/admin/terms/components/TermItem.vue' import TermItem from '@/features/admin/terms/components/TermItem.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import TermsFilters from '@/features/admin/terms/components/TermsFilters.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import TermDetailsModal from '@/features/admin/terms/components/modals/TermDetailsModal.vue' import TermDetailsModal from '@/features/admin/terms/components/modals/TermDetailsModal.vue'
import AddTermStudentModal from '@/features/admin/terms/components/modals/AddTermStudentModal.vue' import AddTermStudentModal from '@/features/admin/terms/components/modals/AddTermStudentModal.vue'
import AttachCourseToTermModal from '@/features/admin/terms/components/modals/AttachCourseToTermModal.vue'
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue' import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue'
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
import AttachCourseToTermModal from '@/features/admin/terms/components/modals/AttachCourseToTermModal.vue'
import { import {
adminTermsKeys, adminTermsKeys,
useAdminTermsListQuery, useAdminTermsListQuery,
useChangeAdminTermStatusMutation,
useCloneAdminTermMutation,
useDeleteAdminTermMutation, useDeleteAdminTermMutation,
useUpdateAdminTermMutation,
} from '@/services/query/admin-terms' } from '@/services/query/admin-terms'
import { usePagination } from '@/composables/usePagination'
import useModal from '@/composables/useModal'
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -111,8 +109,8 @@ const onEdit = (term) => {
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all }) const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
const deleteMutation = useDeleteAdminTermMutation() const deleteMutation = useDeleteAdminTermMutation()
const cloneMutation = useCloneAdminTermMutation() // Backend has no dedicated status endpoint PATCH /terms/:id with { isActive }.
const changeStatusMutation = useChangeAdminTermStatusMutation() const updateMutation = useUpdateAdminTermMutation()
const onAskDelete = (term) => { const onAskDelete = (term) => {
openModal('ConfirmModal', { openModal('ConfirmModal', {
@@ -122,12 +120,8 @@ const onAskDelete = (term) => {
}) })
} }
const onClone = (term) => {
cloneMutation.mutate(term.id, { onSuccess: invalidate })
}
const onChangeStatus = ({ id, isActive }) => { const onChangeStatus = ({ id, isActive }) => {
changeStatusMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate }) updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
} }
const onShowDetails = (term) => { const onShowDetails = (term) => {

Some files were not shown because too many files have changed in this diff Show More