36 Commits

Author SHA1 Message Date
madresebanoo fe8ce68b15 feat(admin): add certificates page to admin sidebar and router
- AdminLayout: add گواهینامه‌ها menu item
- admin router: register /certificates route → AdminCertificatesPage
- endpoints: add getAdminCertificates, addAdminCertificate, deleteAdminCertificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:37:41 +04:30
madresebanoo f5620a86f9 feat(certificates): admin certificate management UI
Admin can view, issue, and delete certificates. Student certificate
query now normalises the response shape (works with both paginated
and non-paginated API responses).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:20 +04:30
madresebanoo 72d92c24d5 feat(admin/users): support multiple roles in user table and form
UsersTable now shows all role labels joined (not just the first role),
and its CSS badge class is based on the primary role. UserFormPage
uses a multi-select for roles instead of a single-select, and the
form validation requires at least one role. TermItem layout fix for
inactive badge alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:04 +04:30
madresebanoo 97adb2923e fix: branding, 401 logout, and blocked-user message on register page
- Copyright.vue / AuthCopyright.vue: change link from mugweb.ir to madresebanoo.ir
- SelectField.vue: add min-width:0 to prevent flex overflow in narrow layouts
- useRedirectAfterLogin: check roles array (multi-role support) instead of single role
- StudentRegisterPage: show block reason when user's status is blocked
- http.js: only clear session on 401 if a token already existed (login
  form 401 should not trigger logout); clear via authStore so WordPress
  cookie is also cleaned up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:15:46 +04:30
madresebanoo c17dbe2ae9 fix(courses): normalize "my courses" list response and cover image
The list query returned the raw paginated payload unmapped, so
current/ended filtering and pagination metadata didn't work; align it
with the same coverUrl fallback the detail query already used.
2026-09-02 15:04:31 +04:30
madresebanoo fb629a54a5 feat(users): add user evaluation panel with PDF export
Shows the admin a summary of a user's sessions viewed, exam/homework
performance, and current term status, with a PDF export action.
2026-09-02 15:03:55 +04:30
madresebanoo 2b3e8ee322 feat(services): add Service Types management UI
Admin CRUD page for service types, plus wiring the student "request
service" form's category select to the real list instead of a
hardcoded pair of options.
2026-09-02 15:03:15 +04:30
madresebanoo a8b24ca46e fix(courses): course cover image not saved on create/edit
The admin course form sent a stray upload id (imageId/uploadId) that
didn't match any field the API accepts; the API expects cover_media_id
with the media id returned from the upload endpoint. Also switch course
displays to the coverUrl field the API actually returns.
2026-09-02 15:02:02 +04:30
madresebanoo 0bd7c44c6c Merge branch 'master' of https://git.madresebanoo.ir/front-end/banu-front
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-09-02 14:59:50 +04:30
madresebanoo 9064de4c7d fix: support onUploadProgress in useUploadMediaMutation
SessionFormPage now passes { formData, onUploadProgress } to mutateAsync
for progress tracking. Update uploadMediaMutationFn to destructure the
object and forward onUploadProgress to apiUploadMedia; update apiUploadMedia
to accept and pass the callback to axios.

[batch-1-hotfix]
2026-09-02 14:19:51 +04:30
madresebanoo ab99daeec1 Merge pull request 'feat(batch-1): multi-role sidebar, video display, certificate layout, upload progress, counselor access' (#1) from deploy/batch-1-low-risk into master
Reviewed-on: #1
2026-09-02 08:06:21 +00:00
madresebanoo d63ab86e70 feat(batch-1): multi-role sidebar, video display, certificate layout, upload progress, counselor access
- roleMenuItems.js: merged menu builder that unions items from all user roles
  (student + missionary, student + counselor, etc.) additively
- auth store: expose `roles` array in addition to singular `role`; wire in
  WordPress auth-status cookie sync so the public site can show/hide the
  account button
- router guards: use roles.includes() instead of role === for multi-role access
- layouts (Student/Counselor/Missionary): delegate menuItems to
  buildMergedMenuItems so dual-role users see sections from both roles
- StudentSessionDetailsPage: contentKind now returns 'video' (not 'link')
  when session.videoUrl is set; adds embed iframe branch for online sessions
- CertificateItem.vue: fix CSS order/alignment for desktop + mobile layout
- StudentCertificatesPage.vue: map backend payload fields to component props
  with Jalaali date formatting
- SessionFormPage: upload progress bar (%) during video uploads; embed_code
  textarea for online sessions (frontend-only; dormant until batch-2 backend
  migration is deployed)
- wp-sync/auth-status-cookie.js: cookie helper for WordPress login-state sync

[batch-1-low-risk]
2026-09-02 11:17:20 +04:30
sajjadtalkhabi 81332abdb0 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m47s
2026-08-18 09:08:45 +03:30
sajjadtalkhabi e4a0eb463b Merge branch 'master' of ssh://git.madresebanoo.ir:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 2m0s
2026-08-17 16:59:27 +03:30
sajjadtalkhabi 20551fee63 fix: bugs 2026-08-17 16:59:24 +03:30
Server Migration 612a6e3dfe Merge remote-tracking branch 'origin/master'
Deploy banu-front / deploy (push) Successful in 2m42s
2026-08-16 17:07:32 +03:30
Server Migration 861773b05b fix: make admin media uploads reliable 2026-08-16 17:03:36 +03:30
sajjadtalkhabi b2f2f89181 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m26s
2026-08-15 19:42:33 +03:30
Server Migration b64f528c0d fix: complete registration frontend flow
Deploy banu-front / deploy (push) Successful in 1m40s
2026-08-15 17:28:40 +03:30
Server Migration d94a96b25e fix: resolve education dashboard interface issues 2026-08-15 13:06:49 +03:30
sajjadtalkhabi af0149d722 fix: media recorder
Deploy banu-front / deploy (push) Successful in 1m45s
2026-08-15 13:01:18 +03:30
sajjadtalkhabi fec1da6ec8 fix: localize Yup validation messages
Deploy banu-front / deploy (push) Successful in 1m50s
2026-08-14 21:08:52 +03:30
Server Migration fe08fc46fb fix: bind session audio URL correctly
Deploy banu-front / deploy (push) Successful in 1m20s
2026-08-04 18:45:39 +00:00
Server Migration 3d005859ab fix: remove old-server frontend references
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:43:12 +00:00
Server Migration f13eb9bbb4 build: use reliable npm registry
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:38:11 +00:00
Server Migration fb0e4aa7f4 chore: bind frontend deployment to loopback
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:43 +00:00
Server Migration 94ac69d96a chore: migrate deployment to madresebanoo.ir
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:19 +00:00
sajjadtalkhabi 39b295037e fix: bugs
Deploy banu-front / deploy (push) Successful in 2m18s
2026-08-01 10:34:21 +03:30
sajjadtalkhabi 31d100861b fix: bugs
Deploy banu-front / deploy (push) Failing after 23s
2026-07-30 18:56:55 +03:30
sajjadtalkhabi 6510432f41 Merge branch 'master' of ssh://109.122.252.86:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 10m31s
2026-07-30 18:53:59 +03:30
sajjadtalkhabi 8fefb4568b fix: bugs 2026-07-30 18:53:56 +03:30
root 32e97a7591 Revert "feat: update admin dashboard statistics"
Deploy banu-front / deploy (push) Failing after 1m24s
This reverts commit fe1fb6cfa4.
2026-07-30 14:16:10 +00:00
root fe1fb6cfa4 feat: update admin dashboard statistics
Deploy banu-front / deploy (push) Failing after 1m31s
2026-07-30 14:15:46 +00:00
sajjadtalkhabi bc9ce01d3f Merge branch 'master' of ssh://109.122.252.86:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 3m33s
2026-07-30 17:41:10 +03:30
sajjadtalkhabi 38051db180 fix: bugs 2026-07-30 17:40:41 +03:30
sajjadtalkhabi 36b747d267 fix: bugs 2026-07-30 17:38:27 +03:30
121 changed files with 3643 additions and 615 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
run: | run: |
BUILD_DIR="$(mktemp -d -t banu-front-XXXXXX)" BUILD_DIR="$(mktemp -d -t banu-front-XXXXXX)"
echo "BUILD_DIR=$BUILD_DIR" >> "$GITHUB_ENV" echo "BUILD_DIR=$BUILD_DIR" >> "$GITHUB_ENV"
git clone http://109.122.252.86:3000/front-end/banu-front.git "$BUILD_DIR" git clone http://127.0.0.1:3000/front-end/banu-front.git "$BUILD_DIR"
git -C "$BUILD_DIR" checkout "${{ github.sha }}" git -C "$BUILD_DIR" checkout "${{ github.sha }}"
- name: Build Docker image - name: Build Docker image
@@ -37,7 +37,7 @@ jobs:
docker run -d \ docker run -d \
--name "$CONTAINER" \ --name "$CONTAINER" \
--restart unless-stopped \ --restart unless-stopped \
-p "$PORT:80" \ -p "127.0.0.1:$PORT:80" \
"$IMAGE:latest" "$IMAGE:latest"
- name: Prune dangling images - name: Prune dangling images
+1 -1
View File
@@ -8,7 +8,7 @@ COPY package.json package-lock.json ./
# RUN npm config set registry https://mirror-npm.runflare.com # RUN npm config set registry https://mirror-npm.runflare.com
# RUN npm config set strict-ssl false # RUN npm config set strict-ssl false
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global RUN npm config set registry https://registry.npmjs.org/ --global
RUN npm config set strict-ssl false RUN npm config set strict-ssl false
RUN npm ci RUN npm ci
+1 -1
View File
@@ -6,7 +6,7 @@ APP_DIR="/root/banu-front"
IMAGE="banu-front" IMAGE="banu-front"
CONTAINER="banu-front" CONTAINER="banu-front"
PORT="8080" PORT="8080"
VITE_API_BASE_URL="http://109.122.252.86/api" VITE_API_BASE_URL="//api.madresebanoo.ir/api"
echo "==> Pulling latest code..." echo "==> Pulling latest code..."
if [ -d "$APP_DIR/.git" ]; then if [ -d "$APP_DIR/.git" ]; then
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Banu</title> <title>مدرسه بانو</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+241
View File
@@ -13,6 +13,7 @@
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7", "@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"html2pdf.js": "^0.14.0",
"husky": "^8.0.0", "husky": "^8.0.0",
"jalaali-js": "^1.2.8", "jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
@@ -102,6 +103,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -1722,6 +1732,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
@@ -2168,6 +2198,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.29", "version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -2371,6 +2410,26 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2656,6 +2715,18 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.49.0", "version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@@ -2722,6 +2793,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -3032,6 +3112,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -3853,6 +3942,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
@@ -3908,6 +4008,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4469,6 +4575,30 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/html2pdf.js": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz",
"integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0",
"jspdf": "^4.0.0"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -4607,6 +4737,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5171,6 +5307,23 @@
"json5": "lib/cli.js" "json5": "lib/cli.js"
} }
}, },
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6022,6 +6175,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6106,6 +6275,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6416,6 +6592,16 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/read-pkg": { "node_modules/read-pkg": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -6563,6 +6749,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp-tree": { "node_modules/regexp-tree": {
"version": "0.1.27", "version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -6716,6 +6909,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -7140,6 +7343,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -7685,6 +7898,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/svg-tags": { "node_modules/svg-tags": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
@@ -7779,6 +8002,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -8018,6 +8250,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/validate-npm-package-license": { "node_modules/validate-npm-package-license": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7", "@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"html2pdf.js": "^0.14.0",
"husky": "^8.0.0", "husky": "^8.0.0",
"jalaali-js": "^1.2.8", "jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
+4
View File
@@ -4,6 +4,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
font-size: 106.25%;
}
body { body {
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
+21 -21
View File
@@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: montserrat; font-family: montserrat;
src: url('../fonts/montserrat/Montserrat-ExtraLight.ttf') format('truetype'); src: url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
font-weight: 200; font-weight: 200;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -16,8 +16,8 @@
@font-face { @font-face {
font-family: aria; font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Light.woff2') format('woff2'); src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
font-weight: 300; font-weight: 200;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@@ -25,7 +25,7 @@
@font-face { @font-face {
font-family: aria; font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2'); src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2');
font-weight: 400; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@@ -33,6 +33,14 @@
@font-face { @font-face {
font-family: aria; font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Medium.woff2') format('woff2'); src: url('../fonts/aria/AriaWebFaNum-Medium.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-SemiBold.woff2') format('woff2');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -46,25 +54,17 @@
font-display: swap; font-display: swap;
} }
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face { @font-face {
font-family: iran-yekan; font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebmediumfanum.woff') format('woff'); src: url('../fonts/iran-yekan/iranyekanwebmediumfanum.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebboldfanum.woff') format('woff');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -80,7 +80,7 @@
@font-face { @font-face {
font-family: iran-yekan; font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanweblightfanum.woff') format('woff'); src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="copyright"> <div class="copyright">
<div class="copyright__text"> <div class="copyright__text">
<a href="https://mugweb.ir/" target="_blank" rel="noopener"> <a href="https://madresebanoo.ir/" target="_blank" rel="noopener">
<p class="copyright__en">All Rights Reserved. 2026</p> <p class="copyright__en">All Rights Reserved. 2026</p>
<p class="copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست.</p> <p class="copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست.</p>
</a> </a>
+16 -6
View File
@@ -7,7 +7,9 @@
</button> </button>
</div> </div>
<div class="header-block__logo"> <div class="header-block__logo">
<img :src="logo" alt="logo" /> <a :href="MAIN_SITE_URL">
<img :src="logo" alt="logo" />
</a>
</div> </div>
<div class="header-block__title"> <div class="header-block__title">
<slot> <slot>
@@ -49,9 +51,10 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import useAuth from '@/composables/useAuth' import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
@@ -65,7 +68,7 @@ defineProps({
const ui = useUIStore() const ui = useUIStore()
const router = useRouter() const router = useRouter()
const { clearSession } = useAuth() const { role, clearSession } = useAuth()
const logoutMutation = useLogoutMutation() const logoutMutation = useLogoutMutation()
const logo = gallery.authLogo const logo = gallery.authLogo
@@ -98,8 +101,10 @@ const onLogout = async () => {
} }
} }
const userMenuItems = [ const userMenuItems = computed(() => [
{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile }, ...(role.value === 'student'
? [{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile }]
: []),
{ {
key: 'logout', key: 'logout',
label: 'خروج', label: 'خروج',
@@ -107,7 +112,7 @@ const userMenuItems = [
color: 'var(--color-primary)', color: 'var(--color-primary)',
onClick: onLogout, onClick: onLogout,
}, },
] ])
const iconList = [ const iconList = [
// { icon: 'chat', action: () => {} }, // { icon: 'chat', action: () => {} },
@@ -147,7 +152,12 @@ const iconList = [
display: none; display: none;
} }
a {
display: block;
}
img { img {
display: block;
margin: 0 auto; margin: 0 auto;
height: 3.5rem; height: 3.5rem;
object-fit: contain; object-fit: contain;
+26 -2
View File
@@ -1,5 +1,10 @@
<template> <template>
<div class="stat-card"> <component
:is="to ? RouterLink : 'div'"
:to="to || undefined"
class="stat-card"
:class="{ 'stat-card--linked': to }"
>
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" /> <SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" />
<div class="stat-card__head"> <div class="stat-card__head">
@@ -14,10 +19,11 @@
<span class="stat-card__subtitle">{{ subtitle }}</span> <span class="stat-card__subtitle">{{ subtitle }}</span>
</div> </div>
</div> </div>
</div> </component>
</template> </template>
<script setup> <script setup>
import { RouterLink } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({ defineProps({
@@ -27,6 +33,7 @@ defineProps({
value: { type: [String, Number], default: '' }, value: { type: [String, Number], default: '' },
unit: { type: String, default: '' }, unit: { type: String, default: '' },
subtitle: { type: String, default: '' }, subtitle: { type: String, default: '' },
to: { type: [String, Object], default: null },
}) })
</script> </script>
@@ -41,6 +48,23 @@ defineProps({
background: rgba(255, 255, 255, 58%); background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%); box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem; border-radius: 0.9375rem;
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&--linked {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 9px 24px rgba(0, 0, 0, 7%);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
&__corner { &__corner {
position: absolute; position: absolute;
+18 -2
View File
@@ -3,9 +3,12 @@
<label class="file-uploader__dropzone-wrap"> <label class="file-uploader__dropzone-wrap">
<div <div
class="file-uploader__dropzone" class="file-uploader__dropzone"
:class="{ 'file-uploader__dropzone--active': isDragging }" :class="{
'file-uploader__dropzone--active': isDragging,
'file-uploader__dropzone--disabled': disabled,
}"
@dragover.prevent @dragover.prevent
@dragenter="isDragging = true" @dragenter="onDragEnter"
@dragleave="isDragging = false" @dragleave="isDragging = false"
@drop="handleDrop" @drop="handleDrop"
> >
@@ -19,6 +22,7 @@
class="file-uploader__input" class="file-uploader__input"
:accept="accept" :accept="accept"
:multiple="multiple" :multiple="multiple"
:disabled="disabled"
@change="handleFileSelect" @change="handleFileSelect"
/> />
</div> </div>
@@ -75,6 +79,7 @@ const props = defineProps({
multiple: { type: Boolean, default: true }, multiple: { type: Boolean, default: true },
maxFiles: { type: Number, default: 10 }, maxFiles: { type: Number, default: 10 },
maxSize: { type: Number, default: 0 }, maxSize: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error']) const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
@@ -82,6 +87,7 @@ const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
const isDragging = ref(false) const isDragging = ref(false)
const handleFileSelect = (event) => { const handleFileSelect = (event) => {
if (props.disabled) return
const files = [...event.target.files] const files = [...event.target.files]
emitFiles(files) emitFiles(files)
event.target.value = '' event.target.value = ''
@@ -90,10 +96,15 @@ const handleFileSelect = (event) => {
const handleDrop = (event) => { const handleDrop = (event) => {
event.preventDefault() event.preventDefault()
isDragging.value = false isDragging.value = false
if (props.disabled) return
const files = [...event.dataTransfer.files] const files = [...event.dataTransfer.files]
emitFiles(files) emitFiles(files)
} }
const onDragEnter = () => {
if (!props.disabled) isDragging.value = true
}
const emitFiles = (files) => { const emitFiles = (files) => {
if (props.modelValue.length + files.length > props.maxFiles) { if (props.modelValue.length + files.length > props.maxFiles) {
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`) emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
@@ -155,6 +166,11 @@ const formatFileSize = (bytes) => {
&--active { &--active {
border-color: var(--color-primary); border-color: var(--color-primary);
} }
&--disabled {
cursor: not-allowed;
opacity: 0.55;
}
} }
&__upload-icon { &__upload-icon {
+5 -1
View File
@@ -283,6 +283,7 @@ onBeforeUnmount(() => unmountFloating())
&__value { &__value {
flex: 1; flex: 1;
min-width: 0;
text-align: start; text-align: start;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -371,12 +372,15 @@ onBeforeUnmount(() => unmountFloating())
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
padding: 0.25rem 0.75rem; min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: none; border: none;
border-radius: 1.5rem; border-radius: 1.5rem;
background: transparent; background: transparent;
text-align: right; text-align: right;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 400;
line-height: 1.5;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
+24 -2
View File
@@ -15,6 +15,11 @@ export const fields = {
lastName: 'نام خانوادگی', lastName: 'نام خانوادگی',
nationalCode: 'کد ملی', nationalCode: 'کد ملی',
postalCode: 'کد پستی', postalCode: 'کد پستی',
virtualPhoneNumber: 'شماره تلفن مجازی',
seminaryCode: 'کد حوزوی',
seminaryServiceCenterCode: 'کد مرکز خدمات حوزه',
childrenCount: 'تعداد فرزند',
marriageStatus: 'وضعیت تأهل',
birthDate: 'تاریخ تولد', birthDate: 'تاریخ تولد',
birthday: 'تاریخ تولد', birthday: 'تاریخ تولد',
@@ -28,12 +33,17 @@ export const fields = {
address: 'آدرس', address: 'آدرس',
title: 'عنوان', title: 'عنوان',
subject: 'موضوع',
category: 'نوع خدمت',
note: 'یادداشت',
description: 'توضیحات', description: 'توضیحات',
bio: 'بیوگرافی', bio: 'بیوگرافی',
message: 'پیام', message: 'پیام',
content: 'محتوا', content: 'محتوا',
rejectionReason: 'دلیل رد', rejectionReason: 'دلیل رد',
reason: 'دلیل رد',
adminNote: 'یادداشت مدیر', adminNote: 'یادداشت مدیر',
reviewerNote: 'یادداشت ارزیاب',
activitySummary: 'خلاصهٔ فعالیت', activitySummary: 'خلاصهٔ فعالیت',
avatar: 'تصویر پروفایل', avatar: 'تصویر پروفایل',
@@ -49,12 +59,15 @@ export const fields = {
startDate: 'تاریخ شروع', startDate: 'تاریخ شروع',
endDate: 'تاریخ پایان', endDate: 'تاریخ پایان',
deadline: 'مهلت تحویل',
fromDate: 'از تاریخ', fromDate: 'از تاریخ',
toDate: 'تا تاریخ', toDate: 'تا تاریخ',
capacity: 'ظرفیت', capacity: 'ظرفیت',
minCapacity: 'حداقل ظرفیت', minCapacity: 'حداقل ظرفیت',
maxCapacity: 'حداکثر ظرفیت', maxCapacity: 'حداکثر ظرفیت',
score: 'نمره',
minimumScore: 'حد نصاب قبولی',
order: 'ترتیب', order: 'ترتیب',
priority: 'اولویت', priority: 'اولویت',
@@ -67,23 +80,32 @@ export const fields = {
termId: 'ترم', termId: 'ترم',
courseId: 'دوره', courseId: 'دوره',
sessionId: 'جلسه', sessionId: 'جلسه',
missionaryRequestId: 'درخواست مبلغ',
sessionType: 'نوع جلسه', sessionType: 'نوع جلسه',
type: 'نوع جلسه',
link: 'لینک جلسه',
teacherId: 'مدرس', teacherId: 'مدرس',
studentIds: 'دانش‌آموزان', studentIds: 'دانش‌آموزان',
prerequisiteCourseIds: 'دوره‌های پیش‌نیاز',
contentMediaId: 'فایل محتوای دوره',
coverMediaId: 'تصویر جلد',
imageId: 'تصویر',
educationStatus: 'وضعیت تحصیلی', educationStatus: 'وضعیت تحصیلی',
fieldOfStudy: 'رشتهٔ تحصیلی', fieldOfStudy: 'رشتهٔ تحصیلی',
seminaryLevel: 'مقطع حوزه', seminaryLevel: 'مقطع حوزه',
universityLevel: 'مقطع دانشگاهی', universityLevel: 'مقطع دانشگاهی',
universityName: 'نام حوزه علمیه / دانشگاه', universityName: 'نام حوزه علمیه / دانشگاه',
workExperienceSummary: 'خلاصهٔ سوابق شغلی', degree: 'مقطع تحصیلی',
jobTitle: 'عنوان شغل',
organizationName: 'نام سازمان',
hijabApproach: 'رویکرد حجاب', hijabApproach: 'رویکرد حجاب',
prerequisites: 'پیش‌نیازها', prerequisites: 'پیش‌نیازها',
specializedTopics: 'موضوعات تخصصی', specializedTopics: 'موضوعات تخصصی',
propagationPlatforms: 'بسترهای تبلیغ', propagationPlatforms: 'بسترهای تبلیغ',
propagationMethodDescription: 'شرح روش تبلیغ', propagationMethodDescription: 'شرح روش تبلیغ',
propagationExperienceYears: 'سنوات تجربهٔ تبلیغ', propagationExperienceYears: 'سابقه فعالیت تبلیغی',
relevantCertificates: 'گواهی‌نامه‌های مرتبط', relevantCertificates: 'گواهی‌نامه‌های مرتبط',
onlinePlatformDetails: 'جزئیات بستر آنلاین', onlinePlatformDetails: 'جزئیات بستر آنلاین',
+1 -3
View File
@@ -1,7 +1,5 @@
import { string } from 'yup' import { string } from 'yup'
import { fields } from '@/constants/fields'
export const phoneNumberRule = string() export const phoneNumberRule = string()
.required() .required()
.matches(/^09\d{9}$/, ({ path }) => `${fields[path] || path} نامعتبر است`) .matches(/^09\d{9}$/, 'شماره موبایل باید با ۰۹ شروع شود و ۱۱ رقم باشد')
+15 -8
View File
@@ -6,7 +6,7 @@ export const getEnumObject = (enumArray, key = 'value', value = 'label') =>
export const ROLES = Object.freeze([ export const ROLES = Object.freeze([
{ value: 'admin', label: 'مدیر', changeable: false }, { value: 'admin', label: 'مدیر', changeable: false },
{ value: 'student', label: 'دانشجو', changeable: true }, { value: 'student', label: 'طلبه', changeable: true },
{ value: 'teacher', label: 'استاد', changeable: true }, { value: 'teacher', label: 'استاد', changeable: true },
{ value: 'counselor', label: 'مشاور', changeable: true }, { value: 'counselor', label: 'مشاور', changeable: true },
{ value: 'missionary', label: 'مبلغ', changeable: true }, { value: 'missionary', label: 'مبلغ', changeable: true },
@@ -68,7 +68,6 @@ export const CHANGEABLE_ROLES = Object.freeze(ROLES.filter((r) => r.changeable).
export const EDUCATION_STATUS = Object.freeze({ export const EDUCATION_STATUS = Object.freeze({
seminary_student: 'طلبه', seminary_student: 'طلبه',
seminary_and_university_student: 'طلبه و دانشجو',
graduate: 'فارغ التحصیلی', graduate: 'فارغ التحصیلی',
}) })
@@ -76,14 +75,13 @@ export const SEMINARY_LEVEL = Object.freeze({
level_2: 'سطح دو', level_2: 'سطح دو',
level_3: 'سطح سه', level_3: 'سطح سه',
level_4: 'سطح چهار', level_4: 'سطح چهار',
not_applicable: 'طلبه نیستم',
}) })
export const UNIVERSITY_LEVEL = Object.freeze({ export const UNIVERSITY_LEVEL = Object.freeze({
bachelor: ' مقطع کارشناسی (لیسانس)', bachelor: ' مقطع کارشناسی (لیسانس)',
master: ' مقطع کارشناسی ارشد (فوق لیسانس)', master: ' مقطع کارشناسی ارشد (فوق لیسانس)',
phd: ' مقطع دکتری', phd: ' مقطع دکتری',
not_applicable: 'دانشجو نیستم', not_applicable: 'طلبه نیستم',
}) })
export const PROPAGATION_PLATFORM = Object.freeze({ export const PROPAGATION_PLATFORM = Object.freeze({
@@ -142,9 +140,15 @@ export const COURSE_CONTENT_TYPE = Object.freeze({
}) })
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({ export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
video: 'video/*', video: '.mp4,.webm,video/mp4,video/webm',
voice: 'audio/*', voice: '.mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm',
text: '.pdf,.doc,.docx,.txt', text: '.pdf,application/pdf',
})
export const COURSE_CONTENT_TYPE_MAX_SIZE = Object.freeze({
video: 300 * 1024 * 1024,
voice: 50 * 1024 * 1024,
text: 50 * 1024 * 1024,
}) })
export const SESSION_TYPE = Object.freeze({ export const SESSION_TYPE = Object.freeze({
@@ -216,8 +220,11 @@ export const MISSIONARY_REQUEST_STATUS = Object.freeze({
rejected: 'رد شده', rejected: 'رد شده',
}) })
export const MAIN_SITE_URL = 'https://madresebanoo.ir/'
// Landing route per role after login / when a logged-in user hits a guest // Landing route per role after login / when a logged-in user hits a guest
// page. Roles not listed here fall back to the student dashboard. // page. Roles not listed here fall back to the student dashboard. Approved
// users of every role except admin bypass this and land on MAIN_SITE_URL.
export const ROLE_HOME_ROUTES = Object.freeze({ export const ROLE_HOME_ROUTES = Object.freeze({
admin: 'admin-dashboard', admin: 'admin-dashboard',
counselor: 'counselor-dashboard', counselor: 'counselor-dashboard',
@@ -49,7 +49,7 @@
</div> </div>
<div class="submission-details__row"> <div class="submission-details__row">
<LineInfoBlock title="پاسخ دانشجو" :desc="submission.userDescription || '—'" /> <LineInfoBlock title="پاسخ طلبه" :desc="submission.userDescription || '—'" />
</div> </div>
<div v-if="submission.attachments?.length" class="submission-details__attachments"> <div v-if="submission.attachments?.length" class="submission-details__attachments">
@@ -0,0 +1,137 @@
<template>
<div class="certificate-item">
<div class="certificate-item__main">
<p class="certificate-item__title">{{ certificate.title || '—' }}</p>
<p class="certificate-item__student">{{ certificate.user?.name || '—' }}</p>
</div>
<div class="certificate-item__meta">
<div class="certificate-item__pill">
<span class="certificate-item__pill-label">تاریخ صدور:</span>
<span class="certificate-item__pill-value">{{ issuedAt }}</span>
</div>
<div class="certificate-item__pill">
<span class="certificate-item__pill-label">فایل‌ها:</span>
<span class="certificate-item__pill-value">{{ filesLabel }}</span>
</div>
</div>
<div class="certificate-item__actions">
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('delete', certificate)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
certificate: { type: Object, required: true },
})
const emit = defineEmits(['delete'])
const issuedAt = computed(() => formatJalaaliDate(props.certificate.issuedAt) || '—')
const filesLabel = computed(() => {
const parts = []
if (props.certificate.hasImage) parts.push('تصویر')
if (props.certificate.hasPdf) parts.push('PDF')
return parts.length > 0 ? parts.join(' / ') : '—'
})
</script>
<style lang="scss" scoped>
.certificate-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, 70%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-flow: row wrap;
align-items: center;
}
&__main {
flex: 1 1 33%;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__student {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #848484;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
flex: 1 1 33%;
}
&__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.7rem;
color: #535353;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
flex: 1 1 100%;
@media (min-width: 1024px) {
flex: 1 1 auto;
}
}
}
</style>
@@ -0,0 +1,292 @@
<template>
<BasicModal
title="صدور گواهینامه"
title-en="Issue Certificate"
width="95%"
max-width="56rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="issue-certificate" @submit.prevent="onSubmit(close)">
<div class="issue-certificate__grid">
<SelectField
v-model="form.userId"
name="userId"
label="طلبه"
placeholder="نام طلبه را جستجو کنید"
:options="userOptions"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchUsers"
:error="errors.userId"
/>
<SelectField
v-model="form.termId"
name="termId"
label="ترم گذرانده‌شده"
placeholder="ترم را انتخاب کنید"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
:error="errors.termId"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره گذرانده‌شده"
placeholder="دوره را انتخاب کنید"
:options="courseOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchCourses"
/>
<DatePickerField v-model="form.issuedAt" name="issuedAt" label="تاریخ صدور" />
<DatePickerField v-model="form.startedAt" name="startedAt" label="تاریخ شروع" />
<DatePickerField v-model="form.endedAt" name="endedAt" label="تاریخ پایان" />
</div>
<p v-if="errors.termId" class="issue-certificate__hint">{{ errors.termId }}</p>
<div class="issue-certificate__files">
<div class="issue-certificate__file-col">
<label class="issue-certificate__file-label">تصویر گواهینامه (JPG/PNG)</label>
<FileUploader
v-model="imageFiles"
accept="image/*"
:multiple="false"
:max-files="1"
@select="onImageSelect"
@error="onFileError"
/>
</div>
<div class="issue-certificate__file-col">
<label class="issue-certificate__file-label">فایل PDF گواهینامه</label>
<FileUploader
v-model="pdfFiles"
accept="application/pdf"
:multiple="false"
:max-files="1"
@select="onPdfSelect"
@error="onFileError"
/>
</div>
</div>
<p v-if="errors.imageMediaId" class="issue-certificate__hint">{{ errors.imageMediaId }}</p>
<div class="issue-certificate__divider" />
<div class="issue-certificate__actions">
<BaseButton
variant="transparent"
text="منصرف شدم"
custom-class="issue-certificate__btn-cancel"
@click="close"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
</template>
</BaseButton>
<BaseButton
type="submit"
text="صدور گواهینامه"
:loading="submitting"
custom-class="issue-certificate__btn-submit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useDebounce from '@/composables/useDebounce'
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 SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { certificateSchema } from '@/features/admin/certificates/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import {
adminCertificatesKeys,
useAddAdminCertificateMutation,
} from '@/services/query/admin-certificates'
defineOptions({ name: 'IssueCertificateModal' })
const queryClient = useQueryClient()
const form = ref({
userId: '',
termId: '',
courseId: '',
issuedAt: '',
startedAt: '',
endedAt: '',
})
const { validate, errors, resetErrors } = useYup(certificateSchema)
const userSearch = ref('')
const userFilters = computed(() => ({ search: userSearch.value, roles: 'student' }))
const userPagination = ref({ page: 1, perPage: 10 })
const { data: usersResponse } = useAdminUsersListQuery(userFilters, userPagination)
const userOptions = computed(() => usersResponse.value?.data ?? [])
const searchUsers = useDebounce((q) => {
userSearch.value = q || ''
}, 400)
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const courseSearch = ref('')
const courseFilters = computed(() => ({ title: courseSearch.value }))
const coursePagination = ref({ page: 1, perPage: 10 })
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
const courseOptions = computed(() => coursesResponse.value?.data ?? [])
const searchCourses = useDebounce((q) => {
courseSearch.value = q || ''
}, 400)
const imageFiles = ref([])
const pdfFiles = ref([])
const uploadMediaMutation = useUploadMediaMutation()
const onImageSelect = async (files) => {
const file = files[0]
if (!file) return
try {
const fd = objectToFormData({ file, purpose: 'certificate_image' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
imageFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
} catch {
toast.error('بارگذاری تصویر گواهینامه با خطا مواجه شد.')
}
}
const onPdfSelect = async (files) => {
const file = files[0]
if (!file) return
try {
const fd = objectToFormData({ file, purpose: 'certificate_pdf' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
pdfFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
} catch {
toast.error('بارگذاری فایل PDF گواهینامه با خطا مواجه شد.')
}
}
const onFileError = (msg) => toast.error(msg)
const addMutation = useAddAdminCertificateMutation()
const submitting = computed(() => addMutation.isPending.value)
const onSubmit = async (close) => {
const payload = {
...form.value,
imageMediaId: imageFiles.value[0]?.id ?? null,
pdfMediaId: pdfFiles.value[0]?.id ?? null,
}
const { isValid } = await validate(payload)
if (!isValid) return
await addMutation.mutateAsync(payload)
await queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
resetErrors()
close()
}
</script>
<style lang="scss" scoped>
.issue-certificate {
width: 100%;
text-align: start;
padding: 0.5rem 0;
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
margin-block: 0.75rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1280px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__hint {
color: var(--color-error);
font-family: var(--font-family-fa);
font-size: 0.75rem;
margin: -0.25rem 0 0.5rem;
}
&__files {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 0.5rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__file-label {
display: block;
font-family: var(--font-family-fa);
font-size: 0.8125rem;
color: #4b5563;
margin-bottom: 0.375rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1.5rem;
}
&__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 12rem;
}
}
</style>
@@ -0,0 +1,129 @@
<template>
<div class="certificates-page">
<BoxedIconTitleBlock
class="certificates-page__heading"
title="مدیریت گواهینامه‌ها"
desc="در این قسمت می‌توانید برای طلبه‌ها گواهینامه صادر کنید"
>
<template #icon>
<SvgIcon name="certificate" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="certificates-page__list-header">
<SimpleTitleIconBlock
title="لیست گواهینامه‌های صادرشده"
class="certificates-page__list-title"
>
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton
text="صدور گواهینامه جدید"
custom-class="certificates-page__add-btn"
@click="onAdd"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="certificates.length > 0">
<CertificateItem
v-for="certificate in certificates"
:key="certificate.id"
:certificate="certificate"
@delete="onAskDelete"
/>
</div>
<NoItems v-else title="متاسفیم" desc="گواهینامه‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
<IssueCertificateModal v-if="isModal('IssueCertificateModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
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 BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CertificateItem from '@/features/admin/certificates/components/CertificateItem.vue'
import IssueCertificateModal from '@/features/admin/certificates/components/modals/IssueCertificateModal.vue'
import {
adminCertificatesKeys,
useAdminCertificatesListQuery,
useDeleteAdminCertificateMutation,
} from '@/services/query/admin-certificates'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const filters = ref({})
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminCertificatesListQuery(filters, pagination, {
keepPreviousData: true,
})
const certificates = computed(() => data.value?.data ?? [])
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const onAdd = () => openModal('IssueCertificateModal')
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
const deleteMutation = useDeleteAdminCertificateMutation()
const onAskDelete = (certificate) => {
openModal('ConfirmModal', {
title: `حذف گواهینامه ${certificate.user?.name || ''}`,
message:
'بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید این گواهینامه را حذف کنید؟',
onConfirm: () => deleteMutation.mutate(certificate.id, { onSuccess: invalidate }),
})
}
</script>
<style lang="scss" scoped>
.certificates-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
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>
@@ -0,0 +1,24 @@
import { mixed, number, object, string } from 'yup'
export const certificateSchema = object()
.shape({
userId: number().typeError('طلبه را انتخاب کنید').required('طلبه را انتخاب کنید'),
termId: mixed().nullable().notRequired(),
courseId: mixed().nullable().notRequired(),
issuedAt: string().nullable().notRequired(),
startedAt: string().nullable().notRequired(),
endedAt: string().nullable().notRequired(),
imageMediaId: number().nullable().notRequired(),
pdfMediaId: number().nullable().notRequired(),
})
.test('term-or-course', function (value) {
if (value?.termId || value?.courseId) return true
return this.createError({ path: 'termId', message: 'یکی از ترم یا دوره را انتخاب کنید' })
})
.test('image-or-pdf', function (value) {
if (value?.imageMediaId || value?.pdfMediaId) return true
return this.createError({
path: 'imageMediaId',
message: 'حداقل یک فایل (تصویر یا PDF) بارگذاری کنید',
})
})
@@ -1,8 +1,8 @@
<template> <template>
<div class="course-item"> <div class="course-item">
<div class="course-item__main"> <div class="course-item__main">
<div v-if="course.image" class="course-item__image"> <div v-if="course.coverUrl" class="course-item__image">
<img :src="course.image" :alt="course.title" /> <img :src="course.coverUrl" :alt="course.title" />
</div> </div>
<div v-else class="course-item__image course-item__image--placeholder"> <div v-else class="course-item__image course-item__image--placeholder">
<SvgIcon name="book" :size="22" color="#bcbcbc" /> <SvgIcon name="book" :size="22" color="#bcbcbc" />
@@ -30,14 +30,13 @@
/> />
</div> </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"> <div class="course-item__actions">
<div v-if="!isActive" class="course-item__status">
<span class="course-item__status-badge">
<span class="course-item__status-dot" />
غیرفعال
</span>
</div>
<ToggleSwitch <ToggleSwitch
:model-value="!!isActive" :model-value="!!isActive"
@update:model-value="emit('change-status', { id: course.id, isActive: !isActive })" @update:model-value="emit('change-status', { id: course.id, isActive: !isActive })"
@@ -192,10 +191,6 @@ const isActive = computed(() => props.course.isActive ?? false)
justify-content: flex-start; justify-content: flex-start;
} }
&__status {
flex: 0 0 auto;
}
&__status-badge { &__status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -217,6 +212,7 @@ const isActive = computed(() => props.course.isActive ?? false)
&__actions { &__actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
@@ -154,7 +154,7 @@ const form = ref({
templateId: '', templateId: '',
title: '', title: '',
capacity: '', capacity: '',
imageId: null, coverMediaId: null,
isActive: false, isActive: false,
}) })
@@ -212,10 +212,10 @@ watch(existingCourse, (course) => {
templateId: '', templateId: '',
title: course.title || '', title: course.title || '',
capacity: course.capacity ?? '', capacity: course.capacity ?? '',
imageId: course.imageId || null, coverMediaId: course.coverMediaId || null,
isActive: course.isActive ?? false, isActive: course.isActive ?? false,
} }
if (course.image) image.value = { url: course.image } if (course.coverUrl) image.value = { url: course.coverUrl }
}) })
const uploadMutation = useUploadMediaMutation() const uploadMutation = useUploadMediaMutation()
@@ -225,8 +225,8 @@ const onImageCropped = async (file) => {
const formData = objectToFormData({ file, purpose: 'cover', 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 */
} }
@@ -14,7 +14,7 @@
<template v-else-if="course"> <template v-else-if="course">
<div class="course-details__hero"> <div class="course-details__hero">
<div class="course-details__image"> <div class="course-details__image">
<img v-if="course.image" :src="course.image" :alt="course.title" /> <img v-if="course.coverUrl" :src="course.coverUrl" :alt="course.title" />
<SvgIcon v-else name="book" :size="32" color="#bcbcbc" /> <SvgIcon v-else name="book" :size="32" color="#bcbcbc" />
</div> </div>
<div class="course-details__grid"> <div class="course-details__grid">
@@ -33,7 +33,7 @@
<SelectField <SelectField
v-model="form.teacherId" v-model="form.teacherId"
name="teacherId" name="teacherId"
label="استاد" label="استاد (اختیاری)"
:options="teacherOptions" :options="teacherOptions"
option-label="name" option-label="name"
option-value="id" option-value="id"
@@ -251,12 +251,12 @@ watch(
{ immediate: true } { immediate: true }
) )
const uploadMutation = useUploadMediaMutation() const coverUploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData) const response = await coverUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload } image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id form.value.coverMediaId = payload?.id
@@ -270,15 +270,28 @@ const onImageError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseMutation() const addMutation = useAddAdminCourseMutation()
const updateMutation = useUpdateAdminCourseMutation() const updateMutation = useUpdateAdminCourseMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value) const submitting = computed(
() =>
coverUploadMutation.isPending.value ||
addMutation.isPending.value ||
updateMutation.isPending.value
)
const onSubmit = async () => { const onSubmit = async () => {
if (coverUploadMutation.isPending.value) {
toast.info('لطفاً تا پایان بارگذاری تصویر دوره منتظر بمانید.')
return
}
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
if (isEditMode.value) { try {
await updateMutation.mutateAsync({ id: courseId.value, payload }) if (isEditMode.value) {
} else { await updateMutation.mutateAsync({ id: courseId.value, payload })
await addMutation.mutateAsync(payload) } else {
await addMutation.mutateAsync(payload)
}
} catch {
return
} }
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all }) await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
router.push({ name: 'admin-courses' }) router.push({ name: 'admin-courses' })
@@ -69,7 +69,12 @@ const courseIcon = gallery.courseIcon
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null)) const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
const onAdd = () => { const onAdd = () => {
router.push({ name: 'admin-add-course' }).catch(() => {}) router
.push({
name: 'admin-add-course',
query: routeTermId.value ? { termId: routeTermId.value } : undefined,
})
.catch(() => {})
} }
const tabs = [ const tabs = [
+14 -5
View File
@@ -2,10 +2,15 @@ import { array, boolean, mixed, number, object, string } from 'yup'
export const courseSchema = object().shape({ export const courseSchema = object().shape({
title: string().required().min(3).max(255), title: string().required().min(3).max(255),
teacherId: mixed().required(), // The backend intentionally allows courses without an assigned teacher.
capacity: number().typeError('ظرفیت باید عدد باشد').required().min(1), teacherId: mixed().nullable().notRequired(),
capacity: number()
.typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
prerequisiteCourseIds: array().nullable().default([]), prerequisiteCourseIds: array().nullable().default([]),
contentMediaId: number().nullable().notRequired(), coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
termId: mixed().required(), termId: mixed().required(),
isActive: boolean().nullable().notRequired(), isActive: boolean().nullable().notRequired(),
@@ -14,7 +19,11 @@ export const courseSchema = object().shape({
export const offeredCourseSchema = object().shape({ export const offeredCourseSchema = object().shape({
termId: string().required(), termId: string().required(),
title: string().required(), title: string().required(),
capacity: string().required(), capacity: number()
imageId: string().nullable().notRequired(), .typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required()
.integer('ظرفیت باید عدد صحیح باشد')
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
isActive: boolean().nullable().notRequired(), isActive: boolean().nullable().notRequired(),
}) })
+47 -27
View File
@@ -1,15 +1,15 @@
<template> <template>
<div class="dashboard"> <div class="dashboard">
<div class="dashboard__main"> <div class="dashboard__main">
<!-- اطلاعات موسسه --> <!-- اطلاعات مدرسه -->
<section class="dashboard__section"> <section class="dashboard__section">
<BoxedIconTitleBlock title="اطلاعات موسسه" desc="آخرین اطلاعات موسسه شما."> <BoxedIconTitleBlock title="اطلاعات مدرسه" desc="آخرین اطلاعات مدرسه شما.">
<template #icon> <template #icon>
<SvgIcon name="heart" :size="24" color="var(--color-primary)" /> <SvgIcon name="heart" :size="24" color="var(--color-primary)" />
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="1" :cols-per-row="3" /> <SkeletonLoaderBlock v-if="isLoading" :rows="2" :cols-per-row="3" />
<div v-else class="dashboard__stats"> <div v-else class="dashboard__stats">
<DashboardStatCard <DashboardStatCard
v-for="(stat, i) in institutionStats" v-for="(stat, i) in institutionStats"
@@ -19,6 +19,7 @@
:value="stat.value" :value="stat.value"
:unit="stat.unit" :unit="stat.unit"
:subtitle="stat.subtitle" :subtitle="stat.subtitle"
:to="stat.to"
/> />
</div> </div>
</section> </section>
@@ -95,37 +96,56 @@ const ACCESS_LABELS = {
limited: 'محدود', limited: 'محدود',
} }
const formatThousands = (value) => {
const n = Number(value ?? 0)
return n >= 1000
? { value: toFaDigits(Math.round(n / 1000)), unit: 'هزار' }
: { value: toFaDigits(n), unit: 'نفر' }
}
const institutionStats = computed(() => { const institutionStats = computed(() => {
const stats = dashboard.value.stats ?? {} const stats = dashboard.value.stats ?? {}
const users = formatThousands(stats.activeUsers)
return [ return [
{ {
icon: 'user', icon: 'users-three',
label: 'آمار کلی کاربران', label: 'مجموع کل متقاضیان',
value: users.value, value: toFaDigits(stats.totalApplicants ?? 0),
unit: users.unit, unit: 'نفر',
subtitle: 'کاربر فعال', subtitle: 'احراز شده و نشده',
to: { name: 'admin-users' },
}, },
{ {
icon: 'video', icon: 'clock',
label: 'دوره های ایجاد شده', label: 'تعداد کاربران در انتظار ارزیابی',
value: toFaDigits(stats.coursesCreated ?? 0), value: toFaDigits(stats.awaitingEvaluation ?? 0),
unit: 'دوره', unit: 'نفر',
subtitle: 'ایجاد شده', subtitle: 'در انتظار ارزیابی',
to: { name: 'admin-user-verification' },
}, },
{ {
icon: 'shopping-bag', icon: 'book',
label: 'دوره های فروش رفته', label: 'آمار طلبه‌های ترم ۱',
value: toFaDigits(stats.coursesSold ?? 0), value: toFaDigits(stats.term1Students ?? 0),
unit: 'عدد', unit: 'نفر',
subtitle: 'دوره فروش رفته', subtitle: 'ترم اول',
to: { name: 'admin-terms' },
},
{
icon: 'book',
label: 'آمار طلبه‌های ترم ۲',
value: toFaDigits(stats.term2Students ?? 0),
unit: 'نفر',
subtitle: 'ترم دوم',
to: { name: 'admin-terms' },
},
{
icon: 'book',
label: 'آمار طلبه‌های ترم ۳',
value: toFaDigits(stats.term3Students ?? 0),
unit: 'نفر',
subtitle: 'ترم سوم',
to: { name: 'admin-terms' },
},
{
icon: 'graduation-cap',
label: 'آمار فارغ‌التحصیلان',
value: toFaDigits(stats.graduates ?? 0),
unit: 'نفر',
subtitle: 'فارغ‌التحصیل',
to: { name: 'admin-users' },
}, },
] ]
}) })
@@ -246,7 +266,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem; gap: 1.5rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
width: 19.625rem; width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0; flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6; border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem; padding-inline-start: 2rem;
+23 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'admin', layout: 'admin',
role: 'admin', role: 'admin',
title: 'سلام بر شما', title: 'سلام بر شما',
subtitle: 'به سامانه موسسه بانو خوش آمدید.', subtitle: 'به سامانه مدرسه بانو خوش آمدید.',
}, },
}, },
{ {
@@ -241,6 +241,17 @@ export default [
subtitle: 'لیست خدمات را مشاهده و مدیریت نمایید.', subtitle: 'لیست خدمات را مشاهده و مدیریت نمایید.',
}, },
}, },
{
path: '/service-types',
name: 'admin-service-types',
component: () => import('@/features/admin/services/pages/ServiceTypesPage.vue'),
meta: {
layout: 'admin',
role: 'admin',
title: 'مرکز خدمات',
subtitle: 'انواع خدمات قابل درخواست توسط کاربران را مدیریت کنید.',
},
},
{ {
path: '/consultations', path: '/consultations',
name: 'admin-consultations', name: 'admin-consultations',
@@ -263,4 +274,15 @@ export default [
subtitle: 'درخواست‌های اعزام مبلغین را مشاهده و مدیریت نمایید.', subtitle: 'درخواست‌های اعزام مبلغین را مشاهده و مدیریت نمایید.',
}, },
}, },
{
path: '/certificates',
name: 'admin-certificates',
component: () => import('@/features/admin/certificates/pages/AdminCertificatesPage.vue'),
meta: {
layout: 'admin',
role: 'admin',
title: 'گواهینامه‌ها',
subtitle: 'گواهینامه‌های دوره‌ها را صادر و مدیریت نمایید.',
},
},
] ]
@@ -0,0 +1,86 @@
<template>
<div class="service-type-item">
<div class="service-type-item__main">
<p class="service-type-item__title">{{ serviceType.title }}</p>
<p class="service-type-item__desc">{{ serviceType.description || '—' }}</p>
</div>
<div class="service-type-item__actions">
<CircleButton
tooltip="ویرایش"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('edit', serviceType)"
>
<template #icon>
<SvgIcon name="pencil" :size="18" color="var(--color-primary)" />
</template>
</CircleButton>
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('delete', serviceType)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
serviceType: { type: Object, required: true },
})
const emit = defineEmits(['edit', 'delete'])
</script>
<style lang="scss" scoped>
.service-type-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 50%);
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
&__main {
flex: 1;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
}
&__desc {
font-family: var(--font-family-fa);
font-size: 0.8125rem;
color: #848484;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
}
</style>
@@ -0,0 +1,138 @@
<template>
<BasicModal
:title="isEditMode ? 'ویرایش نوع خدمت' : 'افزودن نوع خدمت'"
title-en="Service Type"
width="95%"
max-width="40rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="service-type-form" @submit.prevent="onSubmit(close)">
<TextField
v-model="form.title"
name="title"
label="عنوان نوع خدمت"
placeholder="مثال: خدمات رفاهی"
:error="errors.title"
@blur="validateAt('title', form.title)"
/>
<TextareaField
v-model="form.description"
name="description"
label="توضیحات"
placeholder="توضیح کوتاهی درباره این نوع خدمت بنویسید"
:row="4"
:error="errors.description"
/>
<div class="service-type-form__divider" />
<div class="service-type-form__actions">
<BaseButton
variant="transparent"
text="منصرف شدم"
custom-class="service-type-form__btn-cancel"
@click="close"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
</template>
</BaseButton>
<BaseButton
type="submit"
:text="isEditMode ? 'ذخیره تغییرات' : 'افزودن'"
:loading="submitting"
custom-class="service-type-form__btn-submit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BasicModal from '@/components/BasicModal.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { serviceTypeSchema } from '@/features/admin/services/schema/service-type'
import {
serviceTypesKeys,
useAddServiceTypeMutation,
useUpdateServiceTypeMutation,
} from '@/services/query/service-types'
defineOptions({ name: 'ServiceTypeFormModal' })
const queryClient = useQueryClient()
const { getModal } = useModal()
const modalData = computed(() => getModal('ServiceTypeFormModal')?.data ?? {})
const editingServiceType = computed(() => modalData.value.serviceType ?? null)
const isEditMode = computed(() => !!editingServiceType.value)
const form = ref({
title: editingServiceType.value?.title ?? '',
description: editingServiceType.value?.description ?? '',
})
const { validate, validateAt, errors } = useYup(serviceTypeSchema)
const addMutation = useAddServiceTypeMutation()
const updateMutation = useUpdateServiceTypeMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: editingServiceType.value.id, payload })
} else {
await addMutation.mutateAsync(payload)
}
await queryClient.invalidateQueries({ queryKey: serviceTypesKeys.all })
close()
}
</script>
<style lang="scss" scoped>
.service-type-form {
width: 100%;
text-align: start;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1rem;
}
&__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 10rem;
}
}
</style>
+2
View File
@@ -1,4 +1,6 @@
export { default as SendMessageModal } from './components/modals/SendMessageModal.vue' export { default as SendMessageModal } from './components/modals/SendMessageModal.vue'
export { default as ServiceDetailsModal } from './components/modals/ServiceDetailsModal.vue' export { default as ServiceDetailsModal } from './components/modals/ServiceDetailsModal.vue'
export { default as ServiceTypeFormModal } from './components/modals/ServiceTypeFormModal.vue'
export { default as ServiceItem } from './components/ServiceItem.vue' export { default as ServiceItem } from './components/ServiceItem.vue'
export { default as ServiceTypeItem } from './components/ServiceTypeItem.vue'
export { default as ServicesFilters } from './components/ServicesFilters.vue' export { default as ServicesFilters } from './components/ServicesFilters.vue'
@@ -0,0 +1,109 @@
<template>
<div class="service-types-page">
<BoxedIconTitleBlock
class="service-types-page__heading"
title="انواع خدمات"
desc="در این قسمت می‌توانید نوع‌های خدمت قابل درخواست توسط کاربران را تعریف کنید."
>
<template #icon>
<SvgIcon name="users-three" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="service-types-page__list-header">
<SimpleTitleIconBlock title="لیست انواع خدمات" class="service-types-page__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton text="افزودن نوع خدمت" custom-class="service-types-page__add-btn" @click="onAdd">
<template #appendIcon>
<SvgIcon name="plus" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
<div v-else-if="serviceTypes.length > 0">
<ServiceTypeItem
v-for="serviceType in serviceTypes"
:key="serviceType.id"
:service-type="serviceType"
@edit="onEdit"
@delete="onAskDelete"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز هیچ نوع خدمتی تعریف نشده است." />
<ServiceTypeFormModal v-if="isModal('ServiceTypeFormModal')" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import ServiceTypeItem from '@/features/admin/services/components/ServiceTypeItem.vue'
import ServiceTypeFormModal from '@/features/admin/services/components/modals/ServiceTypeFormModal.vue'
import {
serviceTypesKeys,
useServiceTypesListQuery,
useDeleteServiceTypeMutation,
} from '@/services/query/service-types'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const { data, isLoading } = useServiceTypesListQuery()
const serviceTypes = computed(() => data.value ?? [])
const onAdd = () => openModal('ServiceTypeFormModal')
const onEdit = (serviceType) => openModal('ServiceTypeFormModal', { serviceType })
const invalidate = () => queryClient.invalidateQueries({ queryKey: serviceTypesKeys.all })
const deleteMutation = useDeleteServiceTypeMutation()
const onAskDelete = (serviceType) => {
openModal('ConfirmModal', {
title: `حذف نوع خدمت «${serviceType.title}»`,
message:
'بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید این نوع خدمت را حذف کنید؟',
onConfirm: () => deleteMutation.mutate(serviceType.id, { onSuccess: invalidate }),
})
}
</script>
<style lang="scss" scoped>
.service-types-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
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>
@@ -0,0 +1,6 @@
import { object, string } from 'yup'
export const serviceTypeSchema = object().shape({
title: string().trim().required('عنوان نوع خدمت را وارد کنید').max(255, 'عنوان طولانی است'),
description: string().trim().nullable().notRequired().max(2000, 'توضیحات طولانی است'),
})
@@ -77,6 +77,19 @@
/> />
</div> </div>
<!-- row 3b — embed code (online sessions only) -->
<div class="session-form__row" v-if="form.type === 'online'">
<TextareaField
v-model="form.embedCode"
name="embedCode"
label="کد امبد (Embed Code)"
placeholder="در صورت وجود، کد iframe/embed جلسه (مثلاً از آپارات یا سرویس ویدئوکنفرانس) را اینجا وارد کنید تا به‌جای لینک، به‌صورت مستقیم در صفحه نمایش داده شود."
:row="3"
:error="errors.embedCode"
@blur="validateAt('embedCode', form.embedCode)"
/>
</div>
<!-- row 4 — description --> <!-- row 4 — description -->
<div class="session-form__row"> <div class="session-form__row">
<TextareaField <TextareaField
@@ -95,11 +108,23 @@
:accept="contentAccept" :accept="contentAccept"
:multiple="false" :multiple="false"
:max-files="1" :max-files="1"
:disabled="!form.contentType" :max-size="contentMaxSize"
:disabled="!form.contentType || contentUploadMutation.isPending.value"
@select="onContentSelect" @select="onContentSelect"
@remove="onContentRemove" @remove="onContentRemove"
@error="onContentError" @error="onContentError"
/> />
<div v-if="contentUploadMutation.isPending.value" class="session-form__upload-progress">
<div class="session-form__upload-progress-track">
<div
class="session-form__upload-progress-bar"
:style="{ width: contentUploadProgress + '%' }"
/>
</div>
<span class="session-form__upload-progress-text">
{{ contentUploadProgress }}% بارگذاری شده
</span>
</div>
</div> </div>
</div> </div>
@@ -162,7 +187,12 @@ import TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema' import { sessionSchema } from '@/features/admin/sessions/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums' import {
COURSE_CONTENT_TYPE,
COURSE_CONTENT_TYPE_ACCEPT,
COURSE_CONTENT_TYPE_MAX_SIZE,
SESSION_TYPE,
} from '@/enums'
import { import {
adminSessionsKeys, adminSessionsKeys,
useAddAdminSessionMutation, useAddAdminSessionMutation,
@@ -175,6 +205,7 @@ const router = useRouter()
const queryClient = useQueryClient() 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 presetCourseId = computed(() => (route.query.courseId ? Number(route.query.courseId) : null))
const isEditMode = computed(() => !!sessionId.value) const isEditMode = computed(() => !!sessionId.value)
const sessionTypeOptions = [ const sessionTypeOptions = [
@@ -189,10 +220,11 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
const form = ref({ const form = ref({
title: '', title: '',
courseId: '', courseId: presetCourseId.value ?? '',
contentType: '', contentType: '',
type: '', type: '',
link: '', link: '',
embedCode: '',
description: '', description: '',
}) })
@@ -200,8 +232,12 @@ const image = ref(null)
const contentFiles = ref([]) const contentFiles = ref([])
const coverMediaId = ref(null) const coverMediaId = ref(null)
const contentMediaId = ref(null) const contentMediaId = ref(null)
const contentUploadProgress = ref(0)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*') const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const contentMaxSize = computed(
() => COURSE_CONTENT_TYPE_MAX_SIZE[form.value.contentType] || 50 * 1024 * 1024
)
const { validate, validateAt, errors } = useYup(sessionSchema) const { validate, validateAt, errors } = useYup(sessionSchema)
@@ -228,7 +264,10 @@ const onContentTypeChange = () => {
} }
const onSessionTypeChange = () => { const onSessionTypeChange = () => {
if (form.value.type !== 'online') form.value.link = '' if (form.value.type !== 'online') {
form.value.link = ''
form.value.embedCode = ''
}
} }
const { data: existingSession } = useAdminSessionQuery(sessionId, { const { data: existingSession } = useAdminSessionQuery(sessionId, {
@@ -257,6 +296,7 @@ watch(
contentType, contentType,
type: session.type ?? '', type: session.type ?? '',
link: session.link ?? '', link: session.link ?? '',
embedCode: session.embedCode ?? '',
description: session.description ?? '', description: session.description ?? '',
} }
coverMediaId.value = coverMedia?.id ?? null coverMediaId.value = coverMedia?.id ?? null
@@ -276,7 +316,8 @@ watch(
{ immediate: true } { immediate: true }
) )
const uploadMutation = useUploadMediaMutation() const contentUploadMutation = useUploadMediaMutation()
const coverUploadMutation = useUploadMediaMutation()
const purposeForContentType = (contentType) => { const purposeForContentType = (contentType) => {
if (contentType === 'video') return 'video' if (contentType === 'video') return 'video'
@@ -287,13 +328,20 @@ const purposeForContentType = (contentType) => {
const onContentSelect = async (files) => { const onContentSelect = async (files) => {
const file = files?.[0] const file = files?.[0]
if (!file) return if (!file) return
contentUploadProgress.value = 0
try { try {
const formData = objectToFormData({ const formData = objectToFormData({
file, file,
purpose: purposeForContentType(form.value.contentType), purpose: purposeForContentType(form.value.contentType),
context: 'session', context: 'session',
}) })
const response = await uploadMutation.mutateAsync(formData) const response = await contentUploadMutation.mutateAsync({
formData,
onUploadProgress: (event) => {
if (!event.total) return
contentUploadProgress.value = Math.round((event.loaded / event.total) * 100)
},
})
const payload = response?.data ?? response const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }] contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
@@ -313,7 +361,7 @@ const onContentError = (msg) => toast.error(msg)
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'session' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
const response = await uploadMutation.mutateAsync(formData) const response = await coverUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload } image.value = { url: payload?.url, ...payload }
coverMediaId.value = payload?.id ?? null coverMediaId.value = payload?.id ?? null
@@ -331,6 +379,7 @@ const buildPayload = (values) => {
courseId: values.courseId, courseId: values.courseId,
type: values.type, type: values.type,
link: values.type === 'online' ? values.link : undefined, link: values.type === 'online' ? values.link : undefined,
embedCode: values.type === 'online' ? values.embedCode : undefined,
description: values.description, description: values.description,
mediaIds, mediaIds,
} }
@@ -346,16 +395,34 @@ const buildPayload = (values) => {
const addMutation = useAddAdminSessionMutation() const addMutation = useAddAdminSessionMutation()
const updateMutation = useUpdateAdminSessionMutation() const updateMutation = useUpdateAdminSessionMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value) const submitting = computed(
() =>
contentUploadMutation.isPending.value ||
coverUploadMutation.isPending.value ||
addMutation.isPending.value ||
updateMutation.isPending.value
)
const onSubmit = async () => { const onSubmit = async () => {
if (contentUploadMutation.isPending.value || coverUploadMutation.isPending.value) {
toast.info('لطفاً تا پایان بارگذاری محتوای جلسه منتظر بمانید.')
return
}
const { isValid } = await validate(form.value) const { isValid } = await validate(form.value)
if (!isValid) return if (!isValid) return
if (form.value.contentType && contentMediaId.value == null) {
toast.error('برای نوع محتوای انتخاب‌شده، فایل جلسه را بارگذاری کنید.')
return
}
const payload = buildPayload(form.value) const payload = buildPayload(form.value)
if (isEditMode.value) { try {
await updateMutation.mutateAsync({ id: sessionId.value, payload }) if (isEditMode.value) {
} else { await updateMutation.mutateAsync({ id: sessionId.value, payload })
await addMutation.mutateAsync(payload) } else {
await addMutation.mutateAsync(payload)
}
} catch {
return
} }
// The list query is inactive while we're on the form page, and the global // The list query is inactive while we're on the form page, and the global
// `refetchOnMount: false` means navigating back won't refetch a merely-stale // `refetchOnMount: false` means navigating back won't refetch a merely-stale
@@ -448,6 +515,38 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
color: var(--color-prim-gray); color: var(--color-prim-gray);
} }
&__upload-progress {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: -0.25rem;
margin-bottom: 0.75rem;
}
&__upload-progress-track {
flex: 1;
height: 0.5rem;
border-radius: 999px;
background: var(--color-thd-gray);
overflow: hidden;
}
&__upload-progress-bar {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.2s ease;
}
&__upload-progress-text {
flex-shrink: 0;
font-family: var(--font-family-en);
font-size: 0.75rem;
color: var(--color-sec-gray);
min-width: 3.5rem;
text-align: left;
}
&__preview { &__preview {
place-items: center center; place-items: center center;
} }
@@ -109,7 +109,12 @@ const onFilterApply = () => resetPagination()
const onFilterReset = () => resetPagination() const onFilterReset = () => resetPagination()
const onAdd = () => { const onAdd = () => {
router.push({ name: 'admin-add-session' }).catch(() => {}) router
.push({
name: 'admin-add-session',
query: routeCourseId.value ? { courseId: routeCourseId.value } : undefined,
})
.catch(() => {})
} }
const onEdit = (session) => { const onEdit = (session) => {
@@ -9,5 +9,6 @@ export const sessionSchema = object().shape({
then: (schema) => schema.required(), then: (schema) => schema.required(),
otherwise: (schema) => schema.nullable().notRequired(), otherwise: (schema) => schema.nullable().notRequired(),
}), }),
embedCode: string().nullable().notRequired(),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
}) })
@@ -1,8 +1,8 @@
<template> <template>
<div class="course-item"> <div class="course-item">
<div class="course-item__main"> <div class="course-item__main">
<div v-if="course.image" class="course-item__image"> <div v-if="course.coverUrl" class="course-item__image">
<img :src="course.image" :alt="course.title" /> <img :src="course.coverUrl" :alt="course.title" />
</div> </div>
<div v-else class="course-item__image course-item__image--placeholder"> <div v-else class="course-item__image course-item__image--placeholder">
<SvgIcon name="book" :size="22" color="#bcbcbc" /> <SvgIcon name="book" :size="22" color="#bcbcbc" />
@@ -12,23 +12,18 @@
<div class="term-item__dates"> <div class="term-item__dates">
<div class="term-item__date"> <div class="term-item__date">
<span class="term-item__date-label">تاریخ شروع:</span> <span class="term-item__date-label">تاریخ ایجاد:</span>
<span class="term-item__date-value">{{ startDate || '—' }}</span> <span class="term-item__date-value">{{ createdDate || '—' }}</span>
</div> </div>
<div class="term-item__date">
<span class="term-item__date-label">تاریخ پایان:</span>
<span class="term-item__date-value">{{ endDate || '—' }}</span>
</div>
</div>
<div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge">
<span class="term-item__status-dot" />
غیرفعال
</span>
</div> </div>
<div class="term-item__actions"> <div class="term-item__actions">
<div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge">
<span class="term-item__status-dot" />
غیرفعال
</span>
</div>
<ToggleSwitch <ToggleSwitch
:model-value="!!term.isActive" :model-value="!!term.isActive"
@update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })" @update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })"
@@ -80,8 +75,7 @@ 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(() => formatJalaaliDate(props.term.startsAt) || '') const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || '')
const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -171,10 +165,6 @@ const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
font-size: 0.75rem; font-size: 0.75rem;
} }
&__status {
flex: 0 0 auto;
}
&__status-badge { &__status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -196,6 +186,7 @@ const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
&__actions { &__actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
@@ -1,7 +1,7 @@
<template> <template>
<BasicModal <BasicModal
title="افزودن دانشجو" title="افزودن طلبه"
title-en="Add Student" title-en="Add Seminarian"
width="95%" width="95%"
max-width="42rem" max-width="42rem"
min-width="auto" min-width="auto"
@@ -15,7 +15,7 @@
v-model="searchInput" v-model="searchInput"
type="text" type="text"
class="add-term-student__search-input" class="add-term-student__search-input"
placeholder="اسم دانشجو را وارد نمایید" placeholder="اسم طلبه را وارد نمایید"
@input="onSearchInput" @input="onSearchInput"
/> />
</div> </div>
@@ -56,7 +56,7 @@
</div> </div>
</div> </div>
<NoItems v-else title="بدون نتیجه" desc="دانشجویی یافت نشد." /> <NoItems v-else title="بدون نتیجه" desc="طلبه‌ای یافت نشد." />
<div class="add-term-student__divider" /> <div class="add-term-student__divider" />
@@ -163,8 +163,6 @@ 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 {
@@ -21,14 +21,10 @@
<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="formatJalaaliDate(term.startsAt) || '-'" :numeric-desc="formatJalaaliDate(term.createdAt) || '-'"
/> />
<LineInfoBlock <LineInfoBlock title="تعداد طلبه‌ها" :numeric-desc="term.studentsCount ?? 0" />
title="تاریخ پایان"
:numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
/>
<LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" />
<LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" /> <LineInfoBlock title="تعداد دوره‌ها" :numeric-desc="term.coursesCount ?? 0" />
<LineInfoBlock title="وضعیت" :desc="term.isActive ? 'فعال' : 'غیرفعال'" /> <LineInfoBlock title="وضعیت" :desc="term.isActive ? 'فعال' : 'غیرفعال'" />
<div v-if="term.description" class="term-details__cell--full"> <div v-if="term.description" class="term-details__cell--full">
@@ -41,9 +37,9 @@
<TabsBlock :tabs="tabs" v-model="activeTab"> <TabsBlock :tabs="tabs" v-model="activeTab">
<template #students> <template #students>
<div class="term-details__panel-header"> <div class="term-details__panel-header">
<LineTitleBlock title="لیست دانشجویان این ترم" title-en="Students List" /> <LineTitleBlock title="لیست طلبه‌های این ترم" title-en="Seminarians List" />
<BaseButton <BaseButton
text="افزودن دانشجو" text="افزودن طلبه"
custom-class="term-details__panel-btn" custom-class="term-details__panel-btn"
@click="onOpenAddStudent" @click="onOpenAddStudent"
> >
@@ -76,7 +72,7 @@
</div> </div>
</div> </div>
</div> </div>
<NoItems v-else title="متاسفیم" desc="دانشجویی برای نمایش وجود ندارد." /> <NoItems v-else title="متاسفیم" desc="طلبه‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="studentPaginationMeta" @update:page="setStudentPage" /> <PaginationBlock :pagination="studentPaginationMeta" @update:page="setStudentPage" />
</template> </template>
@@ -161,7 +157,7 @@ const { data: term } = useAdminTermQuery(termId, {
const tabs = [ const tabs = [
{ name: 'courses', label: 'دوره‌ها', icon: 'list-bullets' }, { name: 'courses', label: 'دوره‌ها', icon: 'list-bullets' },
{ name: 'students', label: 'دانشجویان', icon: 'users' }, { name: 'students', label: 'طلبه‌ها', icon: 'users' },
] ]
const activeTab = ref('courses') const activeTab = ref('courses')
@@ -30,24 +30,6 @@
</template> </template>
</TextField> </TextField>
</div> </div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.startsAt"
name="startsAt"
label="تاریخ شروع"
:min="todayIso"
:error="errors.startsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.endsAt"
name="endsAt"
label="تاریخ پایان"
:min="todayIso"
:error="errors.endsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third"> <div class="term-form__cell term-form__cell--third">
<TextField <TextField
v-model="form.minimumScore" v-model="form.minimumScore"
@@ -136,7 +118,6 @@ import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth' 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 BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { import {
adminTermsKeys, adminTermsKeys,
@@ -152,14 +133,10 @@ const queryClient = useQueryClient()
const termId = computed(() => (route.params.id ? Number(route.params.id) : null)) const termId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEditMode = computed(() => !!termId.value) const isEditMode = computed(() => !!termId.value)
const todayIso = new Date().toISOString()
const form = ref({ const form = ref({
title: '', title: '',
description: '', description: '',
isActive: true, isActive: true,
startsAt: '',
endsAt: '',
minimumScore: '', minimumScore: '',
score: '', score: '',
coverMediaId: null, coverMediaId: null,
@@ -181,8 +158,6 @@ watch(
title: term.title || '', title: term.title || '',
description: term.description || '', description: term.description || '',
isActive: term.isActive ?? true, isActive: term.isActive ?? true,
startsAt: term.startsAt || '',
endsAt: term.endsAt || '',
minimumScore: term.minimumScore ?? '', minimumScore: term.minimumScore ?? '',
score: term.score ?? '', score: term.score ?? '',
} }
+2 -4
View File
@@ -4,13 +4,11 @@ export const termSchema = object().shape({
title: string().required().min(3), title: string().required().min(3),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
isActive: boolean().default(true), isActive: boolean().default(true),
startsAt: string().required(),
endsAt: string().required(),
minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(), minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
score: number().typeError('کل نمره آزمون باید عدد باشد').required(), score: number().typeError('کل نمره آزمون باید عدد باشد').required(),
coverMediaId: number().nullable().notRequired(), coverMediaId: number().typeError('شناسه تصویر جلد باید عدد باشد').nullable().notRequired(),
}) })
export const addTermStudentSchema = object().shape({ export const addTermStudentSchema = object().shape({
studentIds: array().of(mixed()).min(1, 'حداقل یک دانشجو را انتخاب کنید').required(), studentIds: array().of(mixed()).min(1, 'حداقل یک طلبه را انتخاب کنید').required(),
}) })
@@ -0,0 +1,392 @@
<template>
<div class="user-evaluation">
<div class="user-evaluation__header">
<LineTitleBlock
class="user-evaluation__title"
title="سیستم ارزیابی کاربر"
title-en="User Evaluation System"
/>
<BaseButton
text="دانلود PDF"
:loading="pdfLoading"
:disabled="isLoading || !evaluation"
custom-class="user-evaluation__pdf-btn"
data-pdf-ignore
@click="onSaveAsPdf"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="2" :cols-per-row="3" />
<div v-else-if="evaluation" ref="pdfContent" class="user-evaluation__content">
<header class="user-evaluation__pdf-header">
<h1>سیستم ارزیابی کاربر</h1>
<p>{{ fullName }}</p>
</header>
<section class="user-evaluation__section">
<p class="user-evaluation__section-title">مشخصات هویتی</p>
<div class="user-evaluation__identity-grid">
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
<LineInfoBlock title="شماره تماس" :numeric-desc="user?.phone || '—'" />
<LineInfoBlock title="کد ملی" :numeric-desc="user?.nationalCode || '—'" />
<LineInfoBlock title="نقش" :desc="roleText" />
<LineInfoBlock title="تاریخ عضویت" :numeric-desc="joinDateLabel" />
</div>
</section>
<section class="user-evaluation__section">
<p class="user-evaluation__section-title">خلاصه عملکرد</p>
<div class="user-evaluation__stats">
<div v-for="(stat, i) in stats" :key="i" class="user-evaluation__stat-card">
<div class="user-evaluation__stat-icon">
<SvgIcon :name="stat.icon" :size="20" color="var(--color-primary)" />
</div>
<div class="user-evaluation__stat-text">
<p class="user-evaluation__stat-label">{{ stat.label }}</p>
<p class="user-evaluation__stat-value">
{{ stat.value }}
<span v-if="stat.unit" class="user-evaluation__stat-unit">{{ stat.unit }}</span>
</p>
<p v-if="stat.subtitle" class="user-evaluation__stat-subtitle">
{{ stat.subtitle }}
</p>
</div>
</div>
</div>
</section>
</div>
<NoItems v-else title="موردی نیست" desc="اطلاعات ارزیابی این کاربر در دسترس نیست." />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { ROLE_LABELS } from '@/enums'
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 LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { useAdminUserEvaluationQuery } from '@/services/query/admin-users'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
const props = defineProps({
userId: { type: [Number, String], required: true },
user: { type: Object, default: null },
})
const userIdRef = computed(() => props.userId)
const { data: evaluation, isLoading } = useAdminUserEvaluationQuery(userIdRef, {
enabled: () => !!userIdRef.value,
})
const fullName = computed(() => props.user?.name || '—')
const roleText = computed(() => {
const names = (props.user?.roles || [])
.map((r) => (typeof r === 'string' ? r : r?.name))
.filter(Boolean)
if (names.length === 0) return 'بدون نقش'
return names.map((name) => ROLE_LABELS[name] || name).join('، ')
})
const joinDateLabel = computed(() => formatJalaaliDate(props.user?.createdAt) || '—')
const formatMembershipDuration = (days) => {
if (days == null) return '—'
if (days < 1) return 'کمتر از یک روز'
const years = Math.floor(days / 365)
const months = Math.floor((days % 365) / 30)
const remDays = Math.floor(days % 30)
const parts = []
if (years) parts.push(`${years} سال`)
if (months) parts.push(`${months} ماه`)
if (!years && !months) parts.push(`${remDays} روز`)
return parts.join(' و ')
}
const stats = computed(() => {
const e = evaluation.value || {}
const exams = e.exams || {}
const assignments = e.assignments || {}
return [
{
icon: 'video',
label: 'جلسات مشاهده‌شده',
value: e.sessions_viewed ?? 0,
unit: 'جلسه',
},
{
icon: 'check-square',
label: 'آزمون‌های شرکت‌کرده',
value: exams.taken ?? 0,
unit: 'آزمون',
subtitle: `قبول‌شده: ${exams.passed ?? 0}`,
},
{
icon: 'certificate',
label: 'میانگین امتیاز',
value: exams.average_score_percent ?? '—',
unit: exams.average_score_percent == null ? '' : '%',
},
{
icon: 'file',
label: 'تکالیف انجام‌شده',
value: assignments.completed ?? 0,
unit: 'تکلیف',
subtitle: `تاییدشده: ${assignments.accepted ?? 0}`,
},
{
icon: 'book',
label: 'ترم فعلی',
value: e.current_term?.title || 'بدون ترم',
unit: '',
},
{
icon: 'clock',
label: 'مدت عضویت',
value: formatMembershipDuration(e.member_since?.days),
unit: '',
},
]
})
const pdfContent = ref(null)
const pdfLoading = ref(false)
const onSaveAsPdf = async () => {
if (!pdfContent.value || isLoading.value || pdfLoading.value) return
pdfLoading.value = true
try {
await document.fonts?.ready
const pdfSource = pdfContent.value.cloneNode(true)
pdfSource.classList.add('user-evaluation__content--pdf')
pdfSource.querySelectorAll('[data-pdf-ignore]').forEach((element) => element.remove())
const html2pdfModule = await import('html2pdf.js')
const html2pdf = html2pdfModule.default || html2pdfModule
await html2pdf()
.set({
margin: [8, 8, 8, 8],
filename: `user-evaluation-${props.userId}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
},
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['.user-evaluation__section', '.user-evaluation__stat-card'],
},
})
.from(pdfSource)
.save()
} catch {
toast.error('ساخت فایل PDF با خطا مواجه شد.')
} finally {
pdfLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.user-evaluation {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: start;
&__header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.625rem;
}
&__title {
flex: 1;
min-width: 12rem;
padding: 0;
}
&__pdf-btn {
min-width: 9rem;
flex-shrink: 0;
}
&__pdf-header {
display: none;
}
&__section {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
&__section-title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.8125rem;
color: #8a8a8a;
margin: 0 0 0.625rem;
}
&__identity-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__stats {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__stat-card {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.875rem;
background: rgba(255, 255, 255, 55%);
box-shadow: 0 4px 10px -6px rgba(0, 0, 0, 8%);
border-radius: 0.875rem;
min-width: 0;
}
&__stat-icon {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
background: rgba(243, 102, 117, 8%);
display: flex;
align-items: center;
justify-content: center;
}
&__stat-text {
min-width: 0;
flex: 1;
}
&__stat-label {
margin: 0 0 0.25rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
line-height: 1.3;
color: #9a9a9a;
}
&__stat-value {
margin: 0;
font-family: var(--font-family-fa);
font-weight: 700;
font-size: 1.0625rem;
line-height: 1.4;
color: #4b4b4b;
word-break: break-word;
}
&__stat-unit {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #8a8a8a;
}
&__stat-subtitle {
margin: 0.25rem 0 0;
font-family: var(--font-family-fa);
font-size: 0.6875rem;
color: #9a9a9a;
}
&__content--pdf {
width: 100%;
padding: 0.25rem;
background: #fff;
box-sizing: border-box;
direction: rtl;
}
&__content--pdf &__pdf-header {
display: block;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
text-align: right;
break-after: avoid;
h1 {
margin: 0;
font-family: var(--font-family-fa);
font-size: 1.25rem;
font-weight: 500;
color: #4b4b4b;
}
p {
margin: 0.25rem 0 0;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #808080;
}
}
&__content--pdf &__section {
margin-bottom: 1rem;
break-inside: avoid;
page-break-inside: avoid;
}
&__content--pdf &__identity-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&__content--pdf &__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&__content--pdf &__stat-card {
break-inside: avoid;
page-break-inside: avoid;
}
}
</style>
@@ -25,12 +25,12 @@
<button <button
type="button" type="button"
class="users-table__role-badge" class="users-table__role-badge"
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`" :class="`users-table__role-badge--${roleNames(user)[0] || 'unknown'}`"
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id" :disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
@click="onRoleClick($event, user)" @click="onRoleClick($event, user)"
> >
<span class="users-table__role-dot" /> <span class="users-table__role-dot" />
<span>{{ roleLabel(user) || 'بدون نقش' }}</span> <span>{{ roleLabelsText(user) || 'بدون نقش' }}</span>
<SvgIcon name="caret-down" :size="12" color="currentColor" /> <SvgIcon name="caret-down" :size="12" color="currentColor" />
</button> </button>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot> <Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
@@ -96,15 +96,13 @@ const queryClient = useQueryClient()
const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details']) const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details'])
const roleName = (user) => { const roleNames = (user) =>
const first = user.roles?.[0] (user.roles || []).map((r) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
return typeof first === 'string' ? first : first?.name
}
const roleLabel = (user) => { const roleLabelsText = (user) => {
const name = roleName(user) const names = roleNames(user)
if (!name) return '' if (names.length === 0) return ''
return ROLES.find((r) => r.value === name)?.label || name return names.map((name) => ROLES.find((r) => r.value === name)?.label || name).join('، ')
} }
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -140,23 +138,39 @@ const onRoleClick = (event, user) => {
roleMenuOpen.value = true roleMenuOpen.value = true
} }
const onChangeRole = (targetRoleName) => { // Toggles one role's membership without touching the user's other roles —
// access must stay additive, so this can never collapse into a single-role
// replace the way the old "switch to X" action used to.
const onToggleRole = (targetRoleName, isAssigned) => {
const user = activeUser.value const user = activeUser.value
if (!user || !targetRoleName) return if (!user || !targetRoleName) return
const current = roleNames(user)
const nextRoles = isAssigned
? current.filter((r) => r !== targetRoleName)
: [...current, targetRoleName]
if (nextRoles.length === 0) return
updateRoleMutation.mutate( updateRoleMutation.mutate(
{ id: user.id, payload: { roles: [targetRoleName] } }, { id: user.id, payload: { roles: nextRoles } },
{ onSuccess: invalidateUsers } { onSuccess: invalidateUsers }
) )
} }
const roleMenuItems = computed(() => { const roleMenuItems = computed(() => {
const current = activeUser.value ? roleName(activeUser.value) : null const user = activeUser.value
return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({ if (!user) return []
key: `role-${r.value}`, const current = roleNames(user)
label: `تغییر به ${r.label}`, return ROLES.filter((r) => r.changeable).map((r) => {
icon: 'arrows-clockwise', const isAssigned = current.includes(r.value)
onClick: () => onChangeRole(r.value), const isLastRole = isAssigned && current.length <= 1
})) return {
key: `role-${r.value}`,
label: isAssigned ? `حذف نقش ${r.label}` : `افزودن نقش ${r.label}`,
icon: isAssigned ? 'close' : 'check',
danger: isAssigned,
disabled: isLastRole,
onClick: () => onToggleRole(r.value, isAssigned),
}
})
}) })
const menuItems = computed(() => { const menuItems = computed(() => {
@@ -76,6 +76,10 @@
</div> </div>
<LineInfoBlock title="گرایش/رشته تحصیلی" :desc="user.fieldOfStudy || '—'" /> <LineInfoBlock title="گرایش/رشته تحصیلی" :desc="user.fieldOfStudy || '—'" />
</div> </div>
<div class="user-details__section">
<UserEvaluationPanel :user-id="userId" :user="user" />
</div>
</template> </template>
<NoItems v-else title="یافت نشد" desc="اطلاعات این کاربر در دسترس نیست." /> <NoItems v-else title="یافت نشد" desc="اطلاعات این کاربر در دسترس نیست." />
@@ -106,6 +110,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useAdminUserQuery } from '@/services/query/admin-users' import { useAdminUserQuery } from '@/services/query/admin-users'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import UserEvaluationPanel from '@/features/admin/users/components/UserEvaluationPanel.vue'
import { import {
EDUCATION_STATUS, EDUCATION_STATUS,
GENDER, GENDER,
@@ -8,10 +8,15 @@
:show-close-button="true" :show-close-button="true"
> >
<template #default> <template #default>
<div v-if="!user" class="user-verification-details"> <div v-if="detailsLoading || !user" class="user-verification-details">
<p class="user-verification-details__loading">در حال بارگذاری…</p> <p class="user-verification-details__loading">در حال بارگذاری…</p>
</div> </div>
<div v-else class="user-verification-details"> <div v-else ref="pdfContent" class="user-verification-details">
<header class="user-verification-details__pdf-header">
<h1>اطلاعات احراز هویت کاربر</h1>
<p>{{ fullName }}</p>
</header>
<section class="user-verification-details__section"> <section class="user-verification-details__section">
<div class="user-verification-details__grid user-verification-details__grid--6"> <div class="user-verification-details__grid user-verification-details__grid--6">
<LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" /> <LineInfoBlock title="نام و نام خانوادگی" :numeric-desc="fullName" />
@@ -59,12 +64,6 @@
<section class="user-verification-details__section"> <section class="user-verification-details__section">
<LineTitleBlock title="اطلاعات شغلی" title-en="Job information" /> <LineTitleBlock title="اطلاعات شغلی" title-en="Job information" />
<div class="user-verification-details__grid user-verification-details__grid--4"> <div class="user-verification-details__grid user-verification-details__grid--4">
<div class="user-verification-details__cell user-verification-details__cell--full">
<LineInfoBlock
title="خلاصه‌ای از سوابق شغلی"
:desc="profile.workExperienceSummary || ''"
/>
</div>
<div class="user-verification-details__cell user-verification-details__cell--full"> <div class="user-verification-details__cell user-verification-details__cell--full">
<LineInfoBlock <LineInfoBlock
title="خلاصه‌ای از فعالیت‌ها در موقعیت‌های شغلی" title="خلاصه‌ای از فعالیت‌ها در موقعیت‌های شغلی"
@@ -184,15 +183,33 @@
class="user-verification-details__video" class="user-verification-details__video"
/> />
</section> </section>
<div class="user-verification-details__divider" />
<div class="user-verification-details__actions" data-pdf-ignore>
<BaseButton
text="ذخیره به صورت PDF"
:loading="pdfLoading"
custom-class="user-verification-details__pdf-btn"
@click="onSaveAsPdf"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div> </div>
</template> </template>
</BasicModal> </BasicModal>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
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 LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import VoiceRecorder from '@/components/form/VoiceRecorder.vue' import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
@@ -219,15 +236,19 @@ const modalData = computed(() => getModal('UserVerificationDetailsModal')?.data
const userId = computed(() => modalData.value.id ?? null) const userId = computed(() => modalData.value.id ?? null)
const userIdRef = computed(() => userId.value) const userIdRef = computed(() => userId.value)
const { data: fetched } = useAdminUserQuery(userIdRef, { const { data: fetched, isLoading: userLoading } = useAdminUserQuery(userIdRef, {
enabled: () => !!userIdRef.value, enabled: () => !!userIdRef.value,
}) })
const { data: registerData } = useAdminUserRegisterDataQuery(userIdRef, { const { data: registerData, isLoading: registerDataLoading } = useAdminUserRegisterDataQuery(
enabled: () => !!userIdRef.value, userIdRef,
}) {
enabled: () => !!userIdRef.value,
}
)
const user = computed(() => modalData.value.user || fetched.value || null) const user = computed(() => modalData.value.user || fetched.value || null)
const detailsLoading = computed(() => userLoading.value || registerDataLoading.value)
const profile = computed(() => ({ ...user.value?.profile, ...registerData.value })) const profile = computed(() => ({ ...user.value?.profile, ...registerData.value }))
@@ -269,6 +290,49 @@ const onlinePlatformDetails = computed(
// `faithProductionAudio` / `leaderMessageVideo` (each carries a `url`). // `faithProductionAudio` / `leaderMessageVideo` (each carries a `url`).
const faithProduction = computed(() => profile.value.faithProductionAudio || null) const faithProduction = computed(() => profile.value.faithProductionAudio || null)
const leaderMessage = computed(() => profile.value.leaderMessageVideo || null) const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
const pdfContent = ref(null)
const pdfLoading = ref(false)
const onSaveAsPdf = async () => {
if (!pdfContent.value || !userId.value || detailsLoading.value || pdfLoading.value) return
pdfLoading.value = true
try {
await document.fonts?.ready
const pdfSource = pdfContent.value.cloneNode(true)
pdfSource.classList.add('user-verification-details--pdf')
pdfSource.querySelectorAll('[data-pdf-ignore]').forEach((element) => element.remove())
const html2pdfModule = await import('html2pdf.js')
const html2pdf = html2pdfModule.default || html2pdfModule
await html2pdf()
.set({
margin: [8, 8, 8, 8],
filename: `user-verification-${userId.value}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
},
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['.user-verification-details__section'],
},
})
.from(pdfSource)
.save()
} catch {
toast.error('ساخت فایل PDF با خطا مواجه شد.')
} finally {
pdfLoading.value = false
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -276,6 +340,10 @@ const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
width: 100%; width: 100%;
text-align: start; text-align: start;
&__pdf-header {
display: none;
}
&__loading { &__loading {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
@@ -445,5 +513,61 @@ const leaderMessage = computed(() => profile.value.leaderMessageVideo || null)
&__pdf-btn { &__pdf-btn {
min-width: 12rem; min-width: 12rem;
} }
&--pdf {
width: 100%;
padding: 0.25rem;
background: #fff;
box-sizing: border-box;
direction: rtl;
}
&--pdf &__pdf-header {
display: block;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
text-align: right;
break-after: avoid;
h1 {
margin: 0;
font-family: var(--font-family-fa);
font-size: 1.25rem;
font-weight: 500;
color: #4b4b4b;
}
p {
margin: 0.25rem 0 0;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #808080;
}
}
&--pdf &__section {
margin-bottom: 1rem;
break-inside: avoid;
page-break-inside: avoid;
}
&--pdf &__grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--pdf &__grid--6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--pdf &__cell--span-3 {
grid-column: span 3;
}
&--pdf :deep(.line-info__title) {
overflow: visible;
text-overflow: initial;
white-space: normal;
}
} }
</style> </style>
+12 -10
View File
@@ -19,14 +19,15 @@
<div class="user-form__row"> <div class="user-form__row">
<div class="user-form__cell user-form__cell--third"> <div class="user-form__cell user-form__cell--third">
<SelectField <SelectField
v-model="form.role" v-model="form.roles"
name="role" name="roles"
label="نقش" label="نقش‌ها"
:multiple="true"
:options="ROLES" :options="ROLES"
option-label="label" option-label="label"
option-value="value" option-value="value"
:error="errors.role" :error="errors.roles"
@change="validateAt('role', form.role)" @change="validateAt('roles', form.roles)"
/> />
</div> </div>
<div class="user-form__cell user-form__cell--third"> <div class="user-form__cell user-form__cell--third">
@@ -265,7 +266,7 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
const initialNationalCode = ref('') const initialNationalCode = ref('')
const form = ref({ const form = ref({
role: '', roles: [],
name: '', name: '',
birthday: '', birthday: '',
nationalCode: '', nationalCode: '',
@@ -307,11 +308,12 @@ watch(
existingUser, existingUser,
(user) => { (user) => {
if (!user) return if (!user) return
const firstRole = Array.isArray(user.roles) ? user.roles[0] : null const roleValues = Array.isArray(user.roles)
const roleValue = typeof firstRole === 'object' ? firstRole?.name : firstRole ? user.roles.map((r) => (typeof r === 'object' ? r?.name : r)).filter(Boolean)
: []
form.value = { form.value = {
...form.value, ...form.value,
role: roleValue || '', roles: roleValues,
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(), name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
birthday: user.birthday || '', birthday: user.birthday || '',
nationalCode: user.nationalCode || '', nationalCode: user.nationalCode || '',
@@ -360,7 +362,7 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
const buildUpdatePayload = () => { const buildUpdatePayload = () => {
return { return {
name: form.value.name, name: form.value.name,
roles: form.value.role ? [form.value.role] : [], roles: form.value.roles,
provinceId: form.value.provinceId || null, provinceId: form.value.provinceId || null,
cityId: form.value.cityId || null, cityId: form.value.cityId || null,
marriageStatus: form.value.maritalStatus || null, marriageStatus: form.value.maritalStatus || null,
+3 -3
View File
@@ -1,4 +1,4 @@
import { number, object, string } from 'yup' import { array, number, object, string } from 'yup'
import { passwordRule } from '@/constants/rules/passwordRule' import { passwordRule } from '@/constants/rules/passwordRule'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule' import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule' import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
@@ -9,7 +9,7 @@ export const rejectionReasonSchema = object().shape({
}) })
const baseUserShape = { const baseUserShape = {
role: string().required(), roles: array().of(string().required()).min(1, 'حداقل یک نقش را انتخاب کنید').required(),
name: string().required().min(3), name: string().required().min(3),
phone: phoneNumberRule, phone: phoneNumberRule,
nationalCode: nationalCodeRule, nationalCode: nationalCodeRule,
@@ -20,7 +20,7 @@ const baseUserShape = {
provinceId: string().nullable().notRequired(), provinceId: string().nullable().notRequired(),
cityId: string().nullable().notRequired(), cityId: string().nullable().notRequired(),
address: string().nullable().notRequired(), address: string().nullable().notRequired(),
avatarMediaId: number().nullable().notRequired(), avatarMediaId: number().typeError('شناسه تصویر پروفایل باید عدد باشد').nullable().notRequired(),
} }
export const userAddSchema = object().shape({ export const userAddSchema = object().shape({
@@ -1,6 +1,6 @@
<template> <template>
<div class="auth-copyright"> <div class="auth-copyright">
<a href="https://mugweb.ir/" target="_blank" rel="noopener"> <a href="https://madresebanoo.ir/" target="_blank" rel="noopener">
<p class="auth-copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست</p> <p class="auth-copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست</p>
<p class="auth-copyright__en">All Rights Reserved. 2026</p> <p class="auth-copyright__en">All Rights Reserved. 2026</p>
</a> </a>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="auth-heading"> <div class="auth-heading">
<img :src="logo" alt="banu" class="auth-heading__logo" /> <img :src="logo" alt="مدرسه بانو" class="auth-heading__logo" />
<h2 class="auth-heading__title">{{ title }}</h2> <h2 class="auth-heading__title">{{ title }}</h2>
<p v-if="step === 'otp'" class="auth-heading__subtitle"> <p v-if="step === 'otp'" class="auth-heading__subtitle">
<span class="auth-heading__phone-label">ارسال کد فعال‌سازی به شماره</span> <span class="auth-heading__phone-label">ارسال کد فعال‌سازی به شماره</span>
@@ -2,7 +2,7 @@
<div class="pattern-section"> <div class="pattern-section">
<div class="pattern-section__bg" :style="{ backgroundImage: `url(${bgImage})` }" /> <div class="pattern-section__bg" :style="{ backgroundImage: `url(${bgImage})` }" />
<div class="pattern-section__front"> <div class="pattern-section__front">
<img :src="frontImage" alt="banu" /> <img :src="frontImage" alt="مدرسه بانو" />
</div> </div>
</div> </div>
</template> </template>
@@ -144,7 +144,7 @@ const onResend = async () => {
const body = { phone: props.phoneNumber } const body = { phone: props.phoneNumber }
if (props.mode === 'forgotPassword') await forgotMutation.mutateAsync(body) if (props.mode === 'forgotPassword') await forgotMutation.mutateAsync(body)
else if (props.mode === 'loginWithCode') await requestLoginOtpMutation.mutateAsync(body) else if (props.mode === 'loginWithCode') await requestLoginOtpMutation.mutateAsync(body)
else await resendCodeMutation.mutateAsync({ phoneNumber: props.phoneNumber }) else await resendCodeMutation.mutateAsync(body)
} }
</script> </script>
@@ -28,7 +28,7 @@
<SelectField <SelectField
v-model="form.universityLevel" v-model="form.universityLevel"
name="universityLevel" name="universityLevel"
label="آخرین مدرک تحصیلی" label="آخرین مدرک تحصیلی دانشگاهی"
:options="universityLevelOptions" :options="universityLevelOptions"
option-label="label" option-label="label"
option-value="value" option-value="value"
@@ -65,20 +65,6 @@
<LineTitleBlock title="مشخصات شغلی" title-en="Job Details" /> <LineTitleBlock title="مشخصات شغلی" title-en="Job Details" />
<div class="educational-info__row"> <div class="educational-info__row">
<div class="educational-info__cell educational-info__cell--full">
<TextareaField
v-model="form.workExperienceSummary"
name="workExperienceSummary"
label="خلاصه ای از سوابق شغلی خود را ذکر بفرمایید."
:row="isMobile ? 5 : 1"
:error="errors.workExperienceSummary"
@blur="validateAt('workExperienceSummary', form.workExperienceSummary)"
>
<template #appendIcon>
<SvgIcon name="file" :size="20" color="var(--color-thd-gray)" />
</template>
</TextareaField>
</div>
<div class="educational-info__cell educational-info__cell--full"> <div class="educational-info__cell educational-info__cell--full">
<TextareaField <TextareaField
v-model="form.activitySummary" v-model="form.activitySummary"
@@ -103,9 +89,9 @@
<script setup> <script setup>
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from '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 { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -138,6 +124,8 @@ const universityLevelOptions = Object.entries(UNIVERSITY_LEVEL).map(([value, lab
const form = ref({ ...store.educational }) const form = ref({ ...store.educational })
watch(form, (value) => store.updateSection('educational', value), { deep: true })
const { validate, validateAt, errors } = useYup(educationalInformationSchema) const { validate, validateAt, errors } = useYup(educationalInformationSchema)
const saveRegisterMutation = useSaveRegisterDataMutation() const saveRegisterMutation = useSaveRegisterDataMutation()
@@ -150,8 +138,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION) store.markStepAsCompleted(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
store.goToNextStep() store.goToNextStep()
} }
store.updateCurrentStep(STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -76,9 +76,9 @@
<script setup> <script setup>
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useUploadMediaMutation } from '@/services/query/auth' import { useUploadMediaMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue' import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
@@ -170,9 +170,6 @@ const onStart = async () => {
try { try {
const constraints = props.kind === 'video' ? { audio: true, video: true } : { audio: true } const constraints = props.kind === 'video' ? { audio: true, video: true } : { audio: true }
mediaStream = await navigator.mediaDevices.getUserMedia(constraints) mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
chunks = [] chunks = []
recorderType = pickRecorderType(props.kind) recorderType = pickRecorderType(props.kind)
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime }) mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
@@ -180,8 +177,16 @@ const onStart = async () => {
if (e.data?.size > 0) chunks.push(e.data) if (e.data?.size > 0) chunks.push(e.data)
} }
mediaRecorder.onstop = () => handleStop() mediaRecorder.onstop = () => handleStop()
mediaRecorder.start()
isRecording.value = true isRecording.value = true
// The live video is conditionally rendered by isRecording, so wait until
// Vue mounts it before attaching the camera stream.
await nextTick()
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
mediaRecorder.start()
elapsedMs.value = 0 elapsedMs.value = 0
timerStart = Date.now() timerStart = Date.now()
timerHandle = setInterval(() => { timerHandle = setInterval(() => {
@@ -75,6 +75,51 @@
:error="errors.gender" :error="errors.gender"
/> />
</div> </div>
<div class="personal-info__cell">
<TextField
v-model="form.childrenCount"
name="childrenCount"
label="تعداد فرزند"
inputmode="numeric"
:convert-digits="true"
:error="errors.childrenCount"
@blur="validateAt('childrenCount', form.childrenCount)"
>
<template #appendIcon>
<SvgIcon name="users-three" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.seminaryCode"
name="seminaryCode"
label="کد حوزوی"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryCode"
@blur="validateAt('seminaryCode', form.seminaryCode)"
>
<template #appendIcon>
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.seminaryServiceCenterCode"
name="seminaryServiceCenterCode"
label="کد مرکز خدمات حوزه"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryServiceCenterCode"
@blur="validateAt('seminaryServiceCenterCode', form.seminaryServiceCenterCode)"
>
<template #appendIcon>
<SvgIcon name="check-square" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
</div> </div>
<LineTitleBlock title="اطلاعات تماس" title-en="Contact Details" /> <LineTitleBlock title="اطلاعات تماس" title-en="Contact Details" />
@@ -95,6 +140,36 @@
</template> </template>
</TextField> </TextField>
</div> </div>
<div class="personal-info__cell">
<TextField
v-model="form.virtualPhoneNumber"
name="virtualPhoneNumber"
label="شماره تلفن مجازی"
inputmode="numeric"
:convert-digits="true"
:error="errors.virtualPhoneNumber"
@blur="validateAt('virtualPhoneNumber', form.virtualPhoneNumber)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell">
<TextField
v-model="form.postalCode"
name="postalCode"
label="کد پستی"
inputmode="numeric"
:convert-digits="true"
:error="errors.postalCode"
@blur="validateAt('postalCode', form.postalCode)"
>
<template #appendIcon>
<SvgIcon name="map-pin-simple-area" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="personal-info__cell"> <div class="personal-info__cell">
<SelectField <SelectField
v-model="form.provinceId" v-model="form.provinceId"
@@ -141,7 +216,11 @@
<StepActions <StepActions
:show-back="false" :show-back="false"
:loading="uploadMutation.isPending.value || updateProfileMutation.isPending.value" :loading="
uploadMutation.isPending.value ||
updateProfileMutation.isPending.value ||
saveRegisterMutation.isPending.value
"
/> />
</form> </form>
</template> </template>
@@ -151,22 +230,26 @@ import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import useAuth from '@/composables/useAuth' import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { convertToJalali } from '@/utils/date-utils'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
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 LineTitleBlock from '@/components/LineTitleBlock.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ImageCropper from '@/components/form/ImageCropper.vue' import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
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 { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums' import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
import { personalInformationSchema } from '@/features/auth/schema/student-register' import { personalInformationSchema } from '@/features/auth/schema/student-register'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration' import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common' import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import { authKeys, useUpdateProfileMutation, useUploadMediaMutation } from '@/services/query/auth' import {
authKeys,
useSaveRegisterDataMutation,
useUpdateProfileMutation,
useUploadMediaMutation,
} from '@/services/query/auth'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
const { user } = useAuth() const { user } = useAuth()
@@ -185,15 +268,14 @@ const maritalStatusOptions = Object.entries(MARITAL_STATUS).map(([value, label])
})) }))
const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label })) const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label }))
const maxBirthDate = computed(() => { const maxBirthDate = (() => {
const d = new Date() const d = new Date()
d.setFullYear(d.getFullYear() - 15) d.setFullYear(d.getFullYear() - 15)
try { const year = d.getFullYear()
return convertToJalali(d.toISOString()) const month = String(d.getMonth() + 1).padStart(2, '0')
} catch { const day = String(d.getDate()).padStart(2, '0')
return d.toISOString() return `${year}-${month}-${day}`
} })()
})
const initialPhone = ref(user.value?.phone || store.personal.phone || '') const initialPhone = ref(user.value?.phone || store.personal.phone || '')
const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '') const initialNationalCode = ref(user.value?.nationalCode || store.personal.nationalCode || '')
@@ -212,10 +294,17 @@ const form = ref({
cityId: user.value?.cityId || store.personal.cityId || null, cityId: user.value?.cityId || store.personal.cityId || null,
address: address:
(typeof user.value?.address === 'string' && user.value.address) || store.personal.address || '', (typeof user.value?.address === 'string' && user.value.address) || store.personal.address || '',
postalCode: store.personal.postalCode || '',
virtualPhoneNumber: store.personal.virtualPhoneNumber || '',
seminaryCode: store.personal.seminaryCode || '',
seminaryServiceCenterCode: store.personal.seminaryServiceCenterCode || '',
childrenCount: store.personal.childrenCount ?? '',
avatar: initialAvatar, avatar: initialAvatar,
avatarId: store.personal.avatarId || '', avatarId: store.personal.avatarId || '',
}) })
watch(form, (value) => store.updateSection('personal', value), { deep: true })
const avatar = ref(initialAvatar) const avatar = ref(initialAvatar)
const { validate, validateAt, errors } = useYup(personalInformationSchema) const { validate, validateAt, errors } = useYup(personalInformationSchema)
@@ -227,8 +316,8 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value, enabled: () => !!provinceIdRef.value,
}) })
const onProvinceChange = () => { const onProvinceChange = (provinceId) => {
provinceIdRef.value = form.value.provinceId provinceIdRef.value = provinceId
form.value.cityId = null form.value.cityId = null
} }
@@ -258,6 +347,7 @@ const onAvatarCropped = async (file) => {
const onAvatarError = (msg) => toast.error(msg) const onAvatarError = (msg) => toast.error(msg)
const updateProfileMutation = useUpdateProfileMutation() const updateProfileMutation = useUpdateProfileMutation()
const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
@@ -280,13 +370,18 @@ const onSubmit = async () => {
: {}), : {}),
} }
await updateProfileMutation.mutateAsync(body) await updateProfileMutation.mutateAsync(body)
await saveRegisterMutation.mutateAsync({
postalCode: payload.postalCode,
virtualPhoneNumber: payload.virtualPhoneNumber,
seminaryCode: payload.seminaryCode,
seminaryServiceCenterCode: payload.seminaryServiceCenterCode,
childrenCount: payload.childrenCount,
})
await queryClient.invalidateQueries({ queryKey: authKeys.me() }) await queryClient.invalidateQueries({ queryKey: authKeys.me() })
store.personal = { ...store.personal, ...next } store.personal = { ...store.personal, ...next }
store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION) store.markStepAsCompleted(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
store.goToNextStep() store.goToNextStep()
} }
store.updateCurrentStep(STUDENT_REGISTRATION.PERSONAL_INFORMATION)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -1,6 +1,6 @@
<template> <template>
<form class="professional-info" @submit.prevent="onSubmit"> <form class="professional-info" @submit.prevent="onSubmit">
<LineTitleBlock title="مشخصات شغلی" title-en="Job Details" /> <LineTitleBlock title="مشخصات تبلیغی" title-en="Propagation Details" />
<div class="professional-info__row"> <div class="professional-info__row">
<div class="professional-info__cell professional-info__cell--5"> <div class="professional-info__cell professional-info__cell--5">
<TextField <TextField
@@ -49,7 +49,7 @@
<SelectField <SelectField
v-model="form.propagationPlatforms" v-model="form.propagationPlatforms"
name="propagationPlatforms" name="propagationPlatforms"
label="در چه بستر هایی فعالیت تبلیغی داشته اید؟" label="در چه بستر هایی فعالیت تبلیغی داشته اید؟ (امکان انتخاب چند گزینه وجود دارد)"
:options="platformOptions" :options="platformOptions"
option-label="label" option-label="label"
option-value="value" option-value="value"
@@ -111,10 +111,10 @@ 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 LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums' import { PROPAGATION_PLATFORM, STUDENT_REGISTRATION } from '@/enums'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { professionalInformationSchema } from '@/features/auth/schema/student-register' import { professionalInformationSchema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration' import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -150,6 +150,8 @@ const form = ref({
onlinePlatformDetails: seedOnline, onlinePlatformDetails: seedOnline,
}) })
watch(form, (value) => store.updateSection('professional', value), { deep: true })
const hasOther = computed(() => form.value.propagationPlatforms.includes('other')) const hasOther = computed(() => form.value.propagationPlatforms.includes('other'))
const hasOnline = computed(() => form.value.propagationPlatforms.includes('online')) const hasOnline = computed(() => form.value.propagationPlatforms.includes('online'))
@@ -195,8 +197,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION) store.markStepAsCompleted(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
store.goToNextStep() store.goToNextStep()
} }
store.updateCurrentStep(STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -1,39 +1,63 @@
<template> <template>
<aside class="sidebar-steps"> <aside class="sidebar-steps">
<div class="sidebar-steps__mobile-logo"> <div class="sidebar-steps__mobile-logo">
<img :src="logoPinkishRed" alt="banu" /> <img :src="logoPinkishRed" alt="مدرسه بانو" />
</div> </div>
<div class="sidebar-steps__panel"> <div class="sidebar-steps__panel">
<div class="sidebar-steps__logo"> <div class="sidebar-steps__logo">
<img :src="logoWhite" alt="banu" /> <img :src="logoWhite" alt="مدرسه بانو" />
</div> </div>
<ul class="sidebar-steps__list"> <ul ref="stepsList" class="sidebar-steps__list scrollbar-thin">
<li <li v-for="tab in tabs" :key="tab.step" class="sidebar-steps__item">
v-for="tab in tabs" <button
:key="tab.key" type="button"
class="sidebar-steps__item" class="sidebar-steps__button"
:class="{ 'sidebar-steps__item--active': activeTab === tab.key }" :class="{
@click="emit('update:active-tab', tab.key)" 'sidebar-steps__button--active': activeStep === tab.step,
> 'sidebar-steps__button--completed': tab.completed,
<SvgIcon :name="tab.icon" :size="22" color="#fff" /> }"
:disabled="tab.disabled"
:aria-label="`${tab.title}، مرحله ${tab.step}`"
:aria-current="activeStep === tab.step ? 'step' : undefined"
@click="emit('select-step', tab.step)"
>
<span class="sidebar-steps__icon">
<SvgIcon :name="tab.completed ? 'check-square' : tab.icon" :size="20" color="#fff" />
</span>
<span class="sidebar-steps__label">{{ tab.title }}</span>
<span class="sidebar-steps__number">{{ tab.step }}</span>
</button>
</li> </li>
</ul> </ul>
<SidebarLogout color="#fff" stacked /> <div class="sidebar-steps__logout">
<SidebarLogout color="#fff" />
</div>
</div> </div>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import { nextTick, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue' import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({ const props = defineProps({
tabs: { type: Array, required: true }, tabs: { type: Array, required: true },
activeTab: { type: String, default: '' }, activeStep: { type: Number, default: 1 },
}) })
const emit = defineEmits(['update:active-tab']) const emit = defineEmits(['select-step'])
const stepsList = ref(null)
const scrollActiveStepIntoView = async () => {
await nextTick()
stepsList.value
?.querySelector('[aria-current="step"]')
?.scrollIntoView({ block: 'nearest', inline: 'center' })
}
watch(() => props.activeStep, scrollActiveStepIntoView, { immediate: true })
const logoWhite = gallery.logoWhite const logoWhite = gallery.logoWhite
const logoPinkishRed = gallery.logoPinkishRed const logoPinkishRed = gallery.logoPinkishRed
@@ -67,14 +91,11 @@ const logoPinkishRed = gallery.logoPinkishRed
gap: 1rem; gap: 1rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
// Fill the full-height column only on desktop; on mobile the panel is a
// content-height bar, so height:100% would overflow past the logo above it
// and collide with the page heading.
height: 100%; height: 100%;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
gap: 1.5rem; gap: 1rem;
padding: 1.25rem 0.75rem; padding: 1.25rem 0.875rem;
border-radius: 1.5rem; border-radius: 1.5rem;
} }
} }
@@ -101,30 +122,127 @@ const logoPinkishRed = gallery.logoPinkishRed
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 1rem; gap: 0.375rem;
flex: 1; flex: 1;
min-width: 0;
overflow-x: auto;
@media (min-width: 1024px) { @media (min-width: 1024px) {
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
gap: 1.25rem; gap: 0.25rem;
padding-top: 1rem; padding-top: 0.5rem;
overflow: hidden auto;
} }
} }
&__item { &__item {
cursor: pointer; flex: 0 0 auto;
opacity: 0.5; min-width: 0;
transition: opacity 0.15s ease;
@media (min-width: 1024px) {
width: 100%;
}
}
&__button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.25rem; gap: 0.5rem;
width: 2.75rem;
min-height: 2.75rem;
padding: 0.375rem;
border: 1px solid transparent;
border-radius: 0.875rem;
background: transparent;
color: #fff;
cursor: pointer;
opacity: 0.62;
transition: opacity 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&--active, @media (min-width: 1024px) {
&:hover { justify-content: flex-start;
width: 100%;
padding: 0.5rem 0.625rem;
}
&:hover:not(:disabled),
&--active {
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 14%);
border-color: rgba(255, 255, 255, 22%);
}
&--completed {
&:not(.sidebar-steps__button--active) {
opacity: 0.85;
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.32;
}
}
&__icon {
display: inline-flex;
flex-shrink: 0;
}
&__label {
display: none;
min-width: 0;
overflow: hidden;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
@media (min-width: 1024px) {
display: block;
}
}
&__number {
display: none;
margin-inline-start: auto;
font-family: var(--font-family-number-fa);
font-size: 0.75rem;
opacity: 0.75;
@media (min-width: 1024px) {
display: inline;
}
}
&__logout {
flex: 0 0 auto;
:deep(.sidebar-logout) {
padding: 0.25rem;
}
:deep(.sidebar-logout__labels) {
display: none;
}
@media (min-width: 1024px) {
border-top: 1px solid rgba(255, 255, 255, 20%);
padding-top: 0.5rem;
:deep(.sidebar-logout) {
justify-content: flex-start;
width: 100%;
padding: 0.5rem 0.625rem;
}
:deep(.sidebar-logout__labels) {
display: flex;
}
} }
} }
} }
@@ -43,7 +43,7 @@
</div> </div>
<div class="skill-five__divider" /> <div class="skill-five__divider" />
<StepActions @back="store.goToPrevStep()" /> <StepActions :loading="saveRegisterMutation.isPending.value" @back="store.goToPrevStep()" />
</form> </form>
</template> </template>
@@ -51,9 +51,10 @@
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { STUDENT_REGISTRATION } from '@/enums' import { STUDENT_REGISTRATION } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { skillAssessmentPart5Schema } from '@/features/auth/schema/student-register' import { skillAssessmentPart5Schema } from '@/features/auth/schema/student-register'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration' import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -69,17 +70,19 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const form = ref({ ...store.skill5 }) const form = ref({ ...store.skill5 })
watch(form, (value) => store.updateSection('skill5', value), { deep: true })
const { validate, validateAt, errors } = useYup(skillAssessmentPart5Schema) const { validate, validateAt, errors } = useYup(skillAssessmentPart5Schema)
const saveRegisterMutation = useSaveRegisterDataMutation()
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
await saveRegisterMutation.mutateAsync(payload)
store.skill5 = { ...store.skill5, ...payload } store.skill5 = { ...store.skill5, ...payload }
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5) store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
store.goToNextStep() store.goToNextStep()
} }
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -10,7 +10,6 @@
purpose="voice" purpose="voice"
:max-seconds="180" :max-seconds="180"
:error="errors.faithProductionAudio" :error="errors.faithProductionAudio"
@uploaded="onUploaded"
/> />
</div> </div>
<div class="skill-six__content-col"> <div class="skill-six__content-col">
@@ -23,7 +22,8 @@
<video :src="videoUrl" controls playsinline class="skill-six__video" /> <video :src="videoUrl" controls playsinline class="skill-six__video" />
</div> </div>
<div v-else class="skill-six__video-placeholder"> <div v-else class="skill-six__video-placeholder">
<p>ویدیویی در دسترس نیست</p> <p>ویدیوی راهنما هنوز بارگذاری نشده است.</p>
<p>تا زمان انتشار ویدیو، ارسال پاسخ در این مرحله اختیاری است.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -34,8 +34,8 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums' import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -55,10 +55,12 @@ const form = ref({
const { validate, errors } = useYup(skillAssessmentPart6Schema) const { validate, errors } = useYup(skillAssessmentPart6Schema)
const onUploaded = (value) => { watch(audio, (value) => {
form.value.faithProductionAudio = value form.value.faithProductionAudio = value
form.value.faithProductionId = value?.uploadId || '' form.value.faithProductionId = value?.uploadId || ''
} })
watch(form, (value) => store.updateSection('skill6', value), { deep: true })
const videoUrl = computed(() => store.registrationMedia.faithProductionVideoUrl || '') const videoUrl = computed(() => store.registrationMedia.faithProductionVideoUrl || '')
@@ -72,8 +74,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6) store.markStepAsCompleted(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
store.goToNextStep() store.goToNextStep()
} }
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -107,8 +107,8 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
&__intro { &__intro {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 300; font-weight: 400;
font-size: 0.875rem; font-size: 0.9375rem;
line-height: 1.7; line-height: 1.7;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
text-align: justify; text-align: justify;
@@ -130,11 +130,16 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6)
&__video-placeholder { &__video-placeholder {
border-radius: 1rem; border-radius: 1rem;
background: #f3f4f6; background: #f3f4f6;
color: var(--color-thd-gray); color: #666;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-size: 0.875rem; font-size: 0.9375rem;
font-weight: 400;
p + p {
margin-top: 0.5rem;
}
} }
&__divider { &__divider {
@@ -9,7 +9,6 @@
context="verification" context="verification"
:max-seconds="180" :max-seconds="180"
:error="errors.leaderMessageVideo" :error="errors.leaderMessageVideo"
@uploaded="onUploaded"
/> />
</div> </div>
<div class="skill-seven__content-col"> <div class="skill-seven__content-col">
@@ -22,7 +21,8 @@
<video :src="videoUrl" controls playsinline class="skill-seven__video" /> <video :src="videoUrl" controls playsinline class="skill-seven__video" />
</div> </div>
<div v-else class="skill-seven__video-placeholder"> <div v-else class="skill-seven__video-placeholder">
<p>ویدیویی در دسترس نیست</p> <p>ویدیوی راهنما هنوز بارگذاری نشده است.</p>
<p>تا زمان انتشار ویدیو، ارسال پاسخ در این مرحله اختیاری است.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -37,9 +37,9 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums' import { STUDENT_REGISTRATION } from '@/enums'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
@@ -59,10 +59,12 @@ const form = ref({
const { validate, errors } = useYup(skillAssessmentPart7Schema) const { validate, errors } = useYup(skillAssessmentPart7Schema)
const onUploaded = (value) => { watch(video, (value) => {
form.value.leaderMessageVideo = value form.value.leaderMessageVideo = value
form.value.leaderMessageId = value?.uploadId || '' form.value.leaderMessageId = value?.uploadId || ''
} })
watch(form, (value) => store.updateSection('skill7', value), { deep: true })
const videoUrl = computed(() => store.registrationMedia.leaderMessageVideoUrl || '') const videoUrl = computed(() => store.registrationMedia.leaderMessageVideoUrl || '')
@@ -80,8 +82,6 @@ const onSubmit = async () => {
toast.error(error?.response?.data?.message || 'عملیات با خطا مواجه شد') toast.error(error?.response?.data?.message || 'عملیات با خطا مواجه شد')
} }
} }
store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -115,8 +115,8 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
&__intro { &__intro {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 300; font-weight: 400;
font-size: 0.875rem; font-size: 0.9375rem;
line-height: 1.7; line-height: 1.7;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
text-align: justify; text-align: justify;
@@ -138,11 +138,16 @@ store.updateCurrentStep(STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7)
&__video-placeholder { &__video-placeholder {
border-radius: 1rem; border-radius: 1rem;
background: #f3f4f6; background: #f3f4f6;
color: var(--color-thd-gray); color: #666;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-size: 0.875rem; font-size: 0.9375rem;
font-weight: 400;
p + p {
margin-top: 0.5rem;
}
} }
&__divider { &__divider {
@@ -42,9 +42,9 @@ import useYup from '@/composables/useYup'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useSaveRegisterDataMutation } from '@/services/query/auth' import { useSaveRegisterDataMutation } from '@/services/query/auth'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StepActions from '@/features/auth/components/studentRegister/StepActions.vue' import StepActions from '@/features/auth/components/studentRegister/StepActions.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration' import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
@@ -73,6 +73,8 @@ const form = ref({
[props.descField]: seed[props.descField] || '', [props.descField]: seed[props.descField] || '',
}) })
watch(form, (value) => store.updateSection(props.storeKey, value), { deep: true })
const hasOther = computed(() => form.value[props.selectField] === 'other') const hasOther = computed(() => form.value[props.selectField] === 'other')
const onSelect = () => { const onSelect = () => {
@@ -91,8 +93,6 @@ const onSubmit = async () => {
store.markStepAsCompleted(props.step) store.markStepAsCompleted(props.step)
store.goToNextStep() store.goToNextStep()
} }
store.updateCurrentStep(props.step)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -115,8 +115,8 @@ store.updateCurrentStep(props.step)
p { p {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 300; font-weight: 400;
font-size: 0.875rem; font-size: 0.9375rem;
line-height: 1.7; line-height: 1.7;
color: #a7a7a7; color: #a7a7a7;
text-align: justify; text-align: justify;
@@ -45,21 +45,27 @@ const emit = defineEmits(['back'])
<style lang="scss" scoped> <style lang="scss" scoped>
.step-actions { .step-actions {
display: flex; display: flex;
gap: 0.5rem; flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
&__back { &__back {
flex: 1 1 60%; flex: 1 1 10rem;
min-width: 0;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
} }
&__next { &__next {
flex: 1 1 40%; flex: 1 1 12rem;
min-width: 0;
max-width: 16rem; max-width: 16rem;
margin-inline-start: auto; margin-inline-start: auto;
@media (min-width: 768px) { @media (max-width: 767px) {
flex: 0 0 16rem; order: -1;
flex-basis: 100%;
max-width: none;
} }
} }
@@ -67,10 +73,17 @@ const emit = defineEmits(['back'])
color: #969696; color: #969696;
padding: 0 1rem; padding: 0 1rem;
width: fit-content; width: fit-content;
font-weight: 500;
} }
&__next-btn { &__next-btn {
width: 100%; width: 100%;
min-height: 2.65rem;
font-weight: 500;
}
:deep(.base-button__text) {
font-size: 0.9375rem;
} }
} }
</style> </style>
@@ -1,6 +1,9 @@
import { ROLE_HOME_ROUTES } from '@/enums'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { MAIN_SITE_URL, ROLE_HOME_ROUTES } from '@/enums'
const shouldLandOnMainSite = (auth) =>
!auth.roles.includes('admin') && auth.user?.status === 'approved'
export const useRedirectAfterLogin = () => { export const useRedirectAfterLogin = () => {
const router = useRouter() const router = useRouter()
@@ -13,6 +16,10 @@ export const useRedirectAfterLogin = () => {
router.push(redirect).catch(() => router.push('/')) router.push(redirect).catch(() => router.push('/'))
return return
} }
if (shouldLandOnMainSite(auth)) {
window.location.href = MAIN_SITE_URL
return
}
const target = { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' } const target = { name: ROLE_HOME_ROUTES[auth.role] || 'student-dashboard' }
router.push(target).catch(() => router.push('/')) router.push(target).catch(() => router.push('/'))
} }
+171 -48
View File
@@ -2,16 +2,37 @@
<div class="student-register"> <div class="student-register">
<div class="student-register__container"> <div class="student-register__container">
<aside class="student-register__sidebar"> <aside class="student-register__sidebar">
<SideBarSteps :tabs="tabs" :active-tab="activeTab" @update:active-tab="goToStep" /> <SideBarSteps :tabs="tabs" :active-step="store.currentStep" @select-step="goToStep" />
</aside> </aside>
<main class="student-register__main"> <main class="student-register__main">
<header class="student-register__heading"> <header class="student-register__heading">
<div class="student-register__step-meta">
<span>مرحله {{ currentStepNumber }} از {{ totalSteps }}</span>
<span>{{ progressPercent }}٪</span>
</div>
<div class="student-register__progress" aria-hidden="true">
<span :style="{ width: `${progressPercent}%` }" />
</div>
<h1 class="student-register__title">{{ stepLabel.title }}</h1> <h1 class="student-register__title">{{ stepLabel.title }}</h1>
<p class="student-register__desc">{{ stepLabel.desc }}</p> <p class="student-register__desc">{{ stepLabel.desc }}</p>
</header> </header>
<component :is="currentComponent" /> <div
v-if="auth.user?.status === 'blocked'"
class="student-register__rejection"
role="alert"
>
<p class="student-register__rejection-title">احراز هویت شما توسط ادمین رد شده است.</p>
<p class="student-register__rejection-reason">
{{ auth.user?.blockReason || 'دلیلی ثبت نشده است.' }}
</p>
</div>
<div v-if="isRegistrationDataPending" class="student-register__loading" aria-live="polite">
در حال بازیابی اطلاعات ثبت‌نام…
</div>
<component :is="currentComponent" v-else />
</main> </main>
</div> </div>
@@ -20,13 +41,14 @@
</template> </template>
<script setup> <script setup>
import { useAuthStore } from '@/store/auth'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { computed, markRaw, watch } from 'vue' import { computed, markRaw, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums' import { STUDENT_REGISTRATION } from '@/enums'
import { useGetRegisterDataQuery } from '@/services/query/auth'
import SideBarSteps from '@/features/auth/components/studentRegister/SideBarSteps.vue' import SideBarSteps from '@/features/auth/components/studentRegister/SideBarSteps.vue'
import SuccessModal from '@/features/auth/components/studentRegister/SuccessModal.vue' import SuccessModal from '@/features/auth/components/studentRegister/SuccessModal.vue'
import { useStudentRegistrationStore } from '@/features/auth/store/student-registration' import { useStudentRegistrationStore } from '@/features/auth/store/student-registration'
import { useGetRegisterDataQuery, useGetRegistrationVideoQuery } from '@/services/query/auth'
import PersonalInformation from '@/features/auth/components/studentRegister/PersonalInformation.vue' import PersonalInformation from '@/features/auth/components/studentRegister/PersonalInformation.vue'
import SkillAssessmentPart1 from '@/features/auth/components/studentRegister/SkillAssessmentPart1.vue' import SkillAssessmentPart1 from '@/features/auth/components/studentRegister/SkillAssessmentPart1.vue'
import SkillAssessmentPart2 from '@/features/auth/components/studentRegister/SkillAssessmentPart2.vue' import SkillAssessmentPart2 from '@/features/auth/components/studentRegister/SkillAssessmentPart2.vue'
@@ -39,17 +61,24 @@ import EducationalInformation from '@/features/auth/components/studentRegister/E
import ProfessionalInformation from '@/features/auth/components/studentRegister/ProfessionalInformation.vue' import ProfessionalInformation from '@/features/auth/components/studentRegister/ProfessionalInformation.vue'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
const auth = useAuthStore()
const { isModal, openModal } = useModal() const { isModal, openModal } = useModal()
const { data: registerData } = useGetRegisterDataQuery() const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery()
watch( watch(
registerData, registerData,
(value) => { (value) => {
if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value) if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value)
}, },
{ immediate: true } { immediate: true, flush: 'sync' }
) )
const { data: registrationMedia } = useGetRegistrationVideoQuery({ retry: false })
watch(registrationMedia, (value) => store.setRegistrationMedia(value), {
immediate: true,
flush: 'sync',
})
const STEP_TO_COMPONENT = { const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: markRaw(PersonalInformation), [STUDENT_REGISTRATION.PERSONAL_INFORMATION]: markRaw(PersonalInformation),
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: markRaw(EducationalInformation), [STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: markRaw(EducationalInformation),
@@ -63,26 +92,6 @@ const STEP_TO_COMPONENT = {
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: markRaw(SkillAssessmentPart7), [STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: markRaw(SkillAssessmentPart7),
} }
const STEP_TO_TAB = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: 'personal',
[STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION]: 'educational',
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: 'professional',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6]: 'skill',
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7]: 'skill',
}
const TAB_TO_STEP = {
personal: STUDENT_REGISTRATION.PERSONAL_INFORMATION,
educational: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION,
professional: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION,
skill: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1,
}
const STEP_LABELS = { const STEP_LABELS = {
[STUDENT_REGISTRATION.PERSONAL_INFORMATION]: { [STUDENT_REGISTRATION.PERSONAL_INFORMATION]: {
title: 'اطلاعـــــات شخصـــــی', title: 'اطلاعـــــات شخصـــــی',
@@ -93,8 +102,8 @@ const STEP_LABELS = {
desc: 'لطفاً اطلاعات تحصیلی و شغلی خود را به‌صورت کامل و دقیق وارد کنید', desc: 'لطفاً اطلاعات تحصیلی و شغلی خود را به‌صورت کامل و دقیق وارد کنید',
}, },
[STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: { [STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION]: {
title: 'اطلاعات فعالیت تبلیغاتی و حرفه‌ای', title: 'اطلاعات فعالیت‌های تبلیغی',
desc: 'لطفاً اطلاعات تبلیغاتی و حرفه‌ای خود را به‌صورت کامل و دقیق وارد کنید', desc: 'لطفاً اطلاعات فعالیت‌های تبلیغی خود را به‌صورت کامل و دقیق وارد کنید',
}, },
[STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: { [STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1]: {
title: 'بخش مهارت (بخش اول)', title: 'بخش مهارت (بخش اول)',
@@ -126,14 +135,33 @@ const STEP_LABELS = {
}, },
} }
const tabs = [ const STEP_NAVIGATION = [
{ key: 'personal', icon: 'user' }, { step: STUDENT_REGISTRATION.PERSONAL_INFORMATION, title: 'مشخصات فردی', icon: 'user' },
{ key: 'educational', icon: 'book' }, { step: STUDENT_REGISTRATION.EDUCATIONAL_INFORMATION, title: 'اطلاعات تحصیلی', icon: 'book' },
{ key: 'professional', icon: 'file' }, { step: STUDENT_REGISTRATION.PROFESSIONAL_INFORMATION, title: 'فعالیت‌های تبلیغی', icon: 'file' },
{ key: 'skill', icon: 'check-square' }, { step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_1, title: 'مهارت ۱', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_2, title: 'مهارت ۲', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_3, title: 'مهارت ۳', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_4, title: 'مهارت ۴', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_5, title: 'مهارت ۵', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_6, title: 'مهارت ۶', icon: 'check-square' },
{ step: STUDENT_REGISTRATION.SKILL_ASSESSMENT_PART_7, title: 'مهارت ۷', icon: 'check-square' },
] ]
const activeTab = computed(() => STEP_TO_TAB[store.currentStep] || 'personal') const tabs = computed(() =>
STEP_NAVIGATION.map((tab) => ({
...tab,
completed: store.completedSteps.includes(tab.step),
disabled: !store.canGoToStep(tab.step),
}))
)
const totalSteps = STEP_NAVIGATION.length
const currentStepNumber = computed(() => {
const index = STEP_NAVIGATION.findIndex((item) => item.step === store.currentStep)
return index >= 0 ? index + 1 : 1
})
const progressPercent = computed(() => Math.round((currentStepNumber.value / totalSteps) * 100))
const currentComponent = computed( const currentComponent = computed(
() => () =>
@@ -145,12 +173,8 @@ const stepLabel = computed(
() => STEP_LABELS[store.currentStep] || STEP_LABELS[STUDENT_REGISTRATION.PERSONAL_INFORMATION] () => STEP_LABELS[store.currentStep] || STEP_LABELS[STUDENT_REGISTRATION.PERSONAL_INFORMATION]
) )
const goToStep = (tabKey) => { const goToStep = (step) => {
const targetStep = TAB_TO_STEP[tabKey] store.updateCurrentStep(step)
if (!targetStep) return
if (store.canGoToStep(targetStep)) {
store.updateCurrentStep(targetStep)
}
} }
watch( watch(
@@ -188,9 +212,8 @@ watch(
width: 100%; width: 100%;
@media (min-width: 1024px) { @media (min-width: 1024px) {
width: 8.333%; width: 13.5rem;
min-width: 5rem; min-width: 13.5rem;
max-width: 6.5rem;
} }
} }
@@ -206,23 +229,123 @@ watch(
} }
&__heading { &__heading {
margin-bottom: 1.25rem;
}
&__step-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
color: #6f6f6f;
font-family: var(--font-family-number-fa);
font-size: 0.875rem;
font-weight: 500;
}
&__progress {
width: 100%;
height: 0.35rem;
margin-bottom: 1rem; margin-bottom: 1rem;
overflow: hidden;
border-radius: 999px;
background: #f2f2f2;
span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width 0.2s ease;
}
} }
&__title { &__title {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 500; font-weight: 600;
font-size: 1.125rem; font-size: 1.375rem;
color: #4c4c4c; color: #3f3f3f;
margin: 0 0 0.25rem; margin: 0 0 0.375rem;
} }
&__desc { &__desc {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 400; font-weight: 400;
font-size: 0.75rem; font-size: 0.9375rem;
color: #adadad; line-height: 1.8;
color: #747474;
margin: 0; margin: 0;
} }
&__loading {
padding: 3rem 1rem;
color: #747474;
font-family: var(--font-family-fa);
font-size: 1rem;
font-weight: 500;
text-align: center;
}
&__rejection {
background: #fdecec;
border: 1px solid #f5b8b8;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
&__rejection-title {
font-family: var(--font-family-fa);
font-weight: 600;
font-size: 0.9375rem;
color: #c62828;
margin: 0 0 0.375rem;
}
&__rejection-reason {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.8;
color: #7a2f2f;
margin: 0;
text-align: justify;
}
&__main :deep(.text-field__label),
&__main :deep(.textarea-field__label),
&__main :deep(.select-field__label),
&__main :deep(.datepicker-field__label) {
color: #626262;
font-size: 0.9375rem;
font-weight: 400;
}
&__main :deep(.text-field__input),
&__main :deep(.textarea-field__input),
&__main :deep(.select-field__trigger),
&__main :deep(.datepicker-field__trigger) {
min-height: 2.6rem;
font-size: 1rem;
font-weight: 400;
}
&__main :deep(.line-title__fa) {
font-size: 1rem;
font-weight: 600;
}
&__main :deep(.line-title__en) {
font-size: 0.8125rem;
font-weight: 400;
}
&__main :deep(.text-field__error),
&__main :deep(.textarea-field__error),
&__main :deep(.select-field__error),
&__main :deep(.datepicker-field__error) {
font-size: 0.8125rem;
font-weight: 400;
}
} }
</style> </style>
+23 -8
View File
@@ -1,6 +1,5 @@
import { array, mixed, number, object, string } from 'yup' import { array, mixed, number, object, string } from 'yup'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule' import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
const requiredString = (min = 0) => { const requiredString = (min = 0) => {
@@ -16,10 +15,26 @@ export const personalInformationSchema = object().shape({
nationalCode: nationalCodeRule, nationalCode: nationalCodeRule,
maritalStatus: string().required(), maritalStatus: string().required(),
gender: string().required(), gender: string().required(),
phone: phoneNumberRule, // The verified phone is read-only here and may be returned in international
// format (for example +98912...). Do not re-validate it as a local 09 number.
phone: string().required(),
provinceId: mixed().required(), provinceId: mixed().required(),
cityId: mixed().required(), cityId: mixed().required(),
address: requiredString(10), address: requiredString(10),
postalCode: string()
.required()
.matches(/^\d{10}$/, 'کد پستی باید ۱۰ رقم باشد'),
virtualPhoneNumber: string()
.required()
.matches(/^\d{10,15}$/, 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد'),
seminaryCode: requiredString(),
seminaryServiceCenterCode: requiredString(),
childrenCount: number()
.typeError('تعداد فرزند باید به‌صورت عدد وارد شود')
.required()
.integer('تعداد فرزند باید عدد صحیح باشد')
.min(0, 'تعداد فرزند نمی‌تواند کمتر از صفر باشد')
.max(20, 'تعداد فرزند نمی‌تواند بیشتر از ۲۰ باشد'),
}) })
export const educationalInformationSchema = object().shape({ export const educationalInformationSchema = object().shape({
@@ -28,16 +43,16 @@ export const educationalInformationSchema = object().shape({
universityLevel: string().required(), universityLevel: string().required(),
universityName: requiredString(5), universityName: requiredString(5),
fieldOfStudy: requiredString(5), fieldOfStudy: requiredString(5),
workExperienceSummary: requiredString(10),
activitySummary: requiredString(10), activitySummary: requiredString(10),
}) })
export const professionalInformationSchema = object().shape({ export const professionalInformationSchema = object().shape({
propagationExperienceYears: number() propagationExperienceYears: number()
.typeError('سنوات تجربه تبلیغ باید عدد باشد') .typeError('سابقه فعالیت تبلیغی باید به‌صورت عدد وارد شود')
.required() .required()
.min(0) .integer('سابقه فعالیت تبلیغی باید عدد صحیح باشد')
.max(70), .min(0, 'سابقه فعالیت تبلیغی نمی‌تواند کمتر از صفر باشد')
.max(70, 'سابقه فعالیت تبلیغی نمی‌تواند بیشتر از ۷۰ سال باشد'),
propagationMethodDescription: requiredString(10), propagationMethodDescription: requiredString(10),
specializedTopics: requiredString(10), specializedTopics: requiredString(10),
propagationPlatforms: array() propagationPlatforms: array()
@@ -104,11 +119,11 @@ export const skillAssessmentPart5Schema = object().shape({
}) })
export const skillAssessmentPart6Schema = object().shape({ export const skillAssessmentPart6Schema = object().shape({
faithProductionAudio: mixed().required(), faithProductionAudio: mixed().nullable().notRequired(),
faithProductionId: mixed().notRequired(), faithProductionId: mixed().notRequired(),
}) })
export const skillAssessmentPart7Schema = object().shape({ export const skillAssessmentPart7Schema = object().shape({
leaderMessageVideo: mixed().required(), leaderMessageVideo: mixed().nullable().notRequired(),
leaderMessageId: mixed().notRequired(), leaderMessageId: mixed().notRequired(),
}) })
@@ -18,6 +18,11 @@ const emptyState = () => ({
provinceId: null, provinceId: null,
cityId: null, cityId: null,
address: '', address: '',
postalCode: '',
virtualPhoneNumber: '',
seminaryCode: '',
seminaryServiceCenterCode: '',
childrenCount: '',
}, },
educational: { educational: {
educationStatus: '', educationStatus: '',
@@ -25,7 +30,6 @@ const emptyState = () => ({
universityLevel: '', universityLevel: '',
universityName: '', universityName: '',
fieldOfStudy: '', fieldOfStudy: '',
workExperienceSummary: '',
activitySummary: '', activitySummary: '',
}, },
professional: { professional: {
@@ -83,6 +87,11 @@ const SECTION_FIELDS = {
'provinceId', 'provinceId',
'cityId', 'cityId',
'address', 'address',
'postalCode',
'virtualPhoneNumber',
'seminaryCode',
'seminaryServiceCenterCode',
'childrenCount',
], ],
educational: [ educational: [
'educationStatus', 'educationStatus',
@@ -90,7 +99,6 @@ const SECTION_FIELDS = {
'universityLevel', 'universityLevel',
'universityName', 'universityName',
'fieldOfStudy', 'fieldOfStudy',
'workExperienceSummary',
'activitySummary', 'activitySummary',
], ],
professional: [ professional: [
@@ -137,6 +145,21 @@ const readPersisted = () => {
} }
} }
const normalizeCompletedSteps = (steps) =>
Array.isArray(steps) ? STUDENT_REGISTRATION_ORDER.filter((step) => steps.includes(step)) : []
const firstIncompleteIndex = (steps) => {
const index = STUDENT_REGISTRATION_ORDER.findIndex((step) => !steps.includes(step))
return index === -1 ? STUDENT_REGISTRATION_ORDER.length - 1 : index
}
const resolveCurrentStep = (candidate, completed) => {
const candidateIndex = STUDENT_REGISTRATION_ORDER.indexOf(candidate)
const reachableIndex = firstIncompleteIndex(completed)
if (candidateIndex >= 0 && candidateIndex <= reachableIndex) return candidate
return STUDENT_REGISTRATION_ORDER[reachableIndex]
}
export const useStudentRegistrationStore = defineStore('studentRegistration', () => { export const useStudentRegistrationStore = defineStore('studentRegistration', () => {
const persisted = readPersisted() || {} const persisted = readPersisted() || {}
const initial = { ...emptyState(), ...persisted } const initial = { ...emptyState(), ...persisted }
@@ -152,8 +175,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
const skill5 = ref({ ...emptyState().skill5, ...persisted.skill5 }) const skill5 = ref({ ...emptyState().skill5, ...persisted.skill5 })
const skill6 = ref({ ...emptyState().skill6, ...persisted.skill6 }) const skill6 = ref({ ...emptyState().skill6, ...persisted.skill6 })
const skill7 = ref({ ...emptyState().skill7, ...persisted.skill7 }) const skill7 = ref({ ...emptyState().skill7, ...persisted.skill7 })
const currentStep = ref(initial.currentStep) const completedSteps = ref(normalizeCompletedSteps(initial.completedSteps))
const completedSteps = ref(initial.completedSteps || []) const currentStep = ref(resolveCurrentStep(initial.currentStep, completedSteps.value))
const registrationMedia = ref(initial.registrationMedia || {}) const registrationMedia = ref(initial.registrationMedia || {})
const sectionRefs = { const sectionRefs = {
@@ -199,18 +222,33 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
) )
const markStepAsCompleted = (stepKey) => { const markStepAsCompleted = (stepKey) => {
if (!completedSteps.value.includes(stepKey)) { if (STUDENT_REGISTRATION_ORDER.includes(stepKey) && !completedSteps.value.includes(stepKey)) {
completedSteps.value = [...completedSteps.value, stepKey] completedSteps.value = [...completedSteps.value, stepKey]
} }
} }
const updateCurrentStep = (stepKey) => { const updateCurrentStep = (stepKey) => {
if (stepKey) currentStep.value = stepKey if (canGoToStep(stepKey)) currentStep.value = stepKey
}
const updateSection = (section, value) => {
const target = sectionRefs[section]
if (!target || !value || typeof value !== 'object') return
target.value = { ...target.value, ...value }
}
const setRegistrationMedia = (value) => {
if (!value || typeof value !== 'object') return
registrationMedia.value = { ...registrationMedia.value, ...value }
} }
const goToNextStep = () => { const goToNextStep = () => {
const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value) const idx = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
if (idx >= 0 && idx < STUDENT_REGISTRATION_ORDER.length - 1) { if (
idx >= 0 &&
idx < STUDENT_REGISTRATION_ORDER.length - 1 &&
completedSteps.value.includes(currentStep.value)
) {
currentStep.value = STUDENT_REGISTRATION_ORDER[idx + 1] currentStep.value = STUDENT_REGISTRATION_ORDER[idx + 1]
} }
} }
@@ -221,9 +259,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
} }
const canGoToStep = (stepKey) => { const canGoToStep = (stepKey) => {
const currentIndex = STUDENT_REGISTRATION_ORDER.indexOf(currentStep.value)
const targetIndex = STUDENT_REGISTRATION_ORDER.indexOf(stepKey) const targetIndex = STUDENT_REGISTRATION_ORDER.indexOf(stepKey)
return targetIndex <= currentIndex + 1 return targetIndex >= 0 && targetIndex <= firstIncompleteIndex(completedSteps.value)
} }
const buildCompletePayload = () => ({ const buildCompletePayload = () => ({
@@ -299,6 +336,8 @@ export const useStudentRegistrationStore = defineStore('studentRegistration', ()
registrationMedia, registrationMedia,
markStepAsCompleted, markStepAsCompleted,
updateCurrentStep, updateCurrentStep,
updateSection,
setRegistrationMedia,
goToNextStep, goToNextStep,
goToPrevStep, goToPrevStep,
canGoToStep, canGoToStep,
@@ -19,6 +19,7 @@
:value="stat.value" :value="stat.value"
:unit="stat.unit" :unit="stat.unit"
:subtitle="stat.subtitle" :subtitle="stat.subtitle"
:to="stat.to"
/> />
</div> </div>
</section> </section>
@@ -91,6 +92,7 @@ const ticketStats = computed(() => {
value: toFaDigits(stats.openTickets ?? 0), value: toFaDigits(stats.openTickets ?? 0),
unit: 'تیکت', unit: 'تیکت',
subtitle: 'در انتظار پاسخ', subtitle: 'در انتظار پاسخ',
to: { name: 'counselor-consultations' },
}, },
{ {
icon: 'check-circle', icon: 'check-circle',
@@ -98,6 +100,7 @@ const ticketStats = computed(() => {
value: toFaDigits(stats.answeredTickets ?? 0), value: toFaDigits(stats.answeredTickets ?? 0),
unit: 'تیکت', unit: 'تیکت',
subtitle: 'پاسخ داده شده', subtitle: 'پاسخ داده شده',
to: { name: 'counselor-consultations' },
}, },
{ {
icon: 'check', icon: 'check',
@@ -105,6 +108,7 @@ const ticketStats = computed(() => {
value: toFaDigits(stats.closedTickets ?? 0), value: toFaDigits(stats.closedTickets ?? 0),
unit: 'تیکت', unit: 'تیکت',
subtitle: 'بسته شده', subtitle: 'بسته شده',
to: { name: 'counselor-consultations' },
}, },
] ]
}) })
@@ -202,7 +206,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem; gap: 1.5rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
width: 19.625rem; width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0; flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6; border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem; padding-inline-start: 2rem;
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'counselor', layout: 'counselor',
role: 'counselor', role: 'counselor',
title: 'سلام بر شما', title: 'سلام بر شما',
subtitle: 'به پنل مشاوره بانو خوش آمدید.', subtitle: 'به پنل مشاوره مدرسه بانو خوش آمدید.',
}, },
}, },
{ {
@@ -10,7 +10,7 @@
<template #default="{ data, close }"> <template #default="{ data, close }">
<form class="sample-modal__form" @submit.prevent="onSubmit(close)"> <form class="sample-modal__form" @submit.prevent="onSubmit(close)">
<p class="sample-modal__intro"> <p class="sample-modal__intro">
این مودال الگوی استاندارد بنو است: عنوان با این مودال الگوی استاندارد مدرسه بانو است: عنوان با
<code>LineTitleBlock</code> <code>LineTitleBlock</code>
در راست، دکمهٔ بستن داخلی در چپ. در راست، دکمهٔ بستن داخلی در چپ.
</p> </p>
@@ -82,6 +82,7 @@ const onSubmit = async (close) => {
close() close()
form.value = { subject: '', note: '' } form.value = { subject: '', note: '' }
resetErrors() resetErrors()
// eslint-disable-next-line no-console
console.info('SampleModal submit', payload) console.info('SampleModal submit', payload)
} }
</script> </script>
@@ -142,7 +142,7 @@
/> />
<BaseButton <BaseButton
variant="secondary" variant="secondary"
text="نمایش مودال نمونه (الگوی بنو)" text="نمایش مودال نمونه (الگوی مدرسه بانو)"
customClass="global-components__btn-wide" customClass="global-components__btn-wide"
@click="openModal('SampleModal', { context: 'پیش‌نمایش' })" @click="openModal('SampleModal', { context: 'پیش‌نمایش' })"
/> />
@@ -19,6 +19,7 @@
:value="stat.value" :value="stat.value"
:unit="stat.unit" :unit="stat.unit"
:subtitle="stat.subtitle" :subtitle="stat.subtitle"
:to="stat.to"
/> />
</div> </div>
</section> </section>
@@ -91,6 +92,7 @@ const requestStats = computed(() => {
value: toFaDigits(stats.totalRequests ?? 0), value: toFaDigits(stats.totalRequests ?? 0),
unit: 'درخواست', unit: 'درخواست',
subtitle: 'ثبت شده', subtitle: 'ثبت شده',
to: { name: 'missionary-requests' },
}, },
{ {
icon: 'check-circle', icon: 'check-circle',
@@ -98,6 +100,7 @@ const requestStats = computed(() => {
value: toFaDigits(stats.acceptedRequests ?? 0), value: toFaDigits(stats.acceptedRequests ?? 0),
unit: 'درخواست', unit: 'درخواست',
subtitle: 'پذیرفته شده', subtitle: 'پذیرفته شده',
to: { name: 'missionary-requests' },
}, },
{ {
icon: 'clock', icon: 'clock',
@@ -105,6 +108,7 @@ const requestStats = computed(() => {
value: toFaDigits(stats.pendingRequests ?? 0), value: toFaDigits(stats.pendingRequests ?? 0),
unit: 'درخواست', unit: 'درخواست',
subtitle: 'در انتظار', subtitle: 'در انتظار',
to: { name: 'missionary-requests' },
}, },
] ]
}) })
@@ -221,7 +225,7 @@ const calendar = useTehranCalendar()
gap: 1.5rem; gap: 1.5rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
width: 19.625rem; width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0; flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6; border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem; padding-inline-start: 2rem;
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'missionary', layout: 'missionary',
role: 'missionary', role: 'missionary',
title: 'سلام بر شما', title: 'سلام بر شما',
subtitle: 'به پنل اعزام بانو خوش آمدید.', subtitle: 'به پنل اعزام مدرسه بانو خوش آمدید.',
}, },
}, },
{ {
@@ -120,7 +120,7 @@ const downloadItems = computed(() => [
@media (min-width: 1024px) { @media (min-width: 1024px) {
flex: 1; flex: 1;
order: 3; order: 1;
min-width: 12rem; min-width: 12rem;
} }
} }
@@ -146,8 +146,8 @@ const downloadItems = computed(() => [
&__course-info { &__course-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-end;
text-align: end; text-align: right;
gap: 0.25rem; gap: 0.25rem;
min-width: 0; min-width: 0;
} }
@@ -186,6 +186,7 @@ const downloadItems = computed(() => [
background: rgba(107, 107, 107, 4%); background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
white-space: nowrap; white-space: nowrap;
align-self: center;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 2; order: 2;
@@ -222,15 +223,20 @@ const downloadItems = computed(() => [
justify-content: center; justify-content: center;
@media (min-width: 1024px) { @media (min-width: 1024px) {
order: 1; order: 3;
justify-content: flex-start; justify-content: flex-end;
} }
} }
&__download-btn { &__download-btn {
min-width: 9rem; width: 100%;
height: 2rem; height: 2rem;
padding: 0 1.25rem; padding: 0 1.25rem;
@media (min-width: 1024px) {
width: auto;
min-width: 9rem;
}
} }
} }
</style> </style>
@@ -35,6 +35,7 @@ import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import { usePagination } from '@/composables/usePagination' import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue' import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
@@ -53,7 +54,17 @@ const { data, isLoading } = useStudentCertificatesQuery(filters, pagination, {
keepPreviousData: true, keepPreviousData: true,
}) })
const certificates = computed(() => data.value?.data ?? []) // Bridge the backend payload (title/teacherName/startedAt/endedAt) into the
// display shape CertificateItem expects, without touching its template.
const certificates = computed(() =>
(data.value?.data ?? []).map((c) => ({
...c,
courseTitle: c.title ?? '',
teacherName: c.teacherName ?? '',
startDate: formatJalaaliDate(c.startedAt) || '',
endDate: formatJalaaliDate(c.endedAt) || '',
}))
)
const meta = computed(() => ({ const meta = computed(() => ({
page: pagination.value.page, page: pagination.value.page,
perPage: pagination.value.perPage, perPage: pagination.value.perPage,
@@ -25,7 +25,7 @@
</div> </div>
<div v-else class="se__empty"> <div v-else class="se__empty">
<SvgIcon name="scroll" :size="128" color="#c3c3c3" /> <SvgIcon name="scroll" :size="128" color="#c3c3c3" />
<p class="se__empty-title">دانشجوی گرامی</p> <p class="se__empty-title">طلبه گرامی</p>
<p class="se__empty-desc">شما تاکنون هیچ آزمونی را به شروع نکرده اید.</p> <p class="se__empty-desc">شما تاکنون هیچ آزمونی را به شروع نکرده اید.</p>
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@
:value="stat.value" :value="stat.value"
:unit="stat.unit" :unit="stat.unit"
:subtitle="stat.subtitle" :subtitle="stat.subtitle"
:to="stat.to"
/> />
</div> </div>
</section> </section>
@@ -90,11 +91,12 @@ const performanceStats = computed(() => {
const stats = dashboard.value.stats ?? {} const stats = dashboard.value.stats ?? {}
return [ return [
{ {
icon: 'user-sound', icon: 'video',
label: 'تعداد جلسات اعزام شده', label: 'تعداد جلسات آموزشی گذرانده‌شده',
value: toFaDigits(stats.attendedSessions ?? 0), value: toFaDigits(stats.attendedSessions ?? 0),
unit: 'جلسه', unit: 'جلسه',
subtitle: 'اعزام شده است', subtitle: 'با موفقیت گذرانده شده',
to: { name: 'student-terms' },
}, },
{ {
icon: 'video', icon: 'video',
@@ -102,6 +104,7 @@ const performanceStats = computed(() => {
value: toFaDigits(stats.enrolledTerms ?? 0), value: toFaDigits(stats.enrolledTerms ?? 0),
unit: 'دوره', unit: 'دوره',
subtitle: 'خریداری شده', subtitle: 'خریداری شده',
to: { name: 'student-terms' },
}, },
{ {
icon: 'clock', icon: 'clock',
@@ -109,6 +112,7 @@ const performanceStats = computed(() => {
value: toFaDigits(stats.hoursSpent ?? 0), value: toFaDigits(stats.hoursSpent ?? 0),
unit: 'ساعت', unit: 'ساعت',
subtitle: 'گذرانده شده', subtitle: 'گذرانده شده',
to: { name: 'student-terms' },
}, },
] ]
}) })
@@ -288,7 +292,7 @@ const calendar = buildTehranCalendar()
gap: 1.5rem; gap: 1.5rem;
@media (min-width: 1024px) { @media (min-width: 1024px) {
width: 19.625rem; width: clamp(21rem, 26vw, 24rem);
flex-shrink: 0; flex-shrink: 0;
border-inline-start: 1px solid #e6e6e6; border-inline-start: 1px solid #e6e6e6;
padding-inline-start: 2rem; padding-inline-start: 2rem;
@@ -1,5 +1,10 @@
<template> <template>
<div class="stat-card"> <component
:is="to ? RouterLink : 'div'"
:to="to || undefined"
class="stat-card"
:class="{ 'stat-card--linked': to }"
>
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" /> <SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" />
<div class="stat-card__head"> <div class="stat-card__head">
@@ -14,10 +19,11 @@
<span class="stat-card__subtitle">{{ subtitle }}</span> <span class="stat-card__subtitle">{{ subtitle }}</span>
</div> </div>
</div> </div>
</div> </component>
</template> </template>
<script setup> <script setup>
import { RouterLink } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({ defineProps({
@@ -27,6 +33,7 @@ defineProps({
value: { type: [String, Number], default: '' }, value: { type: [String, Number], default: '' },
unit: { type: String, default: '' }, unit: { type: String, default: '' },
subtitle: { type: String, default: '' }, subtitle: { type: String, default: '' },
to: { type: [String, Object], default: null },
}) })
</script> </script>
@@ -41,6 +48,23 @@ defineProps({
background: rgba(255, 255, 255, 58%); background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%); box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem; border-radius: 0.9375rem;
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&--linked {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 9px 24px rgba(0, 0, 0, 7%);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
&__corner { &__corner {
position: absolute; position: absolute;
@@ -101,6 +101,66 @@
option-value="value" option-value="value"
/> />
</div> </div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.childrenCount"
name="childrenCount"
label="تعداد فرزند"
inputmode="numeric"
:convert-digits="true"
:error="errors.childrenCount"
@blur="validateAt('childrenCount', form.childrenCount)"
>
<template #appendIcon>
<SvgIcon name="users-three" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.virtualPhoneNumber"
name="virtualPhoneNumber"
label="شماره تلفن مجازی"
inputmode="numeric"
:convert-digits="true"
:error="errors.virtualPhoneNumber"
@blur="validateAt('virtualPhoneNumber', form.virtualPhoneNumber)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.seminaryCode"
name="seminaryCode"
label="کد حوزوی"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryCode"
@blur="validateAt('seminaryCode', form.seminaryCode)"
>
<template #appendIcon>
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.seminaryServiceCenterCode"
name="seminaryServiceCenterCode"
label="کد مرکز خدمات حوزه"
inputmode="numeric"
:convert-digits="true"
:error="errors.seminaryServiceCenterCode"
@blur="validateAt('seminaryServiceCenterCode', form.seminaryServiceCenterCode)"
>
<template #appendIcon>
<SvgIcon name="check-square" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
</div> </div>
<LineTitleBlock title="مشخصات محل سکونت" title-en="Address Details" /> <LineTitleBlock title="مشخصات محل سکونت" title-en="Address Details" />
@@ -127,6 +187,21 @@
:disabled="!form.provinceId" :disabled="!form.provinceId"
/> />
</div> </div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.postalCode"
name="postalCode"
label="کد پستی"
inputmode="numeric"
:convert-digits="true"
:error="errors.postalCode"
@blur="validateAt('postalCode', form.postalCode)"
>
<template #appendIcon>
<SvgIcon name="map-pin-simple-area" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--full"> <div class="edit-profile__cell edit-profile__cell--full">
<TextareaField <TextareaField
v-model="form.address" v-model="form.address"
@@ -227,6 +302,8 @@ import { studentProfileSchema, studentResetPasswordSchema } from '@/features/stu
import { import {
authKeys, authKeys,
useGetMeQuery, useGetMeQuery,
useGetRegisterDataQuery,
useSaveRegisterDataMutation,
useUpdateProfileMutation, useUpdateProfileMutation,
useUploadMediaMutation, useUploadMediaMutation,
} from '@/services/query/auth' } from '@/services/query/auth'
@@ -262,6 +339,11 @@ const form = ref({
provinceId: '', provinceId: '',
cityId: '', cityId: '',
address: '', address: '',
postalCode: '',
virtualPhoneNumber: '',
seminaryCode: '',
seminaryServiceCenterCode: '',
childrenCount: '',
avatarMediaId: null, avatarMediaId: null,
}) })
@@ -326,6 +408,24 @@ watch(
{ immediate: true } { immediate: true }
) )
const { data: registerData } = useGetRegisterDataQuery()
watch(
registerData,
(data) => {
if (!data) return
form.value = {
...form.value,
postalCode: data.postalCode || '',
virtualPhoneNumber: data.virtualPhoneNumber || '',
seminaryCode: data.seminaryCode || '',
seminaryServiceCenterCode: data.seminaryServiceCenterCode || '',
childrenCount: data.childrenCount ?? '',
}
},
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation() const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => { const onAvatarCropped = async (file) => {
@@ -343,6 +443,7 @@ const onAvatarCropped = async (file) => {
const onAvatarError = (msg) => toast.error(msg) const onAvatarError = (msg) => toast.error(msg)
const updateMutation = useUpdateProfileMutation() const updateMutation = useUpdateProfileMutation()
const saveRegisterDataMutation = useSaveRegisterDataMutation()
const resetPasswordMutation = useUpdateProfileMutation() const resetPasswordMutation = useUpdateProfileMutation()
const onSubmit = async () => { const onSubmit = async () => {
@@ -359,7 +460,18 @@ const onSubmit = async () => {
...(payload.avatarMediaId ? { avatarMediaId: payload.avatarMediaId } : {}), ...(payload.avatarMediaId ? { avatarMediaId: payload.avatarMediaId } : {}),
} }
await updateMutation.mutateAsync(body) await updateMutation.mutateAsync(body)
await saveRegisterDataMutation.mutateAsync({
postalCode: payload.postalCode || '',
virtualPhoneNumber: payload.virtualPhoneNumber || '',
seminaryCode: payload.seminaryCode || '',
seminaryServiceCenterCode: payload.seminaryServiceCenterCode || '',
childrenCount:
payload.childrenCount == null || payload.childrenCount === ''
? ''
: Number(payload.childrenCount),
})
await queryClient.invalidateQueries({ queryKey: authKeys.me() }) await queryClient.invalidateQueries({ queryKey: authKeys.me() })
await queryClient.invalidateQueries({ queryKey: authKeys.registerData() })
toast.success('پروفایل با موفقیت به‌روز شد') toast.success('پروفایل با موفقیت به‌روز شد')
router.push({ name: 'student-dashboard' }) router.push({ name: 'student-dashboard' })
} }
@@ -15,6 +15,23 @@ export const studentProfileSchema = object().shape({
provinceId: string().nullable().notRequired(), provinceId: string().nullable().notRequired(),
cityId: string().nullable().notRequired(), cityId: string().nullable().notRequired(),
address: string().nullable().notRequired(), address: string().nullable().notRequired(),
postalCode: string()
.nullable()
.notRequired()
.matches(/^\d{10}$/, { message: 'کد پستی باید ۱۰ رقم باشد', excludeEmptyString: true }),
virtualPhoneNumber: string()
.nullable()
.notRequired()
.matches(/^\d{10,15}$/, {
message: 'شماره تلفن مجازی باید فقط شامل ۱۰ تا ۱۵ رقم باشد',
excludeEmptyString: true,
}),
seminaryCode: string().nullable().notRequired(),
seminaryServiceCenterCode: string().nullable().notRequired(),
childrenCount: string()
.nullable()
.notRequired()
.matches(/^\d+$/, { message: 'تعداد فرزند باید عدد باشد', excludeEmptyString: true }),
}) })
export const studentResetPasswordSchema = object().shape({ export const studentResetPasswordSchema = object().shape({
+1 -1
View File
@@ -7,7 +7,7 @@ export default [
layout: 'student', layout: 'student',
role: 'student', role: 'student',
title: 'سلام بر شما', title: 'سلام بر شما',
subtitle: 'به سامانه موسسه بانو خوش آمدید.', subtitle: 'به سامانه مدرسه بانو خوش آمدید.',
}, },
}, },
{ {
@@ -3,11 +3,14 @@
<form class="csf__form" @submit.prevent="onSubmit"> <form class="csf__form" @submit.prevent="onSubmit">
<div class="csf__row"> <div class="csf__row">
<div class="csf__cell"> <div class="csf__cell">
<TextField <SelectField
v-model="form.category" v-model="form.category"
name="category" name="category"
label="نوع خدمت" label="نوع خدمت"
placeholder="نوع خدمت را وارد کنید" placeholder="نوع خدمت را انتخاب کنید"
:options="serviceTypeOptions"
option-label="label"
option-value="value"
:error="errors.category" :error="errors.category"
/> />
</div> </div>
@@ -64,17 +67,19 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import * as yup from 'yup' import * as yup from 'yup'
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
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 TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue' import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth' import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useServiceTypesListQuery } from '@/services/query/service-types'
defineProps({ defineProps({
submitting: { type: Boolean, default: false }, submitting: { type: Boolean, default: false },
@@ -82,6 +87,11 @@ defineProps({
const emit = defineEmits(['submit', 'file-error']) const emit = defineEmits(['submit', 'file-error'])
const { data: serviceTypes } = useServiceTypesListQuery()
const serviceTypeOptions = computed(() =>
(serviceTypes.value ?? []).map((st) => ({ value: st.title, label: st.title }))
)
const form = ref({ const form = ref({
category: '', category: '',
subject: '', subject: '',
@@ -94,7 +104,7 @@ const form = ref({
const files = ref([]) const files = ref([])
const schema = yup.object({ const schema = yup.object({
category: yup.string().trim().required('نوع خدمت را وارد کنید'), category: yup.string().trim().required('نوع خدمت را انتخاب کنید'),
subject: yup.string().trim().required('عنوان خدمت را وارد کنید'), subject: yup.string().trim().required('عنوان خدمت را وارد کنید'),
message: yup message: yup
.string() .string()
@@ -31,21 +31,29 @@
/> />
</div> </div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio"> <div v-else-if="contentKind === 'audio'" class="ssd__audio">
<VoiceRecorder <VoiceRecorder :model-value="session?.audioUrl" disabled />
model-value="http://api.tripwisedaily.ir/storage/pending/3/test2-AmQiegjc.mp3?expires=1783677165&signature=38104395400100f1bd74594bfc64ac462c14634a8c2bc4541c988766a08ea2bd"
disabled
/>
</div> </div>
<div v-else-if="contentKind === 'text'" class="ssd__text"> <div v-else-if="contentKind === 'text'" class="ssd__text">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" /> <SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
<p class="ssd__text-body">{{ session.text }}</p> <p class="ssd__text-body">{{ session.text }}</p>
</div> </div>
<div v-else-if="contentKind === 'embed'" class="ssd__embed">
<iframe
class="ssd__embed-frame"
:srcdoc="session.embedCode"
sandbox="allow-scripts allow-popups allow-forms allow-presentation"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
<div v-else-if="contentKind === 'link'" class="ssd__link"> <div v-else-if="contentKind === 'link'" class="ssd__link">
<div class="ssd__link__box"> <div class="ssd__link__box">
<span>https://link</span> <span>{{ session?.link || 'لینکی ثبت نشده است' }}</span>
<SvgIcon name="link" :size="22" class="ssd__link-icon" /> <SvgIcon name="link" :size="22" class="ssd__link-icon" />
</div> </div>
<BaseButton text="ورود به جلسه " custom-class="ssd__link-btn"> <BaseButton text="ورود به جلسه " custom-class="ssd__link-btn" @click="openSessionLink">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
@@ -132,13 +140,17 @@ const homework = computed(() => (homeworksData.value?.data ?? [])[0] ?? null)
const contentKind = computed(() => { const contentKind = computed(() => {
const s = session.value const s = session.value
if (!s) return '' if (!s) return ''
if (s.type === 'online') return 'link' if (s.type === 'online') return s.embedCode?.trim() ? 'embed' : 'link'
if (s.videoUrl) return 'link' if (s.videoUrl) return 'video'
if (s.audioUrl) return 'audio' if (s.audioUrl) return 'audio'
if (s.text) return 'text' if (s.text) return 'text'
return '' return ''
}) })
const openSessionLink = () => {
if (session.value?.link) window.open(session.value.link, '_blank', 'noopener,noreferrer')
}
const homeworkAudio = ref(null) const homeworkAudio = ref(null)
const homeworkImage = ref(null) const homeworkImage = ref(null)
const errors = reactive({ audio: '', image: '' }) const errors = reactive({ audio: '', image: '' })
@@ -263,6 +275,18 @@ const onSubmit = async () => {
} }
} }
&__embed {
width: 100%;
}
&__embed-frame {
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
border-radius: 1rem;
background: #000;
}
&__link { &__link {
display: flex; display: flex;
align-items: center; align-items: center;
+19 -2
View File
@@ -147,8 +147,19 @@ const menuItems = computed(() => [
{ {
title: 'مدیریت خدمات', title: 'مدیریت خدمات',
icon: 'users-three', icon: 'users-three',
to: { name: 'admin-services' }, active: ['admin-services', 'admin-service-types'].includes(route.name),
active: route.name === 'admin-services', children: [
{
title: 'درخواست‌های خدمت',
to: { name: 'admin-services' },
active: route.name === 'admin-services',
},
{
title: 'انواع خدمات',
to: { name: 'admin-service-types' },
active: route.name === 'admin-service-types',
},
],
}, },
{ {
title: 'مدیریت مشاوره', title: 'مدیریت مشاوره',
@@ -162,6 +173,12 @@ const menuItems = computed(() => [
to: { name: 'admin-missionary-requests' }, to: { name: 'admin-missionary-requests' },
active: route.name === 'admin-missionary-requests', active: route.name === 'admin-missionary-requests',
}, },
{
title: 'گواهینامه‌ها',
icon: 'certificate',
to: { name: 'admin-certificates' },
active: route.name === 'admin-certificates',
},
]) ])
</script> </script>
+4 -14
View File
@@ -40,13 +40,16 @@
<script setup> <script setup>
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { useAuthStore } from '@/store/auth'
import { RouterView, useRoute } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import Copyright from '@/components/Copyright.vue' import Copyright from '@/components/Copyright.vue'
import HeaderBlock from '@/components/HeaderBlock.vue' import HeaderBlock from '@/components/HeaderBlock.vue'
import { buildMergedMenuItems } from '@/layouts/roleMenuItems'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CounselorSidebar from '@/layouts/sidebars/CounselorSidebar.vue' import CounselorSidebar from '@/layouts/sidebars/CounselorSidebar.vue'
const ui = useUIStore() const ui = useUIStore()
const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const isMobile = ref(window.innerWidth < 1024) const isMobile = ref(window.innerWidth < 1024)
@@ -59,20 +62,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'مشاوره') const pageTitle = computed(() => route.meta?.title || 'مشاوره')
const pageSubtitle = computed(() => route.meta?.subtitle || '') const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [ const menuItems = computed(() => buildMergedMenuItems(auth.roles, route))
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'counselor-dashboard' },
active: route.name === 'counselor-dashboard',
},
{
title: 'مشاوره',
icon: 'chat',
to: { name: 'counselor-consultations' },
active: route.name === 'counselor-consultations',
},
])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+4 -26
View File
@@ -38,13 +38,16 @@
<script setup> <script setup>
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { useAuthStore } from '@/store/auth'
import { RouterView, useRoute } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import Copyright from '@/components/Copyright.vue' import Copyright from '@/components/Copyright.vue'
import HeaderBlock from '@/components/HeaderBlock.vue' import HeaderBlock from '@/components/HeaderBlock.vue'
import { buildMergedMenuItems } from '@/layouts/roleMenuItems'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import StudentSidebar from '@/layouts/sidebars/StudentSidebar.vue' import StudentSidebar from '@/layouts/sidebars/StudentSidebar.vue'
const ui = useUIStore() const ui = useUIStore()
const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const isMobile = ref(window.innerWidth < 1024) const isMobile = ref(window.innerWidth < 1024)
@@ -57,32 +60,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'سیستم مدیریت اعزام') const pageTitle = computed(() => route.meta?.title || 'سیستم مدیریت اعزام')
const pageSubtitle = computed(() => route.meta?.subtitle || '') const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [ const menuItems = computed(() => buildMergedMenuItems(auth.roles, route))
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'missionary-dashboard' },
active: route.name === 'missionary-dashboard',
},
{
title: 'سامانه اعزام',
icon: 'user-sound',
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
},
{
title: 'آموزش من',
icon: 'graduation-cap',
to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'),
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets',
},
])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+4 -52
View File
@@ -38,13 +38,16 @@
<script setup> <script setup>
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { useAuthStore } from '@/store/auth'
import { RouterView, useRoute } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import Copyright from '@/components/Copyright.vue' import Copyright from '@/components/Copyright.vue'
import HeaderBlock from '@/components/HeaderBlock.vue' import HeaderBlock from '@/components/HeaderBlock.vue'
import { buildMergedMenuItems } from '@/layouts/roleMenuItems'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import StudentSidebar from '@/layouts/sidebars/StudentSidebar.vue' import StudentSidebar from '@/layouts/sidebars/StudentSidebar.vue'
const ui = useUIStore() const ui = useUIStore()
const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const isMobile = ref(window.innerWidth < 1024) const isMobile = ref(window.innerWidth < 1024)
@@ -57,58 +60,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onResize))
const pageTitle = computed(() => route.meta?.title || 'پیشخوان') const pageTitle = computed(() => route.meta?.title || 'پیشخوان')
const pageSubtitle = computed(() => route.meta?.subtitle || '') const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [ const menuItems = computed(() => buildMergedMenuItems(auth.roles, route))
{
title: 'داشبورد',
icon: 'monitor',
to: { name: 'student-dashboard' },
active: route.name === 'student-dashboard',
},
{
title: 'آموزش',
icon: 'graduation-cap',
to: { name: 'student-terms' },
active: [
'student-terms',
'student-term-details',
'student-lesson-details',
'student-session-details',
'student-exam',
'student-courses',
'student-course-details',
].includes(route.name),
},
{
title: 'خدمات',
icon: 'folder-simple-star',
to: { name: 'student-services' },
active: route.name === 'student-services',
},
{
title: 'مشاوره',
icon: 'chat-teardrop-text',
to: { name: 'student-consultants' },
active: route.name === 'student-consultants',
},
{
title: 'گواهینامه',
icon: 'certificate',
to: { name: 'student-certificates' },
active: route.name === 'student-certificates',
},
{
title: 'ویرایش پروفایل',
icon: 'user-circle-check',
to: { name: 'student-profile' },
active: route.name === 'student-profile',
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'student-tickets' },
active: route.name === 'student-tickets',
},
])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+124
View File
@@ -0,0 +1,124 @@
// Per-role sidebar sections, kept as plain builder functions so any layout
// can request the union of sections for every role a user holds — additive
// multi-role access without each layout hardcoding its own single-role list.
// Only roles with a registered builder participate in the merge; a role
// without one (e.g. admin) contributes nothing and keeps its own standalone
// menu untouched.
export const buildStudentMenuItems = (route) => [
{
title: 'داشبورد',
icon: 'monitor',
to: { name: 'student-dashboard' },
active: route.name === 'student-dashboard',
},
{
title: 'آموزش',
icon: 'graduation-cap',
to: { name: 'student-terms' },
active: [
'student-terms',
'student-term-details',
'student-lesson-details',
'student-session-details',
'student-exam',
'student-courses',
'student-course-details',
].includes(route.name),
},
{
title: 'خدمات',
icon: 'folder-simple-star',
to: { name: 'student-services' },
active: route.name === 'student-services',
},
{
title: 'مشاوره',
icon: 'chat-teardrop-text',
to: { name: 'student-consultants' },
active: route.name === 'student-consultants',
},
{
title: 'گواهینامه',
icon: 'certificate',
to: { name: 'student-certificates' },
active: route.name === 'student-certificates',
},
{
title: 'ویرایش پروفایل',
icon: 'user-circle-check',
to: { name: 'student-profile' },
active: route.name === 'student-profile',
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'student-tickets' },
active: route.name === 'student-tickets',
},
]
export const buildMissionaryMenuItems = (route) => [
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'missionary-dashboard' },
active: route.name === 'missionary-dashboard',
},
{
title: 'سامانه اعزام',
icon: 'user-sound',
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
},
{
title: 'آموزش من',
icon: 'graduation-cap',
to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'),
},
{
title: 'تیکت',
icon: 'envelope-open',
to: { name: 'missionary-tickets' },
active: route.name === 'missionary-tickets',
},
]
export const buildCounselorMenuItems = (route) => [
{
title: 'پیشخوان',
icon: 'menu',
to: { name: 'counselor-dashboard' },
active: route.name === 'counselor-dashboard',
},
{
title: 'مشاوره',
icon: 'chat',
to: { name: 'counselor-consultations' },
active: route.name === 'counselor-consultations',
},
]
const MENU_BUILDERS = {
student: buildStudentMenuItems,
missionary: buildMissionaryMenuItems,
counselor: buildCounselorMenuItems,
}
/**
* Union of every panel section unlocked by `roles`, in role order (roles
* come back from the API ordered by role id, so a user's base role's
* sections lead and each additional role's sections are appended after).
*/
export const buildMergedMenuItems = (roles, route) => {
const seen = new Set()
const items = []
for (const roleName of roles ?? []) {
const builder = MENU_BUILDERS[roleName]
if (!builder || seen.has(roleName)) continue
seen.add(roleName)
items.push(...builder(route))
}
return items
}
+98 -17
View File
@@ -1,31 +1,53 @@
<template> <template>
<aside class="admin-sidebar"> <aside class="admin-sidebar">
<div class="admin-sidebar__logo"> <div class="admin-sidebar__logo">
<img :src="logo" alt="banu" /> <a :href="MAIN_SITE_URL">
<img :src="logo" alt="مدرسه بانو" />
</a>
</div> </div>
<nav class="admin-sidebar__nav"> <nav class="admin-sidebar__nav">
<ul> <ul>
<li v-for="item in menuItems" :key="item.title" class="admin-sidebar__item"> <li v-for="(item, index) in menuItems" :key="item.title" class="admin-sidebar__item">
<component <button
:is="item.to ? 'RouterLink' : 'div'" v-if="item.children?.length"
type="button"
class="admin-sidebar__link"
:class="{ 'admin-sidebar__link--active': item.active }"
:aria-expanded="isExpanded(item)"
:aria-controls="submenuId(index)"
@click="toggle(item)"
>
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
<span>{{ item.title }}</span>
<SvgIcon
name="caret-down"
:size="16"
class="admin-sidebar__chevron"
:class="{ 'admin-sidebar__chevron--expanded': isExpanded(item) }"
/>
</button>
<RouterLink
v-else
:to="item.to" :to="item.to"
class="admin-sidebar__link" class="admin-sidebar__link"
:class="{ 'admin-sidebar__link--active': item.active }" :class="{ 'admin-sidebar__link--active': item.active }"
> >
<SvgIcon v-if="item.icon" :name="item.icon" :size="22" /> <SvgIcon v-if="item.icon" :name="item.icon" :size="22" />
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
</component> </RouterLink>
<ul v-if="item.children?.length" class="admin-sidebar__submenu"> <Transition v-if="item.children?.length" name="admin-sidebar__accordion">
<li v-for="child in item.children" :key="child.title"> <ul v-show="isExpanded(item)" :id="submenuId(index)" class="admin-sidebar__submenu">
<RouterLink <li v-for="child in item.children" :key="child.title">
:to="child.to" <RouterLink
class="admin-sidebar__sublink" :to="child.to"
:class="{ 'admin-sidebar__sublink--active': child.active }" class="admin-sidebar__sublink"
> :class="{ 'admin-sidebar__sublink--active': child.active }"
{{ child.title }} >
</RouterLink> {{ child.title }}
</li> </RouterLink>
</ul> </li>
</ul>
</Transition>
</li> </li>
</ul> </ul>
</nav> </nav>
@@ -34,16 +56,44 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue' import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
defineProps({ const props = defineProps({
menuItems: { type: Array, default: () => [] }, menuItems: { type: Array, default: () => [] },
}) })
const logo = gallery.logoPinkishRed const logo = gallery.logoPinkishRed
const expandedItems = ref({})
watch(
() => props.menuItems,
(items) => {
const next = { ...expandedItems.value }
items.forEach((item) => {
if (!item.children?.length) return
if (!(item.title in next)) next[item.title] = false
if (item.active) next[item.title] = true
})
expandedItems.value = next
},
{ immediate: true }
)
const isExpanded = (item) => Boolean(expandedItems.value[item.title])
const toggle = (item) => {
expandedItems.value = {
...expandedItems.value,
[item.title]: !isExpanded(item),
}
}
const submenuId = (index) => `admin-sidebar-submenu-${index}`
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -82,6 +132,7 @@ const logo = gallery.logoPinkishRed
} }
&__link { &__link {
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.625rem;
@@ -91,6 +142,9 @@ const logo = gallery.logoPinkishRed
font-size: 0.875rem; font-size: 0.875rem;
color: #4b5563; color: #4b5563;
text-decoration: none; text-decoration: none;
text-align: start;
border: 0;
background: transparent;
transition: background-color 0.15s, color 0.15s; transition: background-color 0.15s, color 0.15s;
cursor: pointer; cursor: pointer;
@@ -105,6 +159,15 @@ const logo = gallery.logoPinkishRed
} }
} }
&__chevron {
margin-inline-start: auto;
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__submenu { &__submenu {
margin-top: 0.25rem !important; margin-top: 0.25rem !important;
padding-inline-start: 1.5rem !important; padding-inline-start: 1.5rem !important;
@@ -130,5 +193,23 @@ const logo = gallery.logoPinkishRed
font-weight: 700; font-weight: 700;
} }
} }
&__accordion-enter-active,
&__accordion-leave-active {
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease;
}
&__accordion-enter-from,
&__accordion-leave-to {
max-height: 0;
opacity: 0;
}
&__accordion-enter-to,
&__accordion-leave-from {
max-height: 16rem;
opacity: 1;
}
} }
</style> </style>
+4 -1
View File
@@ -1,7 +1,9 @@
<template> <template>
<aside class="counselor-sidebar"> <aside class="counselor-sidebar">
<div class="counselor-sidebar__logo"> <div class="counselor-sidebar__logo">
<img :src="logo" alt="banu" /> <a :href="MAIN_SITE_URL">
<img :src="logo" alt="مدرسه بانو" />
</a>
</div> </div>
<nav class="counselor-sidebar__nav"> <nav class="counselor-sidebar__nav">
<ul> <ul>
@@ -23,6 +25,7 @@
</template> </template>
<script setup> <script setup>
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue' import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
+4 -1
View File
@@ -1,7 +1,9 @@
<template> <template>
<aside class="student-sidebar" :style="{ backgroundImage: `url(${bgPattern})` }"> <aside class="student-sidebar" :style="{ backgroundImage: `url(${bgPattern})` }">
<div class="student-sidebar__logo"> <div class="student-sidebar__logo">
<img :src="logo" alt="banu" /> <a :href="MAIN_SITE_URL">
<img :src="logo" alt="مدرسه بانو" />
</a>
</div> </div>
<nav class="student-sidebar__nav"> <nav class="student-sidebar__nav">
<ul> <ul>
@@ -23,6 +25,7 @@
</template> </template>
<script setup> <script setup>
import { MAIN_SITE_URL } from '@/enums'
import { gallery } from '@/utils/gallery' import { gallery } from '@/utils/gallery'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue' import SidebarLogout from '@/layouts/sidebars/SidebarLogout.vue'
+3 -3
View File
@@ -2,7 +2,7 @@ import { ROLE_HOME_ROUTES } from '@/enums'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { tokenService } from '@/services/api/token-service' import { tokenService } from '@/services/api/token-service'
const APP_TITLE = 'بنو' const APP_TITLE = 'مدرسه بانو'
const isPublic = (to) => to.meta?.public === true const isPublic = (to) => to.meta?.public === true
@@ -33,7 +33,7 @@ export const statusGuard = (to) => {
if (!tokenService.hasToken()) return if (!tokenService.hasToken()) return
const auth = useAuthStore() const auth = useAuthStore()
if (!auth.user) return if (!auth.user) return
if (auth.role && auth.role !== 'student') return if (!auth.roles.includes('student')) return
if (auth.user.status === 'approved') return if (auth.user.status === 'approved') return
return { name: 'student-register' } return { name: 'student-register' }
} }
@@ -41,7 +41,7 @@ export const statusGuard = (to) => {
export const roleGuard = (to) => { export const roleGuard = (to) => {
if (!to.meta?.role) return if (!to.meta?.role) return
const auth = useAuthStore() const auth = useAuthStore()
if (auth.role !== to.meta.role) { if (!auth.roles.includes(to.meta.role)) {
return { name: 'home' } return { name: 'home' }
} }
return return
+10
View File
@@ -0,0 +1,10 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetAdminCertificates = (params) =>
http.get(endpoints.getAdminCertificates, { params })
export const apiAddAdminCertificate = (payload) => http.post(endpoints.addAdminCertificate, payload)
export const apiDeleteAdminCertificate = (id) =>
http.delete(buildUrl(endpoints.deleteAdminCertificate, { id }))
+3
View File
@@ -8,6 +8,9 @@ export const apiShowAdminUser = (id) => http.get(buildUrl(endpoints.showUserDeta
export const apiGetAdminUserRegisterData = (id) => export const apiGetAdminUserRegisterData = (id) =>
http.get(buildUrl(endpoints.getUserRegisterData, { id })) http.get(buildUrl(endpoints.getUserRegisterData, { id }))
export const apiGetAdminUserEvaluation = (id) =>
http.get(buildUrl(endpoints.getUserEvaluation, { id }))
export const apiAddAdminUser = (payload) => http.post(endpoints.addNewUser, payload) export const apiAddAdminUser = (payload) => http.post(endpoints.addNewUser, payload)
export const apiUpdateAdminUser = (id, payload) => export const apiUpdateAdminUser = (id, payload) =>
+10 -1
View File
@@ -43,4 +43,13 @@ export const apiSaveRegisterData = (data) => http.post(endpoints.registerData, {
export const apiGetRegistrationQuestionVideo = () => export const apiGetRegistrationQuestionVideo = () =>
http.get(endpoints.getRegistrationQuestionVideo) http.get(endpoints.getRegistrationQuestionVideo)
export const apiUploadMedia = (formData) => http.post(endpoints.uploadMedia, formData) // Media uploads (especially course videos) can legitimately take several
// minutes on slower connections. Keep the normal API timeout short, but give
// this endpoint enough time to complete the backend's 300 MB upload contract.
// `onUploadProgress` is optional and forwarded straight to axios so callers
// can render an upload progress bar (e.g. XHR progress events).
export const apiUploadMedia = (formData, onUploadProgress) =>
http.post(endpoints.uploadMedia, formData, {
timeout: 10 * 60 * 1000,
...(onUploadProgress ? { onUploadProgress } : {}),
})
+15 -1
View File
@@ -14,7 +14,7 @@ export const endpoints = {
verifyForgotPasswordCode: '/auth/password/verify-otp', verifyForgotPasswordCode: '/auth/password/verify-otp',
resetPassword: '/auth/password/reset', resetPassword: '/auth/password/reset',
resendVerificationCodeForRegister: '/resend-verification-code', resendVerificationCodeForRegister: '/auth/login/otp/request',
verifyCode: '/auth/login/otp/verify', verifyCode: '/auth/login/otp/verify',
completeProfile: '/complete-profile', completeProfile: '/complete-profile',
getRegistrationQuestionVideo: '/verification-registration-media', getRegistrationQuestionVideo: '/verification-registration-media',
@@ -55,6 +55,14 @@ export const endpoints = {
getStudentCertificates: '/student/certificates', getStudentCertificates: '/student/certificates',
downloadStudentCertificate: '/student/certificates/:id/download', downloadStudentCertificate: '/student/certificates/:id/download',
// ─── Backend-aligned: Service types (settings) ────────────────────────────
// Read is open to any authenticated role (populates the student "request
// service" category select); create/update/delete are admin-only.
getServiceTypesList: '/service-types',
addServiceType: '/service-types',
updateServiceType: '/service-types/:id',
deleteServiceType: '/service-types/:id',
// ─── Per-role aggregated dashboards ──────────────────────────────────────── // ─── Per-role aggregated dashboards ────────────────────────────────────────
// Each requires the matching role's token; backend scopes the payload. // Each requires the matching role's token; backend scopes the payload.
getAdminDashboard: '/admin/dashboard', getAdminDashboard: '/admin/dashboard',
@@ -74,6 +82,7 @@ export const endpoints = {
changeUserStatus: '/admin/users/:id/status', changeUserStatus: '/admin/users/:id/status',
deleteUser: '/admin/users/:id', deleteUser: '/admin/users/:id',
getUserRegisterData: '/admin/users/:id/register-data', getUserRegisterData: '/admin/users/:id/register-data',
getUserEvaluation: '/admin/users/:id/evaluation',
// ─── Backend-aligned: Terms ──────────────────────────────────────────────── // ─── Backend-aligned: Terms ────────────────────────────────────────────────
getTermsList: '/terms', getTermsList: '/terms',
@@ -187,6 +196,11 @@ export const endpoints = {
listAdminMissionaryRequests: '/admin/missionary-requests', listAdminMissionaryRequests: '/admin/missionary-requests',
showAdminMissionaryRequest: '/admin/missionary-requests/:id', showAdminMissionaryRequest: '/admin/missionary-requests/:id',
changeAdminMissionaryRequestStatus: '/admin/missionary-requests/:id/status', changeAdminMissionaryRequestStatus: '/admin/missionary-requests/:id/status',
// ─── Admin: certificates ───────────────────────────────────────────────────
getAdminCertificates: '/admin/certificates',
addAdminCertificate: '/admin/certificates',
deleteAdminCertificate: '/admin/certificates/:id',
} }
export const buildUrl = (template, params = {}) => export const buildUrl = (template, params = {}) =>
+17 -4
View File
@@ -61,12 +61,25 @@ const addCamelizeInterceptor = (instance) => {
) )
} }
const _addUnauthorizeInterceptor = (instance) => { const addUnauthorizeInterceptor = (instance) => {
instance.interceptors.response.use( instance.interceptors.response.use(
(res) => res, (res) => res,
async (e) => { async (e) => {
if (e?.response?.status === 401) { // Login/OTP-verify endpoints also answer 401 for a plain wrong
tokenService.clearAll() // password or code — that's a credential failure the login form
// should handle itself, not a session expiring. Only treat this as
// "the session died" when we actually had a token going in.
if (e?.response?.status === 401 && tokenService.hasToken()) {
// Route through the auth store, not tokenService directly, so the
// reactive user state and the WordPress-facing mb_auth_status cookie
// both get cleared along with the token — an expired/invalid session
// must not leave WordPress showing a stale "logged in" state.
try {
const { useAuthStore } = await import('@/store/auth')
useAuthStore().clearUser()
} catch {
tokenService.clearAll()
}
toast('نشست شما تایید نشد، لطفا مجدد وارد شوید') toast('نشست شما تایید نشد، لطفا مجدد وارد شوید')
try { try {
const { default: router } = await import('@/router') const { default: router } = await import('@/router')
@@ -89,7 +102,7 @@ const createInstance = (baseUrl, { headers = {}, ...configs } = {}) => {
addResponseUnwrapInterceptor(instance) addResponseUnwrapInterceptor(instance)
addCamelizeInterceptor(instance) addCamelizeInterceptor(instance)
addSnakizeInterceptor(instance) addSnakizeInterceptor(instance)
// _addUnauthorizeInterceptor(instance) addUnauthorizeInterceptor(instance)
return instance return instance
} }
+12
View File
@@ -0,0 +1,12 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetServiceTypes = () => http.get(endpoints.getServiceTypesList)
export const apiAddServiceType = (payload) => http.post(endpoints.addServiceType, payload)
export const apiUpdateServiceType = (id, payload) =>
http.patch(buildUrl(endpoints.updateServiceType, { id }), payload)
export const apiDeleteServiceType = (id) =>
http.delete(buildUrl(endpoints.deleteServiceType, { id }))
+10 -4
View File
@@ -11,10 +11,16 @@ export const adminDashboard = {
openTickets: 32, openTickets: 32,
}, },
stats: { stats: {
activeUsers: 11_000, totalApplicants: 18,
totalUsers: 11_540, awaitingEvaluation: 1,
coursesCreated: 6, term1Students: 2,
coursesSold: 12, term2Students: 0,
term3Students: 1,
graduates: 0,
activeUsers: 11,
totalUsers: 24,
coursesCreated: 4,
coursesSold: 3,
}, },
recentRegistrations: [ recentRegistrations: [
{ {

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