Compare commits
47 Commits
7ed6ec9ae1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b2f2f89181 | |||
| b64f528c0d | |||
| d94a96b25e | |||
| af0149d722 | |||
| fec1da6ec8 | |||
| fe08fc46fb | |||
| 3d005859ab | |||
| f13eb9bbb4 | |||
| fb0e4aa7f4 | |||
| 94ac69d96a | |||
| 39b295037e | |||
| 31d100861b | |||
| 6510432f41 | |||
| 8fefb4568b | |||
| 32e97a7591 | |||
| fe1fb6cfa4 | |||
| bc9ce01d3f | |||
| 38051db180 | |||
| 36b747d267 | |||
| c69a1babe5 | |||
| 90cf147362 | |||
| e8b1e4add6 | |||
| 2a1f1b19d4 | |||
| 8d2aa1c214 | |||
| b592f520ab | |||
| dbc166f015 | |||
| da34813b1d | |||
| 38a396ba21 | |||
| b9ef684b51 | |||
| e700609236 | |||
| 2c617258f4 | |||
| a4432a30cb | |||
| 6b36ec8241 | |||
| 70d14cc119 | |||
| 90aec01095 | |||
| 4011c2f83b | |||
| 020a47f735 | |||
| a007db0d50 | |||
| 130266b416 | |||
| b9fa65b84f | |||
| b8ccbcc030 | |||
| ea562aeefa | |||
| 1c665830b0 | |||
| d42b7aaa7b | |||
| 9660e3fe8f | |||
| 3f3c66a02d | |||
| fc0aa2586e |
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=
|
||||
VITE_USE_MOCKS=
|
||||
@@ -6,7 +6,13 @@ module.exports = {
|
||||
es2021: true,
|
||||
},
|
||||
plugins: ['prettier', 'unicorn', 'import', 'waterfall'],
|
||||
ignorePatterns: ['node_modules/', 'dist/', 'build/', '*.min.js', 'src/components/icons/icon-names.d.ts'],
|
||||
ignorePatterns: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'build/',
|
||||
'*.min.js',
|
||||
'src/components/icons/icon-names.d.ts',
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
@@ -37,7 +43,7 @@ module.exports = {
|
||||
rules: {
|
||||
'no-alert': 'error',
|
||||
'no-shadow': 'off',
|
||||
'no-console': 'off',
|
||||
'no-console': 'error',
|
||||
'import/order': 'off',
|
||||
'dot-notation': 'error',
|
||||
'no-else-return': 'off',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Deploy banu-front
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: host
|
||||
env:
|
||||
IMAGE: banu-front
|
||||
CONTAINER: banu-front
|
||||
PORT: '8080'
|
||||
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
|
||||
VITE_USE_MOCKS: ${{ vars.VITE_USE_MOCKS }}
|
||||
steps:
|
||||
- name: Clone repository at current commit
|
||||
run: |
|
||||
BUILD_DIR="$(mktemp -d -t banu-front-XXXXXX)"
|
||||
echo "BUILD_DIR=$BUILD_DIR" >> "$GITHUB_ENV"
|
||||
git clone http://127.0.0.1:3000/front-end/banu-front.git "$BUILD_DIR"
|
||||
git -C "$BUILD_DIR" checkout "${{ github.sha }}"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL="$VITE_API_BASE_URL" \
|
||||
--build-arg VITE_USE_MOCKS="$VITE_USE_MOCKS" \
|
||||
-t "$IMAGE:${{ github.sha }}" \
|
||||
-t "$IMAGE:latest" \
|
||||
"$BUILD_DIR"
|
||||
|
||||
- name: Replace running container
|
||||
run: |
|
||||
docker rm -f "$CONTAINER" 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name "$CONTAINER" \
|
||||
--restart unless-stopped \
|
||||
-p "127.0.0.1:$PORT:80" \
|
||||
"$IMAGE:latest"
|
||||
|
||||
- name: Prune dangling images
|
||||
run: docker image prune -f
|
||||
|
||||
- name: Cleanup build dir
|
||||
if: always()
|
||||
run: rm -rf "$BUILD_DIR"
|
||||
|
||||
- name: Show running container
|
||||
run: docker ps --filter "name=$CONTAINER" --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{js,vue}": "eslint --fix",
|
||||
"*.scss": "prettier --write"
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
@@ -5,14 +5,17 @@ WORKDIR /app
|
||||
|
||||
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 registry https://registry.npmjs.org/ --global
|
||||
RUN npm config set strict-ssl false
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_BASE_URL=http://109.122.252.86/api
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_USE_MOCKS=false
|
||||
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
@@ -6,7 +6,7 @@ APP_DIR="/root/banu-front"
|
||||
IMAGE="banu-front"
|
||||
CONTAINER="banu-front"
|
||||
PORT="8080"
|
||||
VITE_API_BASE_URL="http://109.122.252.86/api"
|
||||
VITE_API_BASE_URL="//api.madresebanoo.ir/api"
|
||||
|
||||
echo "==> Pulling latest code..."
|
||||
if [ -d "$APP_DIR/.git" ]; then
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Source of truth: the Postman collection covering Terms, Courses, Sessions, Exams, Homeworks, Media (2026-05). This doc lists every place where backend and FE disagree on shape, plus FE-side concepts the backend doc has no slot for. Each item needs a product/design call before we wire it up.
|
||||
|
||||
Conventions in the rest of this doc:
|
||||
|
||||
- **B → FE** means the backend exposes a field/endpoint that the FE doesn't surface yet.
|
||||
- **FE → B** means the FE shows/sends a field the backend doc doesn't accept.
|
||||
- **shape diff** means both sides handle the concept but in different shapes (enum values, nesting, naming).
|
||||
@@ -14,7 +15,7 @@ Conventions in the rest of this doc:
|
||||
### Endpoints
|
||||
|
||||
| Backend | FE today | Status |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `GET /terms?per_page&active_only` | `getTermsList` | aligned |
|
||||
| `GET /terms/:id` | `showTerm` | aligned |
|
||||
| `POST /terms` | `addNewTerm` | aligned |
|
||||
@@ -28,7 +29,7 @@ Conventions in the rest of this doc:
|
||||
### Fields
|
||||
|
||||
| Backend → FE | FE has it as | Notes |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `starts_at`, `ends_at` (ISO datetime) | `startDate`, `endDate` | Naming diff; we send `starts_at`/`ends_at` in the mock already — confirm the FE form should rename or keep an adapter. |
|
||||
| `cover_url` (read) + `cover_media_id` (write) | `coverUrl` / `coverMediaId` | aligned in shape, just camelCase. |
|
||||
| `created_at` | `createdAt` | aligned. |
|
||||
@@ -36,6 +37,7 @@ Conventions in the rest of this doc:
|
||||
| `description` | `description` | aligned. |
|
||||
|
||||
**FE → B** (need decision):
|
||||
|
||||
- `studentsCount`, `coursesCount` — computed UI counts. Backend response doesn't include them. Either compute client-side or ask backend for `?include=counts`.
|
||||
- `image` (UI alias of cover) — FE keeps this for a fallback. Backend response only has `cover_url`.
|
||||
|
||||
@@ -46,7 +48,7 @@ Conventions in the rest of this doc:
|
||||
### Endpoints
|
||||
|
||||
| Backend | FE today | Status |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `GET /courses?term_id&per_page` | `getCoursesList` | aligned (drop `/admin/` prefix in `endpoints.js`). |
|
||||
| `GET /courses/:id` | `showCourse` | aligned. Backend response includes nested `term` and `teacher` — FE already reads `course.term` / `course.teacher`, good. |
|
||||
| `POST /courses` | `addNewCourse` | aligned. Backend currently requires `term_id` (422 example), but per user instruction the standalone (template-tab) flow must allow `term_id: null`. **Decide:** ask backend to make `term_id` nullable, or refuse to submit until a term is picked. |
|
||||
@@ -59,7 +61,7 @@ Conventions in the rest of this doc:
|
||||
### Fields
|
||||
|
||||
| Backend ↔ FE | Notes |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `term_id` ⇄ `termId` | aligned. Nullable in CourseFormPage, required in AddOfferedCourseModal — schema reflects this. |
|
||||
| `teacher_id` ⇄ `teacherId` | aligned. |
|
||||
| `capacity` ⇄ `capacity` | aligned. |
|
||||
@@ -69,6 +71,7 @@ Conventions in the rest of this doc:
|
||||
| Nested `term`, `teacher` on show response | FE already reads. |
|
||||
|
||||
**FE → B** (need decision):
|
||||
|
||||
- `sessionsCount` — number-of-sessions field on the create form. Backend has nothing. Either compute server-side from related sessions, or drop the field.
|
||||
- `prerequisites` (array of { courseId, course }) — backend has no prerequisite relation. Drop or ask backend for it.
|
||||
- `contentType` (video/voice/text) + `contentMediaId` — single course-level content file. Backend treats files only as session media. Decide whether course-level content should move to "intro session" or stay a course concept.
|
||||
@@ -81,7 +84,7 @@ Conventions in the rest of this doc:
|
||||
### Endpoints
|
||||
|
||||
| Backend | FE today | Status |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `GET /sessions?course_id&per_page` | `getSessionsList` | aligned. |
|
||||
| `GET /sessions/:id` | `showSession` | aligned. |
|
||||
| `POST /sessions` | `addNewSession` | aligned. |
|
||||
@@ -93,7 +96,7 @@ Conventions in the rest of this doc:
|
||||
### Fields — biggest gap of all five resources
|
||||
|
||||
| Backend | FE today | Notes |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `type` enum: `online \| offline \| content` | `sessionType` enum: `in_person`, `online`, `video`, `audio`, `text`, `slide`, `pdf` | **Shape diff.** FE has 7 values; backend has 3. There's an existing `SESSION_TYPE_TO_SPEC` mapper in `services/mock/fixtures/admin-sessions.js`. Decide whether the FE keeps the richer 7-value enum (and we map down to backend's 3) or collapses. |
|
||||
| `starts_at`, `location`, `link` | All three live **inside** `form.sessionConfig.*` plus also derived to top-level `startsAt` / `location` / `link` in the mock | **Structural diff.** Backend wants flat fields; FE form nests them under `sessionConfig` keyed by `sessionType`. The mock derives top-level from `sessionConfig.*` for show payloads. Decide whether the FE form should flatten the schema to match backend or keep the conditional-by-type config UI. |
|
||||
| `media_ids[]` (write) / `media[]` (read with `collection_name`, `file_name`, `mime_type`, `file_size`, `url`, `download_url`) | `materials[]` with `{ fileId, isRequired, type, title, order }` | **Shape diff.** Backend's media rows are typed by upload-purpose (video/voice/pdf/slide/attachment); FE has its own `type` enum. Decide which shape the FE keeps. |
|
||||
@@ -108,7 +111,7 @@ Conventions in the rest of this doc:
|
||||
### Endpoints
|
||||
|
||||
| Backend | FE today | Status |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `GET /exams/:id` (with questions+options) | `showExam` | aligned. |
|
||||
| `POST /exams` | `addNewExam` | aligned. |
|
||||
| `PATCH /exams/:id` | `updateExam` | aligned. |
|
||||
@@ -122,7 +125,7 @@ Conventions in the rest of this doc:
|
||||
### Fields
|
||||
|
||||
| Backend ↔ FE | Notes |
|
||||
|---|---|
|
||||
| --- | --- | --- |
|
||||
| `session_id` ⇄ `sessionId` | aligned. |
|
||||
| `title` ⇄ `title` | aligned. |
|
||||
| `description` ⇄ `description` | aligned. |
|
||||
@@ -131,6 +134,7 @@ Conventions in the rest of this doc:
|
||||
| Backend question shape: `{ question_text, position, options: [{ option_text, is_correct }] }` | FE: `{ title, score, correctAnswerId, answers: [{ id, title }] }` | **Shape diff.** Backend hides `is_correct` from public reads (only on add). FE concept of `score` (per-question weighting) has no backend slot. Decide: keep FE scoring (ask backend to store) or drop. |
|
||||
|
||||
**FE → B** (need decision):
|
||||
|
||||
- `durationMinutes` — no backend slot.
|
||||
- `randomize` — no backend slot.
|
||||
- `endDate` / `startDate` — exam validity window, no backend slot.
|
||||
@@ -143,7 +147,7 @@ Conventions in the rest of this doc:
|
||||
### Endpoints
|
||||
|
||||
| Backend | FE today | Status |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `POST /homeworks` | `addNewAssignment` | aligned (URL renamed to `/homeworks`; FE key name kept). |
|
||||
| `PATCH /homeworks/:id` | `updateAssignment` | aligned. |
|
||||
| `DELETE /homeworks/:id` | `deleteAssignment` | aligned. |
|
||||
@@ -155,7 +159,7 @@ Conventions in the rest of this doc:
|
||||
### Fields
|
||||
|
||||
| Backend ↔ FE | Notes |
|
||||
|---|---|
|
||||
| --- | --- | --- |
|
||||
| `session_id` ⇄ `sessionId` | aligned. |
|
||||
| `title`, `description` | aligned. |
|
||||
| `deadline` (single datetime) | FE: `startDate` + `endDate` + computed `durationDays` | **Shape diff.** Backend has one deadline; FE has a window. Decide: drop start/end and use single deadline, or ask backend to add a window. |
|
||||
@@ -166,6 +170,7 @@ Conventions in the rest of this doc:
|
||||
| Submission `reviewed_at` (read) | — | Backend provides; FE doesn't surface. |
|
||||
|
||||
**FE → B** (need decision):
|
||||
|
||||
- `priority` enum (`mandatory \| optional`) — no backend slot.
|
||||
- `submissionsCount` — derived count, no backend slot.
|
||||
|
||||
@@ -176,7 +181,7 @@ Conventions in the rest of this doc:
|
||||
### Endpoints
|
||||
|
||||
| Backend | FE today | Status |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `POST /media` (multipart with `purpose` + `file`) | `uploadMedia` | aligned URL & method. |
|
||||
| `GET /media/:id/download` | — | **Backend → FE.** Add as `downloadMedia`. |
|
||||
| `DELETE /media/:id` | — | **Backend → FE.** Add as `deleteMedia`. |
|
||||
@@ -194,6 +199,7 @@ Conventions in the rest of this doc:
|
||||
### Snake_case vs camelCase
|
||||
|
||||
Backend wire format is snake_case throughout. FE today reads camelCase (e.g., the mock returns `coverUrl`, `isActive`). When the real backend lands, the FE will either need:
|
||||
|
||||
- a HTTP-layer transformer (camelize on response, snake_case on request), or
|
||||
- camelCase field aliases on the backend serializer.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Banu</title>
|
||||
<title>مدرسه بانو</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint:fix": "eslint . --ext .js,.vue --fix --max-warnings=0",
|
||||
"lint:css:check": "stylelint \"src/**/*.{css,scss,vue}\" --max-warnings=0",
|
||||
"lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix --max-warnings=0",
|
||||
"prepare": "husky install",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
@@ -20,6 +21,8 @@
|
||||
"@tanstack/vue-query-devtools": "^5.62.2",
|
||||
"@tinymce/tinymce-vue": "^4.0.7",
|
||||
"axios": "^1.12.2",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"husky": "^8.0.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"lodash": "^4.18.1",
|
||||
"pinia": "^3.0.3",
|
||||
@@ -40,6 +43,7 @@
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"eslint-plugin-waterfall": "^1.0.1",
|
||||
"lint-staged": "^15.5.2",
|
||||
"postcss-html": "^1.8.0",
|
||||
"prettier": "2.8.8",
|
||||
"sass": "^1.83.0",
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useGetMeQuery } from '@/services/query/auth'
|
||||
import { tokenService } from '@/services/api/token-service'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
import { useGetMeQuery } from '@/services/query/auth'
|
||||
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||
import StudentLayout from '@/layouts/StudentLayout.vue'
|
||||
import ConfirmModal from '@/components/ConfirmModal.vue'
|
||||
import CounselorLayout from '@/layouts/CounselorLayout.vue'
|
||||
import { tokenService } from '@/services/api/token-service'
|
||||
import MissionaryLayout from '@/layouts/MissionaryLayout.vue'
|
||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -24,6 +26,8 @@ const layouts = {
|
||||
public: PublicLayout,
|
||||
admin: AdminLayout,
|
||||
student: StudentLayout,
|
||||
counselor: CounselorLayout,
|
||||
missionary: MissionaryLayout,
|
||||
}
|
||||
|
||||
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="11.625" stroke="#EEEEEE" stroke-width="0.75"/>
|
||||
<g clip-path="url(#clip0_1_21196)">
|
||||
<path d="M15.375 15.375L8.625 8.625" stroke="#B2B2B2" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.1094 8.625H8.625V14.1094" stroke="#B2B2B2" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21196">
|
||||
<rect width="13.5" height="13.5" fill="white" transform="translate(5.25 5.25)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,7 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.625 10.625H9.375"/>
|
||||
<path d="M5.625 8.125H9.375"/>
|
||||
<path d="M15.3125 13.125C17.211 13.125 18.75 11.586 18.75 9.6875C18.75 7.78902 17.211 6.25 15.3125 6.25C13.414 6.25 11.875 7.78902 11.875 9.6875C11.875 11.586 13.414 13.125 15.3125 13.125Z"/>
|
||||
<path d="M13.125 15H3.125C2.95924 15 2.80027 14.9342 2.68306 14.8169C2.56585 14.6997 2.5 14.5408 2.5 14.375V4.375C2.5 4.20924 2.56585 4.05027 2.68306 3.93306C2.80027 3.81585 2.95924 3.75 3.125 3.75H16.875C17.0408 3.75 17.1997 3.81585 17.3169 3.93306C17.4342 4.05027 17.5 4.20924 17.5 4.375V7.03594"/>
|
||||
<path d="M13.125 12.3389V17.4998L15.3125 16.2498L17.5 17.4998V12.3389"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 8.75H12.8125"/>
|
||||
<path d="M7.5 11.25H12.8125"/>
|
||||
<path d="M10.3125 16.875H3.75C3.58424 16.875 3.42527 16.8092 3.30806 16.6919C3.19085 16.5747 3.125 16.4158 3.125 16.25V9.6875C3.125 7.78126 3.88225 5.95309 5.23017 4.60517C6.57809 3.25725 8.40626 2.5 10.3125 2.5C11.2564 2.5 12.191 2.68591 13.063 3.04712C13.9351 3.40832 14.7274 3.93775 15.3948 4.60517C16.0623 5.27259 16.5917 6.06493 16.9529 6.93696C17.3141 7.80899 17.5 8.74362 17.5 9.6875C17.5 10.6314 17.3141 11.566 16.9529 12.438C16.5917 13.3101 16.0623 14.1024 15.3948 14.7698C14.7274 15.4373 13.9351 15.9667 13.063 16.3279C12.191 16.6891 11.2564 16.875 10.3125 16.875Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_24766)">
|
||||
<path d="M4.64062 7.17188L5.90625 8.4375L8.85938 5.48438" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.75 11.8125C9.54594 11.8125 11.8125 9.54594 11.8125 6.75C11.8125 3.95406 9.54594 1.6875 6.75 1.6875C3.95406 1.6875 1.6875 3.95406 1.6875 6.75C1.6875 9.54594 3.95406 11.8125 6.75 11.8125Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_24766">
|
||||
<rect width="13.5" height="13.5" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_21191)">
|
||||
<path d="M6.5 11.375C9.19239 11.375 11.375 9.19239 11.375 6.5C11.375 3.80761 9.19239 1.625 6.5 1.625C3.80761 1.625 1.625 3.80761 1.625 6.5C1.625 9.19239 3.80761 11.375 6.5 11.375Z" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 3.65625V6.5H9.34375" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21191">
|
||||
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.5 7.5V15.625C17.5 15.7908 17.4342 15.9497 17.3169 16.0669C17.1997 16.1842 17.0408 16.25 16.875 16.25H3.125C2.95924 16.25 2.80027 16.1842 2.68306 16.0669C2.56585 15.9497 2.5 15.7908 2.5 15.625V7.5L10 2.5L17.5 7.5Z"/>
|
||||
<path d="M8.63711 11.875L2.69336 16.0734"/>
|
||||
<path d="M17.307 16.0734L11.3633 11.875"/>
|
||||
<path d="M17.5 7.5L11.3641 11.875H8.63672L2.5 7.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.375 16.25H3.125C2.95924 16.25 2.80027 16.1842 2.68306 16.0669C2.56585 15.9497 2.5 15.7908 2.5 15.625V5C2.5 4.83424 2.56585 4.67527 2.68306 4.55806C2.80027 4.44085 2.95924 4.375 3.125 4.375H7.29141C7.42664 4.375 7.55822 4.41886 7.66641 4.5L10 6.25H16.875C17.0408 6.25 17.1997 6.31585 17.3169 6.43306C17.4342 6.55027 17.5 6.70924 17.5 6.875V9.375"/>
|
||||
<path d="M14.375 15.5109L16.693 16.875L16.0625 14.3289L18.125 12.6258L15.418 12.4172L14.375 10L13.332 12.4172L10.625 12.6258L12.6875 14.3289L12.057 16.875L14.375 15.5109Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.625 7.5L10 2.5L19.375 7.5L10 12.5L0.625 7.5Z"/>
|
||||
<path d="M10 7.5L14.375 9.83359V18.75"/>
|
||||
<path d="M16.875 8.83398V12.9918C16.8754 13.1445 16.8198 13.2921 16.7188 13.4066C15.8359 14.3902 13.6797 16.2504 10 16.2504C6.32031 16.2504 4.16563 14.3902 3.28125 13.4066C3.18023 13.2921 3.12465 13.1445 3.125 12.9918V8.83398"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2_135332)">
|
||||
<path d="M4.21875 16.875L18.2813 16.875C19.0579 16.875 19.6875 16.2454 19.6875 15.4688L19.6875 5.625C19.6875 4.84835 19.0579 4.21875 18.2813 4.21875L4.21875 4.21875C3.4421 4.21875 2.8125 4.84835 2.8125 5.625L2.8125 15.4687C2.8125 16.2454 3.4421 16.875 4.21875 16.875Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.0625 19.6875L8.4375 19.6875" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2_135332">
|
||||
<rect width="22.5" height="22.5" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 746 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.4375 9.84375L15.4688 9.84375" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.4375 12.6562L15.4688 12.6563" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.8125 17.5781C3.18546 17.5781 3.54315 17.43 3.80687 17.1662C4.07059 16.9025 4.21875 16.5448 4.21875 16.1719L4.21875 5.625C4.21875 5.43852 4.29283 5.25968 4.42469 5.12782C4.55655 4.99595 4.7354 4.92188 4.92188 4.92188L18.9844 4.92188C19.1709 4.92188 19.3497 4.99595 19.4816 5.12782C19.6134 5.25968 19.6875 5.43852 19.6875 5.625L19.6875 16.1719C19.6875 16.5448 19.5393 16.9025 19.2756 17.1662C19.0119 17.43 18.6542 17.5781 18.2813 17.5781L2.8125 17.5781Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.8125 17.5781C2.43954 17.5781 2.08185 17.43 1.81813 17.1662C1.55441 16.9025 1.40625 16.5448 1.40625 16.1719L1.40625 7.73438" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 3.75v12.5"/>
|
||||
<path d="M13 3.75v12.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 199 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_21269)">
|
||||
<path d="M5.625 3.1154V16.8841C5.62704 16.994 5.65802 17.1014 5.7148 17.1955C5.77158 17.2896 5.85217 17.367 5.94843 17.42C6.0447 17.473 6.15323 17.4997 6.2631 17.4974C6.37297 17.4951 6.48028 17.4638 6.57422 17.4068L17.8305 10.5224C17.9204 10.468 17.9947 10.3913 18.0463 10.2998C18.0979 10.2082 18.1251 10.1049 18.1251 9.99977C18.1251 9.89467 18.0979 9.79135 18.0463 9.69979C17.9947 9.60823 17.9204 9.53154 17.8305 9.47711L6.57422 2.59274C6.48028 2.53571 6.37297 2.50447 6.2631 2.50214C6.15323 2.49982 6.0447 2.52651 5.94843 2.57951C5.85217 2.63252 5.77158 2.70996 5.7148 2.80405C5.65802 2.89813 5.62704 3.00552 5.625 3.1154Z" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21269">
|
||||
<rect width="19.5" height="19.5" fill="white" transform="scale(1.02564)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 993 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3v9"/>
|
||||
<path d="M15.5 4.5a8 8 0 1 1-7 0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_21319)">
|
||||
<path d="M11.8125 2.625H2.1875C1.94588 2.625 1.75 2.82088 1.75 3.0625V10.9375C1.75 11.1791 1.94588 11.375 2.1875 11.375H11.8125C12.0541 11.375 12.25 11.1791 12.25 10.9375V3.0625C12.25 2.82088 12.0541 2.625 11.8125 2.625Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.1875 4.8125C9.1875 5.39266 8.95703 5.94906 8.5468 6.3593C8.13656 6.76953 7.58016 7 7 7C6.41984 7 5.86344 6.76953 5.4532 6.3593C5.04297 5.94906 4.8125 5.39266 4.8125 4.8125" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21319">
|
||||
<rect width="13.5" height="13.5" fill="white" transform="scale(1.03704)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 171 171" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M85.125 148.969C120.385 148.969 148.969 120.385 148.969 85.125C148.969 49.8651 120.385 21.2812 85.125 21.2812C49.8651 21.2812 21.2812 49.8651 21.2812 85.125C21.2812 120.385 49.8651 148.969 85.125 148.969Z" stroke="#CACACA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M61.1816 65.2207C64.8287 65.2207 67.7852 68.1772 67.7852 71.8242C67.7852 75.4712 64.8287 78.4277 61.1816 78.4277C57.5346 78.4277 54.5781 75.4712 54.5781 71.8242C54.5781 68.1772 57.5346 65.2207 61.1816 65.2207Z" fill="#F55D6D" stroke="#F55D6D" stroke-width="0.09375"/>
|
||||
<path d="M109.064 65.2207C112.711 65.2207 115.668 68.1772 115.668 71.8242C115.668 75.4712 112.711 78.4277 109.064 78.4277C105.417 78.4277 102.461 75.4712 102.461 71.8242C102.461 68.1772 105.417 65.2207 109.064 65.2207Z" fill="#F55D6D" stroke="#F55D6D" stroke-width="0.09375"/>
|
||||
<path d="M111.727 117.047C106.207 107.504 96.9427 101.086 85.125 101.086C73.3073 101.086 64.0433 107.504 58.5234 117.047" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12.5C11.7259 12.5 13.125 11.1009 13.125 9.375C13.125 7.64911 11.7259 6.25 10 6.25C8.27411 6.25 6.875 7.64911 6.875 9.375C6.875 11.1009 8.27411 12.5 10 12.5Z"/>
|
||||
<path d="M4.98438 15.5757C5.45462 14.6494 6.17216 13.8715 7.05745 13.3281C7.94275 12.7847 8.96123 12.4971 10 12.4971C11.0388 12.4971 12.0572 12.7847 12.9425 13.3281C13.8278 13.8715 14.5454 14.6494 15.0156 15.5757"/>
|
||||
<path d="M17.3963 8.75003C17.6599 10.3167 17.4189 11.9266 16.7079 13.3474C15.9969 14.7681 14.8528 15.9261 13.4408 16.6542C12.0287 17.3823 10.4218 17.6428 8.85205 17.3981C7.28231 17.1533 5.83096 16.416 4.70757 15.2926C3.58419 14.1693 2.84689 12.7179 2.60215 11.1482C2.35742 9.57842 2.6179 7.97151 3.34598 6.55946C4.07407 5.14741 5.23209 4.00329 6.65283 3.2923C8.07357 2.58132 9.6835 2.34026 11.2502 2.60394"/>
|
||||
<path d="M14.375 4.375L15.625 5.625L18.125 3.125"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1007 B |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.48438 8.53125C7.16712 8.53125 8.53125 7.16712 8.53125 5.48438C8.53125 3.80163 7.16712 2.4375 5.48438 2.4375C3.80163 2.4375 2.4375 3.80163 2.4375 5.48438C2.4375 7.16712 3.80163 8.53125 5.48438 8.53125Z" stroke-width="0.75"/>
|
||||
<path d="M1.21875 10.5625C2.2623 9.3209 3.73547 8.53125 5.48438 8.53125C7.23328 8.53125 8.70645 9.3209 9.75 10.5625" stroke-width="0.75"/>
|
||||
<path d="M10.1562 3.49096C10.4243 4.12144 10.5625 4.79952 10.5625 5.48463C10.5625 6.16975 10.4243 6.84782 10.1562 7.47831" stroke-width="0.75"/>
|
||||
<path d="M11.6468 2.84375C12.0033 3.67846 12.1871 4.57672 12.1871 5.48438C12.1871 6.39203 12.0033 7.29029 11.6468 8.125" stroke-width="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -0,0 +1,12 @@
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_21228)">
|
||||
<path d="M8.125 5.6875L5.6875 4.0625V7.3125L8.125 5.6875Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.9688 2.4375H2.03125C1.80688 2.4375 1.625 2.61938 1.625 2.84375V8.53125C1.625 8.75562 1.80688 8.9375 2.03125 8.9375H10.9688C11.1931 8.9375 11.375 8.75562 11.375 8.53125V2.84375C11.375 2.61938 11.1931 2.4375 10.9688 2.4375Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.625 10.5625H11.375" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21228">
|
||||
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 863 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 596 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 400 B |
@@ -4,6 +4,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 106.25%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
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-style: normal;
|
||||
font-display: swap;
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWebFaNum-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -33,6 +33,14 @@
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
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-style: normal;
|
||||
font-display: swap;
|
||||
@@ -46,25 +54,17 @@
|
||||
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-family: iran-yekan;
|
||||
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-style: normal;
|
||||
font-display: swap;
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
@font-face {
|
||||
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-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -21,7 +21,7 @@ import { computed } from 'vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan'>} */
|
||||
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan' | 'blue'>} */
|
||||
variant: { type: String, default: 'neutral' },
|
||||
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
|
||||
size: { type: String, default: 'md' },
|
||||
@@ -138,5 +138,10 @@ const onClick = (event) => {
|
||||
background: rgba(104, 104, 104, 10%);
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: rgba(0, 72, 255, 8%);
|
||||
color: #0048ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
:loading="!!data?.confirmLoading"
|
||||
custom-class="confirm-modal__btn confirm-modal__btn--confirm"
|
||||
@click="confirm(data)"
|
||||
/>
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,6 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from './icons/SvgIcon.vue'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
@@ -82,10 +87,6 @@ const cancel = (data) => {
|
||||
&__btn {
|
||||
min-width: 100px;
|
||||
padding: 0.5rem 1.5rem;
|
||||
|
||||
&--confirm {
|
||||
padding-inline: 1.5rem 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,8 +39,9 @@ const social = [
|
||||
.copyright {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: 3rem;
|
||||
|
||||
&__text {
|
||||
padding: 0 0.25rem;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="dropdown">
|
||||
<div v-if="modelValue" ref="floatingEl" class="dropdown-menu" :style="floatingStyle">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
ref="floatingEl"
|
||||
class="dropdown-menu"
|
||||
:class="{ 'dropdown-menu--center': align === 'center' }"
|
||||
:style="floatingStyle"
|
||||
>
|
||||
<ul class="dropdown-menu__list">
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
@@ -11,11 +17,21 @@
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-menu__item"
|
||||
:class="{ 'dropdown-menu__item--danger': item.danger }"
|
||||
:class="{
|
||||
'dropdown-menu__item--danger': item.danger,
|
||||
'dropdown-menu__item--colored': item.color,
|
||||
}"
|
||||
:style="{ color: item.color }"
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<SvgIcon v-if="item.icon" :name="item.icon" :size="16" class="dropdown-menu__icon" />
|
||||
<SvgIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
:size="16"
|
||||
:color="item.iconColor ?? item.color ?? 'currentColor'"
|
||||
class="dropdown-menu__icon"
|
||||
/>
|
||||
<span class="dropdown-menu__label">{{ item.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -43,6 +59,7 @@ const props = defineProps({
|
||||
placement: { type: String, default: 'bottom-end' },
|
||||
offset: { type: Number, default: 8 },
|
||||
minWidth: { type: String, default: '12rem' },
|
||||
align: { type: String, default: 'start' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select'])
|
||||
@@ -180,6 +197,21 @@ onBeforeUnmount(unmountFloating)
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__item--colored &__icon {
|
||||
:deep([stroke]) {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
}
|
||||
|
||||
&--center &__item {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&--center &__label {
|
||||
flex: 0 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
|
||||
@@ -11,14 +11,17 @@
|
||||
</div>
|
||||
<div class="header-block__title">
|
||||
<slot>
|
||||
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div>
|
||||
<div v-if="pageTitle || pageSubtitle" class="header-block__heading">
|
||||
<span v-if="pageSubtitle" class="header-block__heading-eyebrow">{{ pageTitle }}</span>
|
||||
<span>{{ pageSubtitle || pageTitle }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="header-block__icons">
|
||||
<ul>
|
||||
<li v-for="item in iconList" :key="item.icon">
|
||||
<button type="button" class="header-block__icon-btn" @click="item.action">
|
||||
<SvgIcon :name="item.icon" :size="24" color="#9a9a9a" />
|
||||
<SvgIcon :name="item.icon" :size="24" color="var(--header-icon-color)" />
|
||||
<span v-if="item.count" class="header-block__icon-badge">{{ item.count }}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -27,33 +30,91 @@
|
||||
</div>
|
||||
<div class="header-block__title-mobile">
|
||||
<slot>
|
||||
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div>
|
||||
<div v-if="pageTitle || pageSubtitle" class="header-block__heading">
|
||||
<span v-if="pageSubtitle" class="header-block__heading-eyebrow">{{ pageTitle }}</span>
|
||||
<span>{{ pageSubtitle || pageTitle }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<DropdownMenu
|
||||
v-model="userMenuOpen"
|
||||
:reference="userMenuTrigger"
|
||||
:items="userMenuItems"
|
||||
placement="bottom-end"
|
||||
align="center"
|
||||
min-width="9rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUIStore } from '@/store/ui'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useAuth from '@/composables/useAuth'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import { useLogoutMutation } from '@/services/query/auth'
|
||||
|
||||
defineProps({
|
||||
pageTitle: { type: String, default: '' },
|
||||
pageSubtitle: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const hasSlotContent = computed(() => !!slots.default)
|
||||
|
||||
const ui = useUIStore()
|
||||
const router = useRouter()
|
||||
const { role, clearSession } = useAuth()
|
||||
const logoutMutation = useLogoutMutation()
|
||||
|
||||
const logo = gallery.authLogo
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
const userMenuTrigger = ref(null)
|
||||
|
||||
const openUserMenu = (event) => {
|
||||
const trigger = event.currentTarget
|
||||
if (userMenuOpen.value && userMenuTrigger.value === trigger) {
|
||||
userMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
userMenuTrigger.value = trigger
|
||||
userMenuOpen.value = true
|
||||
}
|
||||
|
||||
const goEditProfile = () => {
|
||||
router.push({ name: 'student-profile' }).catch(() => {})
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync()
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
clearSession()
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
}
|
||||
|
||||
const userMenuItems = computed(() => [
|
||||
...(role.value === 'student'
|
||||
? [{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile }]
|
||||
: []),
|
||||
{
|
||||
key: 'logout',
|
||||
label: 'خروج',
|
||||
icon: 'heart',
|
||||
color: 'var(--color-primary)',
|
||||
onClick: onLogout,
|
||||
},
|
||||
])
|
||||
|
||||
const iconList = [
|
||||
{ icon: 'chat', action: () => {} },
|
||||
{ icon: 'bell', action: () => {}, count: 5 },
|
||||
{ icon: 'user', action: () => {} },
|
||||
// { icon: 'chat', action: () => {} },
|
||||
// { icon: 'bell', action: () => {}, count: 5 },
|
||||
{ icon: 'user', action: openUserMenu },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -121,6 +182,18 @@ const iconList = [
|
||||
}
|
||||
}
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__heading-eyebrow {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: #a7a7a7;
|
||||
}
|
||||
|
||||
&__icons {
|
||||
flex-basis: 33.33%;
|
||||
|
||||
@@ -136,12 +209,18 @@ const iconList = [
|
||||
}
|
||||
|
||||
&__icon-btn {
|
||||
--header-icon-color: #9a9a9a;
|
||||
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
--header-icon-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-badge {
|
||||
|
||||
@@ -25,8 +25,8 @@ defineProps({
|
||||
|
||||
&__bar {
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 3px 8px rgba(243, 102, 117, 45%);
|
||||
height: 1.9rem;
|
||||
width: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -47,7 +47,7 @@ const customer = (init) => {
|
||||
init.placeholder = props.placeholder
|
||||
|
||||
// Paste pictures and automatically process base64
|
||||
init.urlconverter_callback = (url, node, onSave, name) => {
|
||||
init.urlconverter_callback = (url, node, _onSave, _name) => {
|
||||
if (node === 'img' && url.startsWith('blob:')) {
|
||||
tinymce.activeEditor && tinymce.activeEditor.uploadImages()
|
||||
}
|
||||
@@ -73,7 +73,7 @@ onMounted(() => {
|
||||
// Listen for text changes and pass them to the outside world
|
||||
watch(
|
||||
() => state.contentValue,
|
||||
(n) => {
|
||||
() => {
|
||||
debounce(() => {
|
||||
emit('update:modelValue', state.contentValue)
|
||||
})
|
||||
@@ -112,7 +112,5 @@ const imgUploadFn = async (blobInfo, success, failure) => {
|
||||
return failure(`HTTP Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const { myInit, contentValue, timeout } = toRefs(state)
|
||||
const { myInit, contentValue } = toRefs(state)
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="message-attachments">
|
||||
<template v-for="item in media" :key="item.id">
|
||||
<a
|
||||
v-if="isImage(item)"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="message-attachments__image-link"
|
||||
>
|
||||
<img :src="item.url" :alt="item.fileName" class="message-attachments__image" />
|
||||
</a>
|
||||
<a
|
||||
v-else
|
||||
:href="item.downloadUrl || item.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="message-attachments__file"
|
||||
>
|
||||
<SvgIcon name="file" :size="18" color="currentColor" />
|
||||
<span class="message-attachments__name">{{ item.fileName }}</span>
|
||||
<span class="message-attachments__size">{{ formatFileSize(item.fileSize) }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
media: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const isImage = (item) =>
|
||||
(item.mimeType || '').startsWith('image/') || /\.(jpe?g|png|gif|webp)$/i.test(item.fileName || '')
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return ''
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
|
||||
&__image-link {
|
||||
display: block;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
&__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 12rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
&__file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
background: rgba(0, 0, 0, 8%);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 14%);
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__size {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="no-items">
|
||||
<div class="no-items__inner">
|
||||
<SvgIcon name="warning" :size="120" color="#cbd5e1" />
|
||||
<SvgIcon name="smiley-sad" :size="171" />
|
||||
<p class="no-items__title">{{ title }}</p>
|
||||
<p class="no-items__desc">{{ desc }}</p>
|
||||
</div>
|
||||
|
||||
@@ -11,16 +11,37 @@
|
||||
@click="onChange(tab.name)"
|
||||
>
|
||||
<span class="tabs-block__tab-card">
|
||||
<SvgIcon v-if="tab.icon" :name="tab.icon" :size="16" color="#bcbcbc" />
|
||||
<SvgIcon v-if="tab.icon" :name="tab.icon" :size="18" color="#bcbcbc" />
|
||||
<span class="tabs-block__tab-label">{{ tab.label }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-for="tab in tabs">
|
||||
<div
|
||||
v-if="tab.hasButton"
|
||||
:key="`btn-${tab.name}`"
|
||||
class="tabs-block__action tabs-block__action--desktop"
|
||||
>
|
||||
<BaseButton
|
||||
v-if="tab.hasButton && activeTab === tab.name"
|
||||
:text="tab.textButton"
|
||||
custom-class="tabs-block__action-btn"
|
||||
@click="onActionClick(tab)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-for="tab in tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="`btn-${tab.name}`"
|
||||
class="tabs-block__action tabs-block__action--desktop"
|
||||
v-if="tab.hasButton"
|
||||
:key="`mbtn-${tab.name}`"
|
||||
class="tabs-block__action tabs-block__action--mobile"
|
||||
>
|
||||
<BaseButton
|
||||
v-if="tab.hasButton && activeTab === tab.name"
|
||||
@@ -33,24 +54,7 @@
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="`mbtn-${tab.name}`"
|
||||
class="tabs-block__action tabs-block__action--mobile"
|
||||
>
|
||||
<BaseButton
|
||||
v-if="tab.hasButton && activeTab === tab.name"
|
||||
:text="tab.textButton"
|
||||
custom-class="tabs-block__action-btn"
|
||||
@click="onActionClick(tab)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tabs-block__content">
|
||||
<slot :name="activeTab" />
|
||||
@@ -126,7 +130,7 @@ const onActionClick = (tab) => {
|
||||
gap: 0.375rem;
|
||||
background: rgba(255, 255, 255, 80%);
|
||||
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
|
||||
padding: 0.875rem;
|
||||
padding: 0.775rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -144,7 +148,8 @@ const onActionClick = (tab) => {
|
||||
&--desktop {
|
||||
display: none;
|
||||
min-width: fit-content;
|
||||
margin-block-end: 0.625rem;
|
||||
|
||||
// margin-block-end: 0.625rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: flex;
|
||||
@@ -162,7 +167,7 @@ const onActionClick = (tab) => {
|
||||
}
|
||||
|
||||
&__action-btn {
|
||||
padding: 0 1.25rem;
|
||||
height: 100%;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="activity-card">
|
||||
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="activity-card__corner" />
|
||||
|
||||
<div class="activity-card__thumb" :class="{ 'activity-card__thumb--circle': circle }">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="title" class="activity-card__thumb-img" />
|
||||
<SvgIcon v-else :name="thumbIcon" :size="'1.5rem'" color="#848484" />
|
||||
</div>
|
||||
|
||||
<div class="activity-card__body">
|
||||
<p class="activity-card__title">{{ title }}</p>
|
||||
|
||||
<div class="activity-card__meta">
|
||||
<SvgIcon :name="metaIcon" :size="'0.875rem'" class="activity-card__meta-icon" />
|
||||
<div class="activity-card__rows">
|
||||
<p v-for="(row, i) in meta" :key="i" class="activity-card__row">
|
||||
<span class="activity-card__row-label">{{ row.label }}</span>
|
||||
<span
|
||||
class="activity-card__row-value"
|
||||
:class="{ 'activity-card__row-value--num': row.numeric }"
|
||||
>
|
||||
{{ row.value }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
/** rows of { label, value, numeric? } */
|
||||
meta: { type: Array, default: () => [] },
|
||||
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
|
||||
metaIcon: { type: String, default: 'calendar' },
|
||||
/** thumbnail glyph shown when there is no avatarUrl */
|
||||
thumbIcon: { type: String, default: 'play' },
|
||||
/** render the thumbnail as a circular avatar instead of a rounded square */
|
||||
circle: { type: Boolean, default: false },
|
||||
avatarUrl: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.activity-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
min-height: 5.9375rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.9375rem;
|
||||
|
||||
&__corner {
|
||||
position: absolute;
|
||||
inset-block-start: 0.75rem;
|
||||
inset-inline-end: 1rem;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.6875rem;
|
||||
height: 3.6875rem;
|
||||
background: #f4f4f4;
|
||||
border-radius: 0.9375rem;
|
||||
overflow: hidden;
|
||||
|
||||
&--circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
text-align: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
&__meta-icon {
|
||||
order: 2;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
&__rows {
|
||||
order: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.6875rem;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
|
||||
&__row-value {
|
||||
&--num {
|
||||
font-family: var(--font-family-number-fa);
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="activity-group">
|
||||
<div class="activity-group__pill">
|
||||
<SvgIcon :name="pillIcon" :size="'1.125rem'" class="activity-group__pill-icon" />
|
||||
<span class="activity-group__pill-label">{{ pillLabel }}</span>
|
||||
</div>
|
||||
|
||||
<DashboardActivityCard
|
||||
:title="title"
|
||||
:meta="meta"
|
||||
:meta-icon="metaIcon"
|
||||
:thumb-icon="thumbIcon"
|
||||
:circle="circle"
|
||||
:avatar-url="avatarUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import DashboardActivityCard from '@/components/dashboard/DashboardActivityCard.vue'
|
||||
|
||||
defineProps({
|
||||
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
|
||||
pillIcon: { type: String, default: 'shopping-bag' },
|
||||
pillLabel: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
meta: { type: Array, default: () => [] },
|
||||
metaIcon: { type: String, default: 'calendar' },
|
||||
thumbIcon: { type: String, default: 'play' },
|
||||
circle: { type: Boolean, default: false },
|
||||
avatarUrl: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.activity-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&__pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.9375rem;
|
||||
}
|
||||
|
||||
&__pill-icon {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
order: 1;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<p class="calendar__title">{{ monthLabel }}</p>
|
||||
|
||||
<div class="calendar__week">
|
||||
<div
|
||||
v-for="(d, i) in days"
|
||||
:key="i"
|
||||
class="calendar__day"
|
||||
:class="{ 'calendar__day--active': d.selected }"
|
||||
>
|
||||
<span v-if="d.marker" class="calendar__marker" />
|
||||
<span class="calendar__weekday">{{ d.weekday }}</span>
|
||||
<span class="calendar__date">{{ d.day }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
monthLabel: { type: String, default: '' },
|
||||
/** ordered right-to-left: { weekday, day, selected?, marker? } */
|
||||
days: { type: Array, default: () => [] },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.125rem 1rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.9375rem;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
text-align: start;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&__week {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
&__day {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.125rem;
|
||||
border-radius: 999px;
|
||||
|
||||
&--active {
|
||||
background: var(--color-secondary);
|
||||
box-shadow: 0 9px 9.75px rgba(0, 112, 116, 19%);
|
||||
|
||||
.calendar__weekday,
|
||||
.calendar__date {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__marker {
|
||||
position: absolute;
|
||||
inset-block-start: 0.125rem;
|
||||
inset-inline-start: 0.25rem;
|
||||
width: 0.25rem;
|
||||
height: 0.25rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
&__weekday {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.625rem;
|
||||
color: #828282;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
color: #717171;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="profile-card">
|
||||
<div class="profile-card__avatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="name" />
|
||||
</div>
|
||||
|
||||
<p class="profile-card__name">{{ name }}</p>
|
||||
|
||||
<div class="profile-card__location">
|
||||
<SvgIcon name="map-pin-simple-area" :size="'0.9375rem'" />
|
||||
<span>{{ location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="profile-card__stats">
|
||||
<div v-for="(stat, i) in stats" :key="i" class="profile-card__stat">
|
||||
<span class="profile-card__stat-label">{{ stat.label }}</span>
|
||||
<span
|
||||
class="profile-card__stat-value"
|
||||
:class="{ 'profile-card__stat-value--text': !isNumeric(stat.value) }"
|
||||
>
|
||||
{{ stat.value }}
|
||||
<span v-if="stat.unit" class="profile-card__stat-unit">{{ stat.unit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
name: { type: String, default: '' },
|
||||
location: { type: String, default: '' },
|
||||
avatarUrl: { type: String, default: '' },
|
||||
/** mini-stats of { label, value, unit? } */
|
||||
stats: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const isNumeric = (value) => /^[\d۰-۹]+$/.test(String(value ?? '').trim())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 1.25rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.9375rem;
|
||||
|
||||
&__avatar {
|
||||
width: 7.75rem;
|
||||
height: 7.75rem;
|
||||
border-radius: 50%;
|
||||
background: #f4f4f4;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&__location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
color: #989898;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 0.6875rem;
|
||||
color: #a7a7a7;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-weight: 400;
|
||||
font-size: 1.5625rem;
|
||||
color: #818181;
|
||||
text-align: center;
|
||||
|
||||
&--text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__stat-unit {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.75rem;
|
||||
color: #929292;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<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" />
|
||||
|
||||
<div class="stat-card__head">
|
||||
<SvgIcon :name="icon" :size="'1.0625rem'" class="stat-card__head-icon" />
|
||||
<span class="stat-card__label">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ value }}</span>
|
||||
<div class="stat-card__meta">
|
||||
<span class="stat-card__unit">{{ unit }}</span>
|
||||
<span class="stat-card__subtitle">{{ subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
|
||||
icon: { type: String, required: true },
|
||||
label: { type: String, default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
unit: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
to: { type: [String, Object], default: null },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.stat-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
min-height: 8.75rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
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 {
|
||||
position: absolute;
|
||||
inset-block-start: 0.875rem;
|
||||
inset-inline-end: 1.125rem;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
&__head-icon {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
order: 1;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.625rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-weight: 700;
|
||||
font-size: 4rem;
|
||||
line-height: 1;
|
||||
color: var(--color-primary);
|
||||
text-shadow: 0 5.25px 8.625px rgba(243, 102, 117, 18%);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 1.125rem;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="badge-select">
|
||||
<label v-if="label" class="badge-select__label">{{ label }}</label>
|
||||
<div class="badge-select__chips" :class="`badge-select__chips--${size}`">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option[optionValue]"
|
||||
type="button"
|
||||
class="badge-select__chip"
|
||||
:class="[
|
||||
`badge-select__chip--${variant}`,
|
||||
{ 'badge-select__chip--selected': modelValue === option[optionValue] },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="select(option)"
|
||||
>
|
||||
{{ option[optionLabel] }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="badge-select__error">{{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: null },
|
||||
label: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
optionLabel: { type: String, default: 'label' },
|
||||
optionValue: { type: String, default: 'value' },
|
||||
variant: { type: String, default: 'neutral' },
|
||||
size: { type: String, default: 'md' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const select = (option) => {
|
||||
if (props.disabled) return
|
||||
const value = option[props.optionValue]
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.badge-select {
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
margin-bottom: 0.375rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
line-height: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
font-family: var(--font-family-fa);
|
||||
color: #b5b5b5;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:enabled {
|
||||
color: #7a7a7a;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #fff;
|
||||
border-color: #f0f0f0;
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 6%);
|
||||
color: #4b4b4b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&--primary#{&}--selected {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__chips--sm .badge-select__chip {
|
||||
padding: 0.25rem 0.875rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
&__chips--md .badge-select__chip {
|
||||
padding: 0.375rem 1.125rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
&__chips--lg .badge-select__chip {
|
||||
padding: 0.5rem 1.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -54,8 +54,8 @@ const onToggle = () => {
|
||||
}
|
||||
|
||||
&--selected .checkbox-field__box {
|
||||
background: #999999;
|
||||
border-color: #999999;
|
||||
background: #999;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
&__box {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="datepicker-field__value">{{ displayValue }}</span>
|
||||
<SvgIcon name="calendar" :size="20" class="datepicker-field__icon" />
|
||||
<SvgIcon name="calendar" :size="20" color="#DDDDDD" class="datepicker-field__icon" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }">
|
||||
@@ -377,6 +377,7 @@ export default {
|
||||
this.$emit('change', gregorian)
|
||||
this.close()
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error in handleConfirm:', error)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
@dragleave="isDragging = false"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<SvgIcon name="upload" :size="32" class="file-uploader__upload-icon" />
|
||||
<span class="file-uploader__upload-text">{{ uploadText }}</span>
|
||||
<img :src="uploadSimple" class="file-uploader__upload-icon" />
|
||||
<span class="file-uploader__upload-text">
|
||||
<span class="file-uploader__upload-text--light">بارگذاری</span>
|
||||
<span class="file-uploader__upload-text--bold">فایل ضمیمــــه</span>
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
class="file-uploader__input"
|
||||
@@ -56,14 +59,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-uploader__empty">هیچ فایلی آپلود نشده است</div>
|
||||
<!-- <div v-else class="file-uploader__empty">هیچ فایلی آپلود نشده است</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import uploadSimple from '@/assets/images/upload-simple.png'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
@@ -77,10 +81,6 @@ const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const uploadText = computed(() =>
|
||||
props.modelValue.length === 0 ? 'فایل جدید اضافه کنید' : 'فایل دیگری اضافه کنید'
|
||||
)
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const files = [...event.target.files]
|
||||
emitFiles(files)
|
||||
@@ -140,14 +140,15 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
&__dropzone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f4f4f4;
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover,
|
||||
@@ -161,10 +162,20 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
&__upload-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
|
||||
&--light {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&--bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
|
||||
<div class="image-cropper__hint">
|
||||
<SvgIcon name="warning" class="image-cropper__hint-icon" />
|
||||
<SvgIcon :name="helperIcon" color="#B8B8B8" :size="20" />
|
||||
<p>{{ helperText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,11 +94,12 @@ const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
bgColor: { type: String, default: '#f3f4f6' },
|
||||
maxSize: { type: Number, default: 250 * 1024 },
|
||||
maxSize: { type: Number, default: 5 * 1024 * 1024 },
|
||||
helperText: {
|
||||
type: String,
|
||||
default: 'سایز تصویر رسمی 3x4 و حجم تصویر بیشتر از 250 کیلو بایت نباشد.',
|
||||
default: 'سایز تصویر رسمی 3x4 و حجم تصویر بیشتر از ۵ مگابایت نباشد.',
|
||||
},
|
||||
helperIcon: { type: String, default: 'warning' },
|
||||
error: { type: String, default: '' },
|
||||
})
|
||||
|
||||
@@ -143,7 +144,7 @@ const onFileChange = (event) => {
|
||||
if (!file) return
|
||||
|
||||
if (props.maxSize > 0 && file.size > props.maxSize) {
|
||||
emit('error', 'حجم تصویر نباید بیشتر از ۲۵۰ کیلوبایت باشد.')
|
||||
emit('error', 'حجم تصویر نباید بیشتر از ۵ مگابایت باشد.')
|
||||
event.target.value = null
|
||||
return
|
||||
}
|
||||
@@ -314,7 +315,7 @@ onBeforeUnmount(cleanup)
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
|
||||
p {
|
||||
font-family: var(--font-family-fa);
|
||||
@@ -324,8 +325,7 @@ onBeforeUnmount(cleanup)
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.75rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ onBeforeUnmount(revokePreview)
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: #eeeeee;
|
||||
background: #eee;
|
||||
border-radius: 1.5rem;
|
||||
min-height: 18rem;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -15,9 +15,19 @@
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="select-field__value">{{ selectedLabel }}</span>
|
||||
<button
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
class="select-field__clear"
|
||||
aria-label="پاک کردن"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
<SvgIcon name="close" :size="14" color="currentColor" />
|
||||
</button>
|
||||
<SvgIcon
|
||||
name="caret-down"
|
||||
:size="20"
|
||||
color="#DDDDDD"
|
||||
class="select-field__caret"
|
||||
:class="{ 'select-field__caret--open': isOpen }"
|
||||
/>
|
||||
@@ -85,10 +95,11 @@ const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: 'انتخاب کنید' },
|
||||
options: { type: Array, required: true },
|
||||
options: { type: Array, default: () => [] },
|
||||
optionLabel: { type: String, default: 'name' },
|
||||
optionValue: { type: String, default: 'id' },
|
||||
multiple: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
@@ -118,16 +129,31 @@ const isSelected = (opt) => {
|
||||
const selectedLabel = computed(() => {
|
||||
const value = props.modelValue
|
||||
if (value === undefined || value === null || value === '') return props.placeholder
|
||||
const options = props.options ?? []
|
||||
if (props.multiple && Array.isArray(value)) {
|
||||
const selected = props.options.filter((o) => value.includes(o[props.optionValue]))
|
||||
const selected = options.filter((o) => value.includes(o[props.optionValue]))
|
||||
if (selected.length === 0) return props.placeholder
|
||||
if (selected.length <= 2) return selected.map((s) => s[props.optionLabel]).join('، ')
|
||||
return `${selected.length} مورد انتخاب شده`
|
||||
}
|
||||
const found = props.options.find((o) => o[props.optionValue] === value)
|
||||
const found = options.find((o) => o[props.optionValue] === value)
|
||||
return found ? found[props.optionLabel] : props.placeholder
|
||||
})
|
||||
|
||||
const hasValue = computed(() =>
|
||||
props.multiple
|
||||
? Array.isArray(props.modelValue) && props.modelValue.length > 0
|
||||
: props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
|
||||
)
|
||||
|
||||
const showClear = computed(() => props.clearable && !props.disabled && hasValue.value)
|
||||
|
||||
const clearSelection = () => {
|
||||
const cleared = props.multiple ? [] : null
|
||||
emit('update:modelValue', cleared)
|
||||
emit('change', cleared)
|
||||
}
|
||||
|
||||
const select = (opt) => {
|
||||
if (props.disabled) return
|
||||
const value = opt[props.optionValue]
|
||||
@@ -241,10 +267,6 @@ onBeforeUnmount(() => unmountFloating())
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: var(--color-error);
|
||||
|
||||
@@ -277,6 +299,24 @@ onBeforeUnmount(() => unmountFloating())
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-inline-end: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-thd-gray);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
@@ -307,10 +347,6 @@ onBeforeUnmount(() => unmountFloating())
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 0.75rem;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
@@ -335,12 +371,15 @@ onBeforeUnmount(() => unmountFloating())
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 1.5rem;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ defineExpose({ focus: () => inputEl.value?.focus() })
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +134,7 @@ defineExpose({ focus: () => inputEl.value?.focus() })
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
inset-inline-start: 0.75rem;
|
||||
inset-inline-end: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
|
||||
@@ -143,7 +143,6 @@ onMounted(() => {
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
|
||||
&--no-resize {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="toggle-switch">
|
||||
<label class="toggle-switch__label">
|
||||
<span class="toggle-switch__text">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@@ -15,7 +16,6 @@
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span class="toggle-switch__text">{{ label }}</span>
|
||||
</label>
|
||||
<div v-if="error" class="toggle-switch__error dir-rtl" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
|
||||
@@ -20,20 +20,29 @@
|
||||
<SvgIcon name="close" :size="40" color="var(--color-error)" />
|
||||
</button>
|
||||
|
||||
<div v-else class="voice-recorder__preview">
|
||||
<button type="button" class="voice-recorder__main-btn" @click="togglePlay">
|
||||
<SvgIcon
|
||||
:name="isPlaying ? 'close' : 'paper-plane-right'"
|
||||
:size="40"
|
||||
color="var(--color-thd-gray)"
|
||||
/>
|
||||
<div v-else class="voice-recorder__player">
|
||||
<button type="button" class="voice-recorder__play-btn" @click="togglePlay">
|
||||
<SvgIcon :name="isPlaying ? 'pause' : 'play'" :size="24" color="#848484" />
|
||||
</button>
|
||||
<div
|
||||
ref="trackEl"
|
||||
class="voice-recorder__track"
|
||||
@pointerdown="onSeekStart"
|
||||
@pointermove="onSeekMove"
|
||||
@pointerup="onSeekEnd"
|
||||
@pointercancel="onSeekEnd"
|
||||
>
|
||||
<div class="voice-recorder__track-fill" :style="{ width: `${progress}%` }" />
|
||||
<span class="voice-recorder__knob" :style="{ left: `${progress}%` }" />
|
||||
</div>
|
||||
<span class="voice-recorder__time">{{ timeLabel }}</span>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
:src="audioUrl"
|
||||
class="voice-recorder__audio"
|
||||
@ended="onEnded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,10 +84,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="audioUrl" class="voice-recorder__progress">
|
||||
<div class="voice-recorder__progress-fill" :style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
@@ -88,9 +93,9 @@
|
||||
<script setup>
|
||||
import { toast } from 'vue3-toastify'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { PermissionModal } from '@/features/auth'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, File, String, null], default: null },
|
||||
@@ -113,14 +118,35 @@ const isRecording = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
const elapsedMs = ref(0)
|
||||
const progress = ref(0)
|
||||
const duration = ref(0)
|
||||
const currentSeconds = ref(0)
|
||||
const audioEl = ref(null)
|
||||
const trackEl = ref(null)
|
||||
const fileInput = ref(null)
|
||||
let seeking = false
|
||||
|
||||
let mediaRecorder = null
|
||||
let mediaStream = null
|
||||
let chunks = []
|
||||
let timerHandle = null
|
||||
let timerStart = 0
|
||||
let recorderType = null
|
||||
|
||||
// The backend only accepts these audio extensions. Browsers can't record mp3/wav,
|
||||
// but most can record into an mp4 container (→ .m4a), so prefer a supported type
|
||||
// whose extension the backend allows; fall back to webm only as a last resort.
|
||||
const ACCEPTED_AUDIO_TYPES = [
|
||||
{ mime: 'audio/mp4', ext: 'm4a' },
|
||||
{ mime: 'audio/mpeg', ext: 'mp3' },
|
||||
{ mime: 'audio/wav', ext: 'wav' },
|
||||
]
|
||||
|
||||
const pickRecorderType = () => {
|
||||
const supported = ACCEPTED_AUDIO_TYPES.find((c) =>
|
||||
window.MediaRecorder?.isTypeSupported?.(c.mime)
|
||||
)
|
||||
return supported || { mime: 'audio/webm', ext: 'webm' }
|
||||
}
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const s = Math.floor(elapsedMs.value / 1000)
|
||||
@@ -154,6 +180,8 @@ const setRecording = (file) => {
|
||||
audioFile.value = file
|
||||
audioUrl.value = URL.createObjectURL(file)
|
||||
progress.value = 0
|
||||
duration.value = 0
|
||||
currentSeconds.value = 0
|
||||
isPlaying.value = false
|
||||
emit('update:modelValue', file)
|
||||
emit('recorded', file)
|
||||
@@ -163,16 +191,19 @@ const startRecording = async () => {
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
chunks = []
|
||||
mediaRecorder = new window.MediaRecorder(mediaStream)
|
||||
recorderType = pickRecorderType()
|
||||
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data?.size > 0) chunks.push(e.data)
|
||||
}
|
||||
mediaRecorder.onstop = () => {
|
||||
isRecording.value = false
|
||||
stopTracks()
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' })
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||
type: 'audio/webm',
|
||||
const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
|
||||
const ext = recorderType?.ext || 'webm'
|
||||
const blob = new Blob(chunks, { type: mime })
|
||||
const file = new File([blob], `recording-${Date.now()}.${ext}`, {
|
||||
type: mime,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
setRecording(file)
|
||||
@@ -238,12 +269,71 @@ const togglePlay = () => {
|
||||
const onEnded = () => {
|
||||
isPlaying.value = false
|
||||
progress.value = 0
|
||||
currentSeconds.value = 0
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const a = audioEl.value
|
||||
if (!a?.duration) return
|
||||
progress.value = (a.currentTime / a.duration) * 100
|
||||
if (!a || !Number.isFinite(duration.value) || duration.value <= 0) return
|
||||
currentSeconds.value = a.currentTime
|
||||
progress.value = (a.currentTime / duration.value) * 100
|
||||
}
|
||||
|
||||
// MediaRecorder blobs report `Infinity` duration in Chromium until the element
|
||||
// is seeked past the end once — force that, then rewind.
|
||||
const onLoadedMetadata = () => {
|
||||
const a = audioEl.value
|
||||
if (!a) return
|
||||
if (Number.isFinite(a.duration)) {
|
||||
duration.value = a.duration
|
||||
return
|
||||
}
|
||||
const restore = () => {
|
||||
if (!Number.isFinite(a.duration)) return
|
||||
duration.value = a.duration
|
||||
a.currentTime = 0
|
||||
a.removeEventListener('timeupdate', restore)
|
||||
}
|
||||
a.addEventListener('timeupdate', restore)
|
||||
a.currentTime = 1e10
|
||||
}
|
||||
|
||||
const formatClock = (secs) => {
|
||||
if (!Number.isFinite(secs) || secs < 0) return '00:00'
|
||||
const total = Math.floor(secs)
|
||||
const h = Math.floor(total / 3600)
|
||||
const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(total % 60).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
const timeLabel = computed(() =>
|
||||
formatClock(isPlaying.value || currentSeconds.value > 0 ? currentSeconds.value : duration.value)
|
||||
)
|
||||
|
||||
const seekTo = (clientX) => {
|
||||
const a = audioEl.value
|
||||
const track = trackEl.value
|
||||
if (!a || !track || !Number.isFinite(duration.value) || duration.value <= 0) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
|
||||
a.currentTime = ratio * duration.value
|
||||
currentSeconds.value = a.currentTime
|
||||
progress.value = ratio * 100
|
||||
}
|
||||
|
||||
const onSeekStart = (event) => {
|
||||
seeking = true
|
||||
trackEl.value?.setPointerCapture?.(event.pointerId)
|
||||
seekTo(event.clientX)
|
||||
}
|
||||
|
||||
const onSeekMove = (event) => {
|
||||
if (seeking) seekTo(event.clientX)
|
||||
}
|
||||
|
||||
const onSeekEnd = () => {
|
||||
seeking = false
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -318,11 +408,76 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
&__player {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&__play-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: #e8e8e8;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.625rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__track-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: #5d5d5d;
|
||||
}
|
||||
|
||||
&__knob {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
background: #4b4b4b;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
color: #b5b5b5;
|
||||
flex-shrink: 0;
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__audio {
|
||||
@@ -386,21 +541,6 @@ onBeforeUnmount(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__progress {
|
||||
width: 100%;
|
||||
height: 0.125rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-thd-gray);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 40%);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-fa);
|
||||
|
||||
@@ -26,6 +26,7 @@ const props = defineProps({
|
||||
const component = computed(() => {
|
||||
const c = iconRegistry[props.name]
|
||||
if (!c && import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[SvgIcon] unknown icon "${props.name}"`)
|
||||
}
|
||||
|
||||
@@ -46,6 +47,5 @@ const iconStyle = computed(() => ({
|
||||
.svg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
fill: currentcolor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,19 +8,27 @@ export type IconName =
|
||||
| 'bell'
|
||||
| 'book'
|
||||
| 'calendar'
|
||||
| 'card-corner-arrow'
|
||||
| 'caret-down'
|
||||
| 'caret-left'
|
||||
| 'caret-right'
|
||||
| 'certificate'
|
||||
| 'chat'
|
||||
| 'chat-centered-dots'
|
||||
| 'chat-teardrop-text'
|
||||
| 'check'
|
||||
| 'check-circle'
|
||||
| 'check-square'
|
||||
| 'clock'
|
||||
| 'close'
|
||||
| 'copy'
|
||||
| 'envelope-open'
|
||||
| 'eye'
|
||||
| 'eye-slash'
|
||||
| 'file'
|
||||
| 'folder-simple-star'
|
||||
| 'funnel'
|
||||
| 'graduation-cap'
|
||||
| 'heart'
|
||||
| 'instagram'
|
||||
| 'link'
|
||||
@@ -28,21 +36,31 @@ export type IconName =
|
||||
| 'map-pin-simple-area'
|
||||
| 'menu'
|
||||
| 'microphone'
|
||||
| 'monitor'
|
||||
| 'mood'
|
||||
| 'newspaper'
|
||||
| 'paper-plane'
|
||||
| 'paper-plane-right'
|
||||
| 'pause'
|
||||
| 'pencil'
|
||||
| 'phone'
|
||||
| 'play'
|
||||
| 'plus'
|
||||
| 'power'
|
||||
| 'scroll'
|
||||
| 'shopping-bag'
|
||||
| 'smiley-sad'
|
||||
| 'spinner'
|
||||
| 'square'
|
||||
| 'telegram'
|
||||
| 'trash'
|
||||
| 'upload'
|
||||
| 'user'
|
||||
| 'user-circle-check'
|
||||
| 'user-sound'
|
||||
| 'users'
|
||||
| 'users-three'
|
||||
| 'video'
|
||||
| 'warning'
|
||||
|
||||
export const iconNames: readonly IconName[]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { toFaDigits } from '@/utils/persian'
|
||||
import { toJalaali } from '@/utils/date-utils'
|
||||
|
||||
const WEEKDAY_NAMES = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']
|
||||
const MONTH_NAMES = [
|
||||
'فروردین',
|
||||
'اردیبهشت',
|
||||
'خرداد',
|
||||
'تیر',
|
||||
'مرداد',
|
||||
'شهریور',
|
||||
'مهر',
|
||||
'آبان',
|
||||
'آذر',
|
||||
'دی',
|
||||
'بهمن',
|
||||
'اسفند',
|
||||
]
|
||||
const GREGORIAN_WEEKDAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Builds the current Jalaali week (Saturday → Friday) anchored to the Asia/Tehran
|
||||
* timezone, independent of the viewer's local clock. The day matching Tehran's
|
||||
* "today" is flagged `selected`. Returns `{ month, days }` for DashboardCalendar.
|
||||
*/
|
||||
export const useTehranCalendar = () => {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).formatToParts(new Date())
|
||||
const part = (type) => parts.find((p) => p.type === type)?.value
|
||||
const todayIndex = GREGORIAN_WEEKDAYS.indexOf(part('weekday'))
|
||||
const todayUtc = Date.UTC(Number(part('year')), Number(part('month')) - 1, Number(part('day')))
|
||||
const weekStartUtc = todayUtc - todayIndex * DAY_MS
|
||||
|
||||
const today = new Date(todayUtc)
|
||||
const { jm } = toJalaali(today.getUTCFullYear(), today.getUTCMonth() + 1, today.getUTCDate())
|
||||
|
||||
const days = WEEKDAY_NAMES.map((weekday, i) => {
|
||||
const date = new Date(weekStartUtc + i * DAY_MS)
|
||||
const { jd } = toJalaali(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
|
||||
return { weekday, day: toFaDigits(jd), selected: i === todayIndex }
|
||||
})
|
||||
|
||||
return { month: `${MONTH_NAMES[jm - 1]} ماه`, days }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const fields = {
|
||||
mobile: 'شماره موبایل',
|
||||
phoneNumber: 'شماره موبایل',
|
||||
phone: 'تلفن ثابت',
|
||||
phone: 'شماره موبایل',
|
||||
password: 'رمز عبور',
|
||||
passwordConfirmation: 'تکرار رمز عبور',
|
||||
newPassword: 'رمز عبور جدید',
|
||||
@@ -15,8 +15,14 @@ export const fields = {
|
||||
lastName: 'نام خانوادگی',
|
||||
nationalCode: 'کد ملی',
|
||||
postalCode: 'کد پستی',
|
||||
virtualPhoneNumber: 'شماره تلفن مجازی',
|
||||
seminaryCode: 'کد حوزوی',
|
||||
seminaryServiceCenterCode: 'کد مرکز خدمات حوزه',
|
||||
childrenCount: 'تعداد فرزند',
|
||||
marriageStatus: 'وضعیت تأهل',
|
||||
|
||||
birthDate: 'تاریخ تولد',
|
||||
birthday: 'تاریخ تولد',
|
||||
gender: 'جنسیت',
|
||||
maritalStatus: 'وضعیت تأهل',
|
||||
|
||||
@@ -27,16 +33,22 @@ export const fields = {
|
||||
address: 'آدرس',
|
||||
|
||||
title: 'عنوان',
|
||||
subject: 'موضوع',
|
||||
category: 'نوع خدمت',
|
||||
note: 'یادداشت',
|
||||
description: 'توضیحات',
|
||||
bio: 'بیوگرافی',
|
||||
message: 'پیام',
|
||||
content: 'محتوا',
|
||||
rejectionReason: 'دلیل رد',
|
||||
reason: 'دلیل رد',
|
||||
adminNote: 'یادداشت مدیر',
|
||||
reviewerNote: 'یادداشت ارزیاب',
|
||||
activitySummary: 'خلاصهٔ فعالیت',
|
||||
|
||||
avatar: 'تصویر پروفایل',
|
||||
avatarId: 'تصویر پروفایل',
|
||||
avatarMediaId: 'تصویر پروفایل',
|
||||
image: 'تصویر',
|
||||
|
||||
status: 'وضعیت',
|
||||
@@ -47,12 +59,15 @@ export const fields = {
|
||||
|
||||
startDate: 'تاریخ شروع',
|
||||
endDate: 'تاریخ پایان',
|
||||
deadline: 'مهلت تحویل',
|
||||
fromDate: 'از تاریخ',
|
||||
toDate: 'تا تاریخ',
|
||||
|
||||
capacity: 'ظرفیت',
|
||||
minCapacity: 'حداقل ظرفیت',
|
||||
maxCapacity: 'حداکثر ظرفیت',
|
||||
score: 'نمره',
|
||||
minimumScore: 'حد نصاب قبولی',
|
||||
|
||||
order: 'ترتیب',
|
||||
priority: 'اولویت',
|
||||
@@ -65,23 +80,32 @@ export const fields = {
|
||||
termId: 'ترم',
|
||||
courseId: 'دوره',
|
||||
sessionId: 'جلسه',
|
||||
missionaryRequestId: 'درخواست مبلغ',
|
||||
sessionType: 'نوع جلسه',
|
||||
type: 'نوع جلسه',
|
||||
link: 'لینک جلسه',
|
||||
teacherId: 'مدرس',
|
||||
studentIds: 'دانشآموزان',
|
||||
prerequisiteCourseIds: 'دورههای پیشنیاز',
|
||||
contentMediaId: 'فایل محتوای دوره',
|
||||
coverMediaId: 'تصویر جلد',
|
||||
imageId: 'تصویر',
|
||||
|
||||
educationStatus: 'وضعیت تحصیلی',
|
||||
fieldOfStudy: 'رشتهٔ تحصیلی',
|
||||
seminaryLevel: 'مقطع حوزه',
|
||||
universityLevel: 'مقطع دانشگاهی',
|
||||
universityName: 'نام حوزه علمیه / دانشگاه',
|
||||
workExperienceSummary: 'خلاصهٔ سوابق شغلی',
|
||||
degree: 'مقطع تحصیلی',
|
||||
jobTitle: 'عنوان شغل',
|
||||
organizationName: 'نام سازمان',
|
||||
hijabApproach: 'رویکرد حجاب',
|
||||
|
||||
prerequisites: 'پیشنیازها',
|
||||
specializedTopics: 'موضوعات تخصصی',
|
||||
propagationPlatforms: 'بسترهای تبلیغ',
|
||||
propagationMethodDescription: 'شرح روش تبلیغ',
|
||||
propagationExperienceYears: 'سنوات تجربهٔ تبلیغ',
|
||||
propagationExperienceYears: 'سابقه فعالیت تبلیغی',
|
||||
relevantCertificates: 'گواهینامههای مرتبط',
|
||||
|
||||
onlinePlatformDetails: 'جزئیات بستر آنلاین',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { string } from 'yup'
|
||||
|
||||
import { fields } from '@/constants/fields'
|
||||
|
||||
export const phoneNumberRule = string()
|
||||
.required()
|
||||
.matches(/^09\d{9}$/, ({ path }) => `${fields[path] || path} نامعتبر است`)
|
||||
.matches(/^09\d{9}$/, 'شماره موبایل باید با ۰۹ شروع شود و ۱۱ رقم باشد')
|
||||
|
||||
@@ -5,8 +5,11 @@ export const getEnumObject = (enumArray, key = 'value', value = 'label') =>
|
||||
}, {})
|
||||
|
||||
export const ROLES = Object.freeze([
|
||||
{ value: 'admin', label: 'مدیر' },
|
||||
{ value: 'student', label: 'دانشآموز' },
|
||||
{ value: 'admin', label: 'مدیر', changeable: false },
|
||||
{ value: 'student', label: 'طلبه', changeable: true },
|
||||
{ value: 'teacher', label: 'استاد', changeable: true },
|
||||
{ value: 'counselor', label: 'مشاور', changeable: true },
|
||||
{ value: 'missionary', label: 'مبلغ', changeable: true },
|
||||
])
|
||||
|
||||
export const LOGIN_STEPS = Object.freeze({
|
||||
@@ -35,18 +38,21 @@ export const STUDENT_REGISTRATION = Object.freeze({
|
||||
|
||||
export const STUDENT_REGISTRATION_ORDER = Object.freeze(Object.values(STUDENT_REGISTRATION))
|
||||
|
||||
// User lifecycle states (Set User Status endpoint):
|
||||
// pending – registered, phone not yet OTP-verified
|
||||
// verified – phone OTP-verified and registration steps completed (awaiting admin)
|
||||
// approved – approved by admin (full access)
|
||||
// blocked – blocked by admin (no access; existing tokens revoked)
|
||||
export const USER_STATUS = Object.freeze({
|
||||
pending: 'تأیید نشده',
|
||||
verified: 'در انتظار تأیید',
|
||||
approved: 'تأیید شده',
|
||||
pending: 'در انتظار',
|
||||
rejected: 'رد شده',
|
||||
blocked: 'مسدود',
|
||||
})
|
||||
|
||||
export const MARITAL_STATUS = Object.freeze({
|
||||
single: 'مجرد',
|
||||
married: 'متاهل',
|
||||
divorced: 'مطلقه',
|
||||
widowed: 'بیوه',
|
||||
})
|
||||
|
||||
export const GENDER = Object.freeze({
|
||||
@@ -54,19 +60,12 @@ export const GENDER = Object.freeze({
|
||||
female: 'خانم',
|
||||
})
|
||||
|
||||
export const ROLE_LABELS = Object.freeze({
|
||||
admin: 'مدیر',
|
||||
counselor: 'مشاور',
|
||||
missionary: 'مبلغ',
|
||||
student: 'دانشجو',
|
||||
teacher: 'استاد',
|
||||
})
|
||||
export const ROLE_LABELS = Object.freeze(Object.fromEntries(ROLES.map((r) => [r.value, r.label])))
|
||||
|
||||
export const CHANGEABLE_ROLES = Object.freeze(['student', 'teacher', 'missionary', 'counselor'])
|
||||
export const CHANGEABLE_ROLES = Object.freeze(ROLES.filter((r) => r.changeable).map((r) => r.value))
|
||||
|
||||
export const EDUCATION_STATUS = Object.freeze({
|
||||
seminary_student: 'طلبه',
|
||||
seminary_and_university_student: 'طلبه و دانشجو',
|
||||
graduate: 'فارغ التحصیلی',
|
||||
})
|
||||
|
||||
@@ -74,14 +73,13 @@ export const SEMINARY_LEVEL = Object.freeze({
|
||||
level_2: 'سطح دو',
|
||||
level_3: 'سطح سه',
|
||||
level_4: 'سطح چهار',
|
||||
not_applicable: 'طلبه نیستم',
|
||||
})
|
||||
|
||||
export const UNIVERSITY_LEVEL = Object.freeze({
|
||||
bachelor: ' مقطع کارشناسی (لیسانس)',
|
||||
master: ' مقطع کارشناسی ارشد (فوق لیسانس)',
|
||||
phd: ' مقطع دکتری',
|
||||
not_applicable: 'دانشجو نیستم',
|
||||
not_applicable: 'طلبه نیستم',
|
||||
})
|
||||
|
||||
export const PROPAGATION_PLATFORM = Object.freeze({
|
||||
@@ -168,12 +166,25 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
|
||||
optional: 'اختیاری',
|
||||
})
|
||||
|
||||
// Single ticket-lifecycle vocabulary. Used by every ticket-like resource —
|
||||
// admin /messages, admin /services, admin /consultations, counselor, student
|
||||
// /my-tickets, /user/services, /user/consultants. Backend exposes exactly
|
||||
// these three values across all of them.
|
||||
export const TICKET_STATUS = Object.freeze({
|
||||
open: 'باز',
|
||||
answered: 'پاسخ داده شده',
|
||||
closed: 'بسته شده',
|
||||
})
|
||||
|
||||
// Priority levels for student-created tickets. Sent to the backend on POST
|
||||
// /student/tickets (currently carried via the `category` field).
|
||||
export const TICKET_PRIORITY = Object.freeze({
|
||||
low: 'کم اهمیت',
|
||||
normal: 'عادی',
|
||||
high: 'مهم',
|
||||
urgent: 'فوری',
|
||||
})
|
||||
|
||||
export const STUDENT_COURSE_STATUS = Object.freeze({
|
||||
active: 'در حال گذراندن',
|
||||
completed: 'تکمیل شده',
|
||||
@@ -185,13 +196,6 @@ export const PRIORITY_STATUS = Object.freeze({
|
||||
low: 'پایین',
|
||||
})
|
||||
|
||||
export const SERVICE_STATUS = Object.freeze({
|
||||
approved: 'تایید شده',
|
||||
pending: 'در انتظار بررسی',
|
||||
in_progress: 'در حال انجام',
|
||||
completed: 'تکمیل شده',
|
||||
})
|
||||
|
||||
export const SERVICE_TYPE = Object.freeze({
|
||||
loan: 'وام',
|
||||
consultation: 'مشاوره',
|
||||
@@ -199,8 +203,20 @@ export const SERVICE_TYPE = Object.freeze({
|
||||
other: 'سایر',
|
||||
})
|
||||
|
||||
export const CONSULTATION_STATUS = Object.freeze({
|
||||
open: 'در حال گفتگو',
|
||||
answered: 'پاسخ داده شده',
|
||||
closed: 'بسته شده',
|
||||
// Lifecycle of a dispatch request assigned to a missionary. `pending` is the
|
||||
// initial backend state; the missionary may set seen, accepted, or rejected.
|
||||
export const MISSIONARY_REQUEST_STATUS = Object.freeze({
|
||||
pending: 'در انتظار بررسی',
|
||||
seen: 'دیده شده',
|
||||
accepted: 'پذیرفته شده',
|
||||
rejected: 'رد شده',
|
||||
})
|
||||
|
||||
// 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.
|
||||
export const ROLE_HOME_ROUTES = Object.freeze({
|
||||
admin: 'admin-dashboard',
|
||||
counselor: 'counselor-dashboard',
|
||||
missionary: 'missionary-dashboard',
|
||||
student: 'student-dashboard',
|
||||
})
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
<div class="assignment-item__sub">
|
||||
<span class="assignment-item__sub-label">دوره:</span>
|
||||
<span class="assignment-item__sub-value">
|
||||
{{ assignment.course?.title || assignment.courseTitle || '—' }}
|
||||
{{ assignment.session?.course?.title || '—' }}
|
||||
</span>
|
||||
<span class="assignment-item__dot">|</span>
|
||||
<span class="assignment-item__sub-label">جلسه:</span>
|
||||
<span class="assignment-item__sub-value">
|
||||
{{ assignment.session?.title || assignment.sessionTitle || '—' }}
|
||||
{{ assignment.session?.title || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="assignment-item__meta">
|
||||
<div class="assignment-item__pill">
|
||||
<span class="assignment-item__pill-label">مدت زمان ارسال:</span>
|
||||
<span class="assignment-item__pill-label">تاریخ سررسید:</span>
|
||||
<span class="assignment-item__pill-value">{{ duration }}</span>
|
||||
</div>
|
||||
<div class="assignment-item__pill">
|
||||
@@ -87,17 +87,13 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'show-details', 'show-submissions'])
|
||||
|
||||
const duration = computed(() =>
|
||||
props.assignment.durationDays == null ? '—' : `${props.assignment.durationDays} روز`
|
||||
)
|
||||
const duration = computed(() => formatJalaaliDate(props.assignment.deadline) || '—')
|
||||
|
||||
const submissions = computed(() =>
|
||||
props.assignment.submissionsCount == null ? '—' : `${props.assignment.submissionsCount} نفر`
|
||||
props.assignment.submittersCount == null ? '—' : `${props.assignment.submittersCount} نفر`
|
||||
)
|
||||
|
||||
const createdAt = computed(
|
||||
() => props.assignment.faCreatedAt || formatJalaaliDate(props.assignment.createdAt) || '—'
|
||||
)
|
||||
const createdAt = computed(() => formatJalaaliDate(props.assignment.createdAt) || '—')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
<div class="assignments-filters__grid">
|
||||
<TextField v-model="form.title" name="title" label="عنوان تکلیف">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-model="form.termId"
|
||||
name="termId"
|
||||
label="ترم"
|
||||
:options="termOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTerms"
|
||||
@update:modelValue="onTermChange"
|
||||
/>
|
||||
<SelectField
|
||||
v-if="form.termId"
|
||||
v-model="form.courseId"
|
||||
name="courseId"
|
||||
label="دوره"
|
||||
@@ -15,8 +27,10 @@
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTemplates"
|
||||
@update:modelValue="onCourseChange"
|
||||
/>
|
||||
<SelectField
|
||||
v-if="form.courseId"
|
||||
v-model="form.sessionId"
|
||||
name="sessionId"
|
||||
label="جلسه"
|
||||
@@ -40,7 +54,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
@@ -65,6 +79,7 @@ import TextField from '@/components/form/TextField.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||
|
||||
@@ -73,6 +88,7 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: '',
|
||||
termId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
fromDate: '',
|
||||
@@ -85,6 +101,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||
|
||||
const emptyForm = () => ({
|
||||
title: '',
|
||||
termId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
fromDate: '',
|
||||
@@ -102,10 +119,21 @@ watch(
|
||||
|
||||
const todayIso = new Date().toISOString()
|
||||
|
||||
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 templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templateFilters = computed(() => ({
|
||||
title: templateSearch.value,
|
||||
termId: form.value.termId || undefined,
|
||||
}))
|
||||
const templatePagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
|
||||
enabled: () => !!form.value.termId,
|
||||
})
|
||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||
|
||||
const sessionSearch = ref('')
|
||||
@@ -114,9 +142,18 @@ const sessionListFilters = computed(() => ({
|
||||
courseId: form.value.courseId || undefined,
|
||||
}))
|
||||
const sessionPagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination)
|
||||
const { data: sessionsResponse } = useAdminSessionsListQuery(
|
||||
sessionListFilters,
|
||||
sessionPagination,
|
||||
{
|
||||
enabled: () => !!form.value.courseId,
|
||||
}
|
||||
)
|
||||
const sessionOptions = computed(() => sessionsResponse.value?.data ?? [])
|
||||
|
||||
const searchTerms = useDebounce((q) => {
|
||||
termSearch.value = q || ''
|
||||
}, 400)
|
||||
const searchTemplates = useDebounce((q) => {
|
||||
templateSearch.value = q || ''
|
||||
}, 400)
|
||||
@@ -124,6 +161,15 @@ const searchSessions = useDebounce((q) => {
|
||||
sessionSearch.value = q || ''
|
||||
}, 400)
|
||||
|
||||
const onTermChange = () => {
|
||||
form.value.courseId = ''
|
||||
form.value.sessionId = ''
|
||||
}
|
||||
|
||||
const onCourseChange = () => {
|
||||
form.value.sessionId = ''
|
||||
}
|
||||
|
||||
const hasFilters = computed(() =>
|
||||
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
|
||||
)
|
||||
@@ -157,7 +203,7 @@ const onReset = () => {
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-model="form.termId"
|
||||
name="termId"
|
||||
label="ترم مرتبط"
|
||||
:options="termOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTerms"
|
||||
:error="errors.termId"
|
||||
@update:model-value="onTermChange"
|
||||
/>
|
||||
<SelectField
|
||||
v-model="form.courseId"
|
||||
name="courseId"
|
||||
@@ -30,6 +42,7 @@
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTemplates"
|
||||
:disabled="!form.termId"
|
||||
:error="errors.courseId"
|
||||
@update:model-value="onCourseChange"
|
||||
/>
|
||||
@@ -46,21 +59,15 @@
|
||||
:error="errors.sessionId"
|
||||
/>
|
||||
<DatePickerField
|
||||
v-model="form.startDate"
|
||||
name="startDate"
|
||||
label="تاریخ شروع"
|
||||
:error="errors.startDate"
|
||||
/>
|
||||
<DatePickerField
|
||||
v-model="form.endDate"
|
||||
name="endDate"
|
||||
label="تاریخ پایان"
|
||||
:error="errors.endDate"
|
||||
v-model="form.deadline"
|
||||
name="deadline"
|
||||
label="ددلاین"
|
||||
:error="errors.deadline"
|
||||
/>
|
||||
<SelectField
|
||||
v-model="form.priority"
|
||||
name="priority"
|
||||
label="اولویت"
|
||||
label="اولویت تکلیف"
|
||||
:options="priorityOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
@@ -77,13 +84,13 @@
|
||||
</div>
|
||||
|
||||
<div class="add-assignment__files">
|
||||
<LineTitleBlock title="فایلهای تکلیف" title-en="Assignment Files" />
|
||||
<FileUploader
|
||||
v-model="attachments"
|
||||
accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx"
|
||||
:multiple="true"
|
||||
:max-files="10"
|
||||
context="assignment"
|
||||
@select="onAttachmentsSelect"
|
||||
@error="onAttachmentsError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +124,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from 'vue3-toastify'
|
||||
import useYup from '@/composables/useYup'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
@@ -128,10 +136,12 @@ import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import FileUploader from '@/components/form/FileUploader.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
||||
import { assignmentSchema } from '@/features/admin/assignments/schema'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||
@@ -158,10 +168,10 @@ const priorityOptions = Object.entries(ASSIGNMENT_PRIORITY).map(([value, label])
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
termId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
deadline: '',
|
||||
priority: 'mandatory',
|
||||
description: '',
|
||||
})
|
||||
@@ -172,10 +182,28 @@ const schema = assignmentSchema
|
||||
|
||||
const { validate, validateAt, errors, resetErrors } = useYup(schema)
|
||||
|
||||
const termSearch = ref('')
|
||||
const termFilters = computed(() => ({ title: termSearch.value }))
|
||||
const termPagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||
const selectedTerm = ref(null)
|
||||
const termOptions = computed(() => {
|
||||
const base = termsResponse.value?.data ?? []
|
||||
if (selectedTerm.value && !base.some((t) => t.id === selectedTerm.value.id)) {
|
||||
return [...base, selectedTerm.value]
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templateFilters = computed(() => ({
|
||||
title: templateSearch.value,
|
||||
termId: form.value.termId || undefined,
|
||||
}))
|
||||
const templatePagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
|
||||
enabled: () => !!form.value.termId,
|
||||
})
|
||||
const selectedTemplate = ref(null)
|
||||
const templateOptions = computed(() => {
|
||||
const base = templatesResponse.value?.data ?? []
|
||||
@@ -203,6 +231,9 @@ const sessionOptions = computed(() => {
|
||||
return base
|
||||
})
|
||||
|
||||
const searchTerms = useDebounce((q) => {
|
||||
termSearch.value = q || ''
|
||||
}, 400)
|
||||
const searchTemplates = useDebounce((q) => {
|
||||
templateSearch.value = q || ''
|
||||
}, 400)
|
||||
@@ -210,40 +241,84 @@ const searchSessions = useDebounce((q) => {
|
||||
sessionSearch.value = q || ''
|
||||
}, 400)
|
||||
|
||||
const onTermChange = (value) => {
|
||||
form.value.termId = value
|
||||
form.value.courseId = ''
|
||||
form.value.sessionId = ''
|
||||
selectedTemplate.value = null
|
||||
selectedSession.value = null
|
||||
}
|
||||
|
||||
const onCourseChange = (value) => {
|
||||
form.value.courseId = value
|
||||
form.value.sessionId = ''
|
||||
selectedSession.value = null
|
||||
}
|
||||
|
||||
const uploadMediaMutation = useUploadMediaMutation()
|
||||
|
||||
const onAttachmentsSelect = async (files) => {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' })
|
||||
const response = await uploadMediaMutation.mutateAsync(fd)
|
||||
const payload = response?.data ?? response
|
||||
const id = payload?.id ?? payload?.uploadId
|
||||
if (id == null) continue
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
|
||||
]
|
||||
} catch {
|
||||
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onAttachmentsError = (msg) => toast.error(msg)
|
||||
|
||||
const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
|
||||
enabled: () => !!assignmentId.value,
|
||||
})
|
||||
|
||||
watch(existingAssignment, (assignment) => {
|
||||
if (!assignment) return
|
||||
const tpl = assignment.course
|
||||
const sessionEntity = assignment.session
|
||||
if (tpl) selectedTemplate.value = tpl
|
||||
if (sessionEntity) selectedSession.value = sessionEntity
|
||||
form.value = {
|
||||
title: assignment.title || '',
|
||||
courseId: tpl?.id || assignment.courseId || '',
|
||||
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
||||
startDate: assignment.startDate || '',
|
||||
endDate: assignment.endDate || '',
|
||||
priority: assignment.priority || 'mandatory',
|
||||
description: assignment.description || '',
|
||||
}
|
||||
attachments.value = Array.isArray(assignment.attachments)
|
||||
? assignment.attachments.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.title || `فایل ${file.id}`,
|
||||
url: file.fileUrl || '',
|
||||
type: file.type || 'other',
|
||||
}))
|
||||
: []
|
||||
})
|
||||
watch(
|
||||
existingAssignment,
|
||||
(assignment) => {
|
||||
if (!assignment) return
|
||||
const sessionEntity = assignment.session
|
||||
const tpl = sessionEntity?.course || assignment.course
|
||||
const termEntity = tpl?.term || assignment.term
|
||||
const termId = termEntity?.id || tpl?.termId || assignment.termId || ''
|
||||
const priorityValue =
|
||||
assignment.priority ||
|
||||
(typeof assignment.isPriority === 'boolean'
|
||||
? assignment.isPriority
|
||||
? 'mandatory'
|
||||
: 'optional'
|
||||
: 'mandatory')
|
||||
if (termEntity) selectedTerm.value = termEntity
|
||||
if (tpl) selectedTemplate.value = tpl
|
||||
if (sessionEntity) selectedSession.value = sessionEntity
|
||||
form.value = {
|
||||
title: assignment.title || '',
|
||||
termId,
|
||||
courseId: tpl?.id || sessionEntity?.courseId || assignment.courseId || '',
|
||||
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
||||
deadline: assignment.deadline || assignment.endDate || '',
|
||||
priority: priorityValue,
|
||||
description: assignment.description || '',
|
||||
}
|
||||
attachments.value = Array.isArray(assignment.media)
|
||||
? assignment.media.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.fileName || `فایل ${m.id}`,
|
||||
size: m.fileSize ?? 0,
|
||||
url: m.url || m.downloadUrl || '',
|
||||
}))
|
||||
: []
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const addMutation = useAddAdminAssignmentMutation()
|
||||
const updateMutation = useUpdateAdminAssignmentMutation()
|
||||
@@ -254,13 +329,13 @@ const onSubmit = async (close) => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
if (!isValid) return
|
||||
const finalPayload = {
|
||||
...payload,
|
||||
attachments: attachments.value.map((file, index) => ({
|
||||
fileId: file.id,
|
||||
type: file.type || 'other',
|
||||
title: file.name,
|
||||
order: index + 1,
|
||||
})),
|
||||
sessionId: payload.sessionId,
|
||||
title: payload.title,
|
||||
description: payload.description ?? '',
|
||||
deadline: payload.deadline || null,
|
||||
isActive: true,
|
||||
isPriority: payload.priority === 'mandatory',
|
||||
mediaIds: attachments.value.map((file) => file.id).filter((id) => id != null),
|
||||
}
|
||||
if (isEditMode.value) {
|
||||
await updateMutation.mutateAsync({ id: assignmentId.value, payload: finalPayload })
|
||||
|
||||
@@ -14,35 +14,17 @@
|
||||
<div class="assignment-details__head">
|
||||
<p class="assignment-details__title">{{ assignment.title || '—' }}</p>
|
||||
<p class="assignment-details__sub">
|
||||
<span>
|
||||
دوره:
|
||||
{{ assignment.course?.title || assignment.courseTitle || '—' }}
|
||||
</span>
|
||||
<span>دوره: {{ courseTitle }}</span>
|
||||
<span class="assignment-details__sep">|</span>
|
||||
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span>
|
||||
<span>جلسه: {{ sessionTitle }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="assignment-details__grid">
|
||||
<LineInfoBlock
|
||||
title="تاریخ شروع"
|
||||
:numeric-desc="formatJalaaliDate(assignment.startDate) || '—'"
|
||||
/>
|
||||
<LineInfoBlock
|
||||
title="تاریخ پایان"
|
||||
:numeric-desc="formatJalaaliDate(assignment.endDate) || '—'"
|
||||
/>
|
||||
<LineInfoBlock
|
||||
title="مدت زمان"
|
||||
:numeric-desc="
|
||||
assignment.durationDays != null ? `${assignment.durationDays} روز` : '—'
|
||||
"
|
||||
/>
|
||||
<LineInfoBlock title="اولویت" :desc="ASSIGNMENT_PRIORITY[assignment.priority] || '—'" />
|
||||
<LineInfoBlock
|
||||
title="تعداد فرستندگان"
|
||||
:numeric-desc="assignment.submissionsCount ?? 0"
|
||||
/>
|
||||
<LineInfoBlock title="تاریخ پایان" :numeric-desc="deadlineLabel" />
|
||||
<LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" />
|
||||
<LineInfoBlock title="اولویت" :desc="priorityLabel" />
|
||||
<LineInfoBlock title="تعداد فرستندگان" :numeric-desc="submittersLabel" />
|
||||
<LineInfoBlock
|
||||
title="تاریخ ثبت"
|
||||
:numeric-desc="
|
||||
@@ -55,18 +37,18 @@
|
||||
<LineInfoBlock title="توضیحات" :desc="assignment.description" />
|
||||
</div>
|
||||
|
||||
<div v-if="assignment.attachments?.length" class="assignment-details__attachments">
|
||||
<div v-if="attachmentItems.length > 0" class="assignment-details__attachments">
|
||||
<LineTitleBlock title="فایلهای تکلیف" title-en="Attachments" />
|
||||
<ul class="assignment-details__attachments-list">
|
||||
<li v-for="file in assignment.attachments" :key="file.id">
|
||||
<li v-for="file in attachmentItems" :key="file.id">
|
||||
<a
|
||||
:href="file.fileUrl || '#'"
|
||||
:href="file.url || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="assignment-details__file"
|
||||
>
|
||||
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
|
||||
<span>{{ file.title || `فایل ${file.id}` }}</span>
|
||||
<span>{{ file.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -112,6 +94,65 @@ const assignmentId = computed(() => modalData.value.id ?? null)
|
||||
const { data: assignment, isLoading } = useAdminAssignmentQuery(assignmentId, {
|
||||
enabled: () => !!assignmentId.value,
|
||||
})
|
||||
|
||||
const courseTitle = computed(
|
||||
() =>
|
||||
assignment.value?.session?.course?.title ||
|
||||
assignment.value?.course?.title ||
|
||||
assignment.value?.courseTitle ||
|
||||
'—'
|
||||
)
|
||||
|
||||
const sessionTitle = computed(
|
||||
() => assignment.value?.session?.title || assignment.value?.sessionTitle || '—'
|
||||
)
|
||||
|
||||
const deadlineRaw = computed(() => assignment.value?.endDate || assignment.value?.deadline || '')
|
||||
const deadlineLabel = computed(() => formatJalaaliDate(deadlineRaw.value) || '—')
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (assignment.value?.durationDays != null) return `${assignment.value.durationDays} روز`
|
||||
const start = assignment.value?.startDate || assignment.value?.createdAt
|
||||
const end = deadlineRaw.value
|
||||
if (!start || !end) return '—'
|
||||
const diff = Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000)
|
||||
return diff >= 0 ? `${diff} روز` : '—'
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
const priority =
|
||||
assignment.value?.priority ||
|
||||
(typeof assignment.value?.isPriority === 'boolean'
|
||||
? assignment.value.isPriority
|
||||
? 'mandatory'
|
||||
: 'optional'
|
||||
: null)
|
||||
return ASSIGNMENT_PRIORITY[priority] || '—'
|
||||
})
|
||||
|
||||
const submittersLabel = computed(
|
||||
() => assignment.value?.submittersCount ?? assignment.value?.submissionsCount ?? 0
|
||||
)
|
||||
|
||||
const attachmentItems = computed(() => {
|
||||
const media = assignment.value?.media
|
||||
if (Array.isArray(media) && media.length > 0) {
|
||||
return media.map((m) => ({
|
||||
id: m.id,
|
||||
title: m.fileName || `فایل ${m.id}`,
|
||||
url: m.url || m.downloadUrl || '',
|
||||
}))
|
||||
}
|
||||
const legacy = assignment.value?.attachments
|
||||
if (Array.isArray(legacy)) {
|
||||
return legacy.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title || `فایل ${a.id}`,
|
||||
url: a.fileUrl || '',
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="55rem" min-width="auto" :show-close-button="true">
|
||||
<BasicModal
|
||||
title="جزئیات تکلیف"
|
||||
title-en="Submission Details"
|
||||
width="95%"
|
||||
max-width="55rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<div class="submission-details">
|
||||
<LineTitleBlock title="جزئیات تکلیف" title-en="Submission Details" />
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
||||
<template v-else-if="submission">
|
||||
<div class="submission-details__person">
|
||||
@@ -44,7 +49,7 @@
|
||||
</div>
|
||||
|
||||
<div class="submission-details__row">
|
||||
<LineInfoBlock title="پاسخ دانشجو" :desc="submission.userDescription || '—'" />
|
||||
<LineInfoBlock title="پاسخ طلبه" :desc="submission.userDescription || '—'" />
|
||||
</div>
|
||||
|
||||
<div v-if="submission.attachments?.length" class="submission-details__attachments">
|
||||
@@ -136,7 +141,6 @@ import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true">
|
||||
<template #default="{ data, close }">
|
||||
<BasicModal
|
||||
title="تکالیف فرستادگان"
|
||||
title-en="Delegates’ Submissions"
|
||||
width="95%"
|
||||
max-width="60rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<div class="assignment-submissions">
|
||||
<LineTitleBlock title="تکالیف فرستادگان" title-en="Delegates’ Submissions" />
|
||||
<p v-if="data?.title" class="assignment-submissions__assignment">{{ data.title }}</p>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
|
||||
<div v-else-if="submissions.length > 0" class="assignment-submissions__list">
|
||||
<div
|
||||
@@ -73,7 +77,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import { useAdminAssignmentSubmissionsQuery } from '@/services/query/admin-assignments'
|
||||
@@ -101,15 +104,16 @@ const paginationMeta = computed(() => ({
|
||||
}))
|
||||
|
||||
const userName = (user) =>
|
||||
`${user?.firstName || ''} ${user?.lastName || ''}`.trim() || user?.fullName || '—'
|
||||
user?.name || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || user?.fullName || '—'
|
||||
|
||||
const locationFromAddress = (user) => {
|
||||
const province = user?.address?.province?.name || ''
|
||||
const city = user?.address?.city?.name || ''
|
||||
const province = user?.province?.name || user?.address?.province?.name || ''
|
||||
const city = user?.city?.name || user?.address?.city?.name || ''
|
||||
return [province, city].filter(Boolean).join('، ')
|
||||
}
|
||||
|
||||
const submittedAt = (sub) => sub.faSubmittedAt || formatJalaaliDate(sub.submittedAt) || '—'
|
||||
const submittedAt = (sub) =>
|
||||
sub.faSubmittedAt || formatJalaaliDate(sub.submittedAt || sub.createdAt || sub.reviewedAt) || '—'
|
||||
|
||||
const onShowDetails = (submission) => {
|
||||
openModal('AssignmentSubmissionDetailsModal', {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as AddAssignmentModal } from './components/modals/AddAssignmentModal.vue'
|
||||
export { default as AssignmentDetailsModal } from './components/modals/AssignmentDetailsModal.vue'
|
||||
export { default as AssignmentItem } from './components/AssignmentItem.vue'
|
||||
export { default as AssignmentSubmissionDetailsModal } from './components/modals/AssignmentSubmissionDetailsModal.vue'
|
||||
export { default as AssignmentSubmissionsModal } from './components/modals/AssignmentSubmissionsModal.vue'
|
||||
export { default as AssignmentsFilters } from './components/AssignmentsFilters.vue'
|
||||
|
||||
export * from './schema'
|
||||
@@ -6,7 +6,7 @@
|
||||
desc="در این قسمت میتوانید تکالیف خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="check-square" :size="24" color="var(--color-primary)" />
|
||||
<img :src="assignmentIcon" alt="" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</SimpleTitleIconBlock>
|
||||
<BaseButton text="افزودن تکلیف جدید" custom-class="assignments-page__add-btn" @click="onAdd">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="18" color="#fff" />
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
@@ -75,8 +76,11 @@ import {
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const assignmentIcon = gallery.assignmentIcon
|
||||
|
||||
const filters = ref({
|
||||
title: '',
|
||||
termId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
fromDate: '',
|
||||
|
||||
@@ -4,8 +4,7 @@ export const assignmentSchema = object().shape({
|
||||
title: string().required().min(3),
|
||||
courseId: string().required(),
|
||||
sessionId: string().required(),
|
||||
startDate: string().required(),
|
||||
endDate: string().required(),
|
||||
deadline: string().notRequired(),
|
||||
priority: string().required(),
|
||||
description: string().nullable().notRequired(),
|
||||
})
|
||||
|
||||
@@ -17,19 +17,15 @@
|
||||
</div>
|
||||
|
||||
<div class="consultation-item__meta">
|
||||
<div class="consultation-item__pill">
|
||||
<span class="consultation-item__pill-label">تاریخ پیام :</span>
|
||||
<span class="consultation-item__pill-value">{{ createdAt }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="consultation-item__status"
|
||||
:class="`consultation-item__status--${consultation.status || 'open'}`"
|
||||
<Badge variant="neutral" size="sm" label="تاریخ پیام :" :value="createdAt" />
|
||||
<Badge
|
||||
:variant="statusVariant"
|
||||
size="sm"
|
||||
dot
|
||||
clickable
|
||||
:value="statusLabel"
|
||||
@click="onStatusClick"
|
||||
>
|
||||
<span class="consultation-item__status-dot" />
|
||||
<span>{{ statusLabel }}</span>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="consultation-item__actions">
|
||||
@@ -55,12 +51,19 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { CONSULTATION_STATUS } from '@/enums'
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
open: 'primary',
|
||||
answered: 'success',
|
||||
closed: 'info',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
consultation: { type: Object, required: true },
|
||||
})
|
||||
@@ -69,7 +72,9 @@ const emit = defineEmits(['show-details', 'change-status'])
|
||||
|
||||
const userName = computed(() => props.consultation.student?.name || '—')
|
||||
|
||||
const statusLabel = computed(() => CONSULTATION_STATUS[props.consultation.status] || '—')
|
||||
const statusLabel = computed(() => TICKET_STATUS[props.consultation.status] || '—')
|
||||
|
||||
const statusVariant = computed(() => STATUS_VARIANT[props.consultation.status] || 'neutral')
|
||||
|
||||
const createdAt = computed(() => formatJalaaliDate(props.consultation.createdAt) || '—')
|
||||
|
||||
@@ -87,7 +92,7 @@ const onStatusClick = (event) => {
|
||||
}
|
||||
|
||||
const menuItems = computed(() =>
|
||||
Object.entries(CONSULTATION_STATUS)
|
||||
Object.entries(TICKET_STATUS)
|
||||
.filter(([value]) => value !== props.consultation.status)
|
||||
.map(([value, label]) => ({
|
||||
key: `status-${value}`,
|
||||
@@ -183,72 +188,6 @@ const menuItems = computed(() =>
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.375rem 0.875rem;
|
||||
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;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
border: none;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
&--answered {
|
||||
background: rgba(0, 154, 18, 8%);
|
||||
color: #009a12;
|
||||
}
|
||||
|
||||
&--open {
|
||||
background: rgba(0, 112, 116, 10%);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: rgba(104, 104, 104, 10%);
|
||||
color: #686868;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
@@ -46,8 +46,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TICKET_STATUS } from '@/enums'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CONSULTATION_STATUS } from '@/enums'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
@@ -76,7 +76,7 @@ watch(
|
||||
|
||||
const todayIso = new Date().toISOString()
|
||||
|
||||
const statusOptions = Object.entries(CONSULTATION_STATUS).map(([value, label]) => ({
|
||||
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
|
||||
<BasicModal
|
||||
title="جزئیات مشاوره"
|
||||
title-en="Consultation Details"
|
||||
width="95%"
|
||||
max-width="64rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default>
|
||||
<div class="consultation-details">
|
||||
<LineTitleBlock title="جزئیات مشاوره" title-en="Consultation Details" />
|
||||
|
||||
<div v-if="consultation" class="consultation-details__date">
|
||||
<span>{{ consultationDate }}</span>
|
||||
</div>
|
||||
@@ -55,7 +60,6 @@ import BasicModal from '@/components/BasicModal.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
adminConsultationsKeys,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ConsultationDetailsModal } from './components/modals/ConsultationDetailsModal.vue'
|
||||
export { default as ConsultationItem } from './components/ConsultationItem.vue'
|
||||
export { default as ConsultationsFilters } from './components/ConsultationsFilters.vue'
|
||||
@@ -62,14 +62,14 @@ import {
|
||||
const { openModal, isModal } = useModal()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const filters = ref({ type: 'advise', userName: '', status: '', fromDate: '', toDate: '' })
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data, isLoading } = useAdminConsultationsListQuery(filters, pagination, {
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
const consultations = computed(() => data.value?.data ?? [])
|
||||
const consultations = computed(() => data.value?.data?.items ?? [])
|
||||
const paginationMeta = computed(() => ({
|
||||
page: pagination.value.page,
|
||||
perPage: pagination.value.perPage,
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
<div class="courses-filters__grid">
|
||||
<TextField v-model="form.title" name="title" label="عنوان دوره">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||
<SvgIcon name="graduation-cap" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-if="showTermFilter"
|
||||
v-model="form.termId"
|
||||
name="termId"
|
||||
label="ترم"
|
||||
@@ -68,7 +67,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ title: '', termId: '', status: '', fromDate: '', toDate: '' }),
|
||||
},
|
||||
showTermFilter: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||
@@ -99,10 +97,8 @@ const statusOptions = [
|
||||
]
|
||||
|
||||
const termFilters = ref({})
|
||||
const termPagination = ref({ page: 1, perPage: 100 })
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination, {
|
||||
enabled: () => props.showTermFilter,
|
||||
})
|
||||
const termPagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
||||
|
||||
const hasFilters = computed(() =>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true">
|
||||
<BasicModal
|
||||
:title="modeTitle"
|
||||
title-en="Offered Course"
|
||||
width="95%"
|
||||
max-width="60rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<form class="offered-course" @submit.prevent="onSubmit(close)">
|
||||
<LineTitleBlock :title="modeTitle" title-en="Offered Course" />
|
||||
|
||||
<div class="offered-course__grid">
|
||||
<div class="offered-course__image-col">
|
||||
<ImageCropper
|
||||
@@ -115,7 +120,6 @@ import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||
import ImageCropper from '@/components/form/ImageCropper.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
|
||||
@@ -82,7 +82,6 @@ import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { adminCoursesKeys } from '@/services/query/admin-courses'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="course-details__grid">
|
||||
<LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" />
|
||||
<LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" />
|
||||
<LineInfoBlock title="پیشنیاز دوره" :desc="teacherName" />
|
||||
<LineInfoBlock title="پیشنیاز دوره" :desc="prerequisiteCourses" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,11 +99,9 @@ const { data: course, isLoading } = useAdminCourseQuery(courseId, {
|
||||
enabled: () => !!courseId.value,
|
||||
})
|
||||
|
||||
const teacherName = computed(() => {
|
||||
const t = course.value?.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
})
|
||||
const prerequisiteCourses = computed(() =>
|
||||
course.value.prerequisiteCourses.map((course_) => course_.title).join(' ,')
|
||||
)
|
||||
|
||||
const sessionsFilters = computed(() => ({ courseId: courseId.value }))
|
||||
const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as AddOfferedCourseModal } from './components/modals/AddOfferedCourseModal.vue'
|
||||
export { default as AddSessionToCourseModal } from './components/modals/AddSessionToCourseModal.vue'
|
||||
export { default as CourseDetailsModal } from './components/modals/CourseDetailsModal.vue'
|
||||
export { default as CourseItem } from './components/CourseItem.vue'
|
||||
export { default as CourseSessionItem } from './components/CourseSessionItem.vue'
|
||||
export { default as CoursesFilters } from './components/CoursesFilters.vue'
|
||||
|
||||
export * from './schema'
|
||||
@@ -14,16 +14,6 @@
|
||||
|
||||
<form class="course-form__form" @submit.prevent="onSubmit">
|
||||
<div class="course-form__grid">
|
||||
<div class="course-form__image-col">
|
||||
<ImageCropper
|
||||
v-model="image"
|
||||
name="image"
|
||||
bg-color="#eeeeee"
|
||||
@crop="onImageCropped"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="course-form__main-col">
|
||||
<div class="course-form__row">
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
@@ -65,28 +55,30 @@
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.prerequisites"
|
||||
name="prerequisites"
|
||||
v-model="form.termId"
|
||||
name="termId"
|
||||
label="ترم"
|
||||
:options="termOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:error="errors.termId"
|
||||
/>
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.prerequisiteCourseIds"
|
||||
name="prerequisiteCourseIds"
|
||||
label="دورههای پیشنیاز"
|
||||
:options="prerequisiteOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:multiple="true"
|
||||
:searchable="true"
|
||||
:on-search="searchPrerequisites"
|
||||
/>
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.contentType"
|
||||
name="contentType"
|
||||
label="نوع فایل دوره"
|
||||
:options="contentTypeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:error="errors.contentType"
|
||||
:disabled="!form.termId"
|
||||
:on-search="searchPrerequisiteCourses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="course-form__cell course-form__cell--full">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
@@ -98,6 +90,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-form__image-col">
|
||||
<ImageCropper
|
||||
v-model="image"
|
||||
name="image"
|
||||
bg-color="#eeeeee"
|
||||
@crop="onImageCropped"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-form__divider" />
|
||||
@@ -132,7 +134,6 @@
|
||||
import { toast } from 'vue3-toastify'
|
||||
import useYup from '@/composables/useYup'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { COURSE_CONTENT_TYPE } from '@/enums'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import useDebounce from '@/composables/useDebounce'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -146,6 +147,7 @@ import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import { courseSchema } from '@/features/admin/courses/schema'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
||||
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
@@ -167,8 +169,7 @@ const form = ref({
|
||||
title: '',
|
||||
teacherId: '',
|
||||
capacity: '',
|
||||
prerequisites: [],
|
||||
contentType: '',
|
||||
prerequisiteCourseIds: [],
|
||||
description: '',
|
||||
coverMediaId: null,
|
||||
termId: termId.value,
|
||||
@@ -176,11 +177,6 @@ const form = ref({
|
||||
|
||||
const image = ref(null)
|
||||
|
||||
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
const { validate, validateAt, errors } = useYup(courseSchema)
|
||||
|
||||
const teacherSearch = ref('')
|
||||
@@ -203,6 +199,11 @@ const teacherOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const termFilters = ref({})
|
||||
const termPagination = ref({ page: 1, perPage: 100 })
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
||||
|
||||
const prereqSearch = ref('')
|
||||
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
|
||||
const prereqPagination = ref({ page: 1, perPage: 10 })
|
||||
@@ -211,13 +212,14 @@ const selectedPrereqs = ref([])
|
||||
const prerequisiteOptions = computed(() => {
|
||||
const base = prereqsResponse.value?.data ?? []
|
||||
const extras = selectedPrereqs.value.filter((p) => !base.some((b) => b.id === p.id))
|
||||
return [...base, ...extras]
|
||||
const merged = [...base, ...extras]
|
||||
return courseId.value ? merged.filter((c) => c.id !== courseId.value) : merged
|
||||
})
|
||||
|
||||
const searchTeachers = useDebounce((q) => {
|
||||
teacherSearch.value = q || ''
|
||||
}, 400)
|
||||
const searchPrerequisites = useDebounce((q) => {
|
||||
const searchPrerequisiteCourses = useDebounce((q) => {
|
||||
prereqSearch.value = q || ''
|
||||
}, 400)
|
||||
|
||||
@@ -225,25 +227,29 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
||||
enabled: () => !!courseId.value,
|
||||
})
|
||||
|
||||
watch(existingCourse, (course) => {
|
||||
if (!course) return
|
||||
const teacher = course.teacher
|
||||
if (teacher) selectedTeacher.value = teacher
|
||||
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : []
|
||||
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
|
||||
watch(
|
||||
existingCourse,
|
||||
(course) => {
|
||||
if (!course) return
|
||||
const teacher = course.teacher
|
||||
if (teacher) selectedTeacher.value = teacher
|
||||
const prereqs = Array.isArray(course.prerequisiteCourseIds) ? course.prerequisiteCourseIds : []
|
||||
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
|
||||
|
||||
form.value = {
|
||||
title: course.title || '',
|
||||
teacherId: teacher?.id || course.teacherId || '',
|
||||
capacity: course.capacity ?? '',
|
||||
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
|
||||
contentType: course.contentType || '',
|
||||
description: course.description || '',
|
||||
coverMediaId: course.coverMediaId || null,
|
||||
termId: course.termId ?? termId.value,
|
||||
}
|
||||
if (course.coverUrl) image.value = { url: course.coverUrl }
|
||||
})
|
||||
form.value = {
|
||||
title: course.title || '',
|
||||
teacherId: teacher?.id || course.teacherId || '',
|
||||
capacity: course.capacity ?? '',
|
||||
prerequisiteCourseIds: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
|
||||
contentType: course.contentType || '',
|
||||
description: course.description || '',
|
||||
coverMediaId: course.coverMediaId || null,
|
||||
termId: course.termId ?? termId.value,
|
||||
}
|
||||
if (course.coverUrl) image.value = { url: course.coverUrl }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const uploadMutation = useUploadMediaMutation()
|
||||
|
||||
@@ -303,29 +309,23 @@ const onCancel = () => router.push({ name: 'admin-courses' })
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: 4fr 8fr;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: 3fr 9fr;
|
||||
grid-template-columns: 9fr 3fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-col {
|
||||
order: 2;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__main-col {
|
||||
order: 1;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 2;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,23 +6,18 @@
|
||||
desc="در این قسمت میتوانید دورههای خود را مدیریت کنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
|
||||
<img :src="courseIcon" alt="" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
<CoursesFilters
|
||||
v-model="currentFilters"
|
||||
:show-term-filter="activeTab === 'offered'"
|
||||
@apply="onFilterApply"
|
||||
@reset="onFilterReset"
|
||||
/>
|
||||
<CoursesFilters v-model="offeredFilters" @apply="onFilterApply" @reset="onFilterReset" />
|
||||
|
||||
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
|
||||
<template #templates>
|
||||
<SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="templates.length > 0">
|
||||
<TabsBlock :tabs="tabs">
|
||||
<template #offered>
|
||||
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="offered.length > 0">
|
||||
<CourseItem
|
||||
v-for="course in templates"
|
||||
v-for="course in offered"
|
||||
:key="course.id"
|
||||
:course="course"
|
||||
@edit="onEditCourse"
|
||||
@@ -31,46 +26,21 @@
|
||||
@show-details="onShowDetails"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="دوره الگویی برای نمایش وجود ندارد." />
|
||||
<PaginationBlock :pagination="templatesPaginationMeta" @update:page="setTemplatesPage" />
|
||||
</template>
|
||||
|
||||
<template #offered>
|
||||
<NoItems
|
||||
v-if="!hasOfferedTerm"
|
||||
title="ترم را انتخاب کنید"
|
||||
desc="برای نمایش دورههای ارائه شده، ابتدا ترم را از فیلترها انتخاب کنید."
|
||||
/>
|
||||
<template v-else>
|
||||
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="offered.length > 0">
|
||||
<CourseItem
|
||||
v-for="course in offered"
|
||||
:key="course.id"
|
||||
:course="course"
|
||||
@edit="onEditCourse"
|
||||
@delete="onAskDelete"
|
||||
@change-status="onChangeStatus"
|
||||
@show-details="onShowDetails"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
|
||||
</template>
|
||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
|
||||
</template>
|
||||
</TabsBlock>
|
||||
|
||||
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
|
||||
<CourseDetailsModal v-if="isModal('CourseDetailsModal')" />
|
||||
<AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||
@@ -81,7 +51,6 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue'
|
||||
import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue'
|
||||
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
|
||||
import AddSessionToCourseModal from '@/features/admin/courses/components/modals/AddSessionToCourseModal.vue'
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
@@ -95,28 +64,15 @@ const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const courseIcon = gallery.courseIcon
|
||||
|
||||
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
|
||||
|
||||
const onAdd = () => {
|
||||
if (activeTab.value === 'templates') {
|
||||
router.push({ name: 'admin-add-course' }).catch(() => {})
|
||||
} else {
|
||||
openModal('AddOfferedCourseModal', {
|
||||
mode: 'add',
|
||||
termId: routeTermId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
router.push({ name: 'admin-add-course' }).catch(() => {})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'templates',
|
||||
label: 'دورههای الگو',
|
||||
icon: 'list-bullets',
|
||||
hasButton: true,
|
||||
textButton: 'افزودن دوره الگوی جدید',
|
||||
buttonAction: onAdd,
|
||||
},
|
||||
{
|
||||
name: 'offered',
|
||||
label: 'دورههای ارائه شده',
|
||||
@@ -126,58 +82,21 @@ const tabs = [
|
||||
buttonAction: onAdd,
|
||||
},
|
||||
]
|
||||
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
|
||||
|
||||
const templateFilters = ref({})
|
||||
const offeredFilters = ref({})
|
||||
|
||||
const currentFilters = computed({
|
||||
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
|
||||
set: (val) => {
|
||||
if (activeTab.value === 'templates') templateFilters.value = val
|
||||
else offeredFilters.value = val
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
pagination: templatesPagination,
|
||||
setPage: setTemplatesPage,
|
||||
reset: resetTemplatesPagination,
|
||||
} = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const {
|
||||
pagination: offeredPagination,
|
||||
setPage: setOfferedPage,
|
||||
reset: resetOfferedPagination,
|
||||
} = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
|
||||
templateFilters,
|
||||
templatesPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'templates',
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
|
||||
|
||||
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
|
||||
offeredFilters,
|
||||
offeredPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
|
||||
const templates = computed(() => templatesData.value?.data ?? [])
|
||||
const templatesPaginationMeta = computed(() => ({
|
||||
page: templatesPagination.value.page,
|
||||
perPage: templatesPagination.value.perPage,
|
||||
...templatesData.value?.meta,
|
||||
}))
|
||||
|
||||
const offered = computed(() => offeredData.value?.data ?? [])
|
||||
const offeredPaginationMeta = computed(() => ({
|
||||
page: offeredPagination.value.page,
|
||||
@@ -185,14 +104,7 @@ const offeredPaginationMeta = computed(() => ({
|
||||
...offeredData.value?.meta,
|
||||
}))
|
||||
|
||||
const onTabChange = () => {
|
||||
/* tab managed via v-model; no further action required */
|
||||
}
|
||||
|
||||
const onFilterApply = () => {
|
||||
if (activeTab.value === 'templates') resetTemplatesPagination()
|
||||
else resetOfferedPagination()
|
||||
}
|
||||
const onFilterApply = () => resetOfferedPagination()
|
||||
const onFilterReset = onFilterApply
|
||||
|
||||
const onEditCourse = (course) => {
|
||||
@@ -200,16 +112,12 @@ const onEditCourse = (course) => {
|
||||
}
|
||||
|
||||
const onShowDetails = (course) => {
|
||||
openModal('CourseDetailsModal', {
|
||||
id: course.id,
|
||||
type: activeTab.value === 'templates' ? 'template' : 'offered',
|
||||
})
|
||||
openModal('CourseDetailsModal', { id: course.id })
|
||||
}
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const deleteMutation = useDeleteAdminCourseMutation()
|
||||
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
|
||||
const updateMutation = useUpdateAdminCourseMutation()
|
||||
|
||||
const onAskDelete = (course) => {
|
||||
@@ -226,7 +134,6 @@ const onChangeStatus = ({ id, isActive }) => {
|
||||
|
||||
const syncRouteTermId = (termId) => {
|
||||
if (!termId) return
|
||||
activeTab.value = 'offered'
|
||||
offeredFilters.value = { ...offeredFilters.value, termId }
|
||||
resetOfferedPagination()
|
||||
}
|
||||
|
||||
@@ -3,19 +3,26 @@ import { array, boolean, mixed, number, object, string } from 'yup'
|
||||
export const courseSchema = object().shape({
|
||||
title: string().required().min(3).max(255),
|
||||
teacherId: mixed().required(),
|
||||
capacity: number().required().min(1),
|
||||
prerequisites: array().nullable().default([]),
|
||||
contentType: string().oneOf(['video', 'voice', 'text']).required(),
|
||||
contentMediaId: number().nullable().notRequired(),
|
||||
capacity: number()
|
||||
.typeError('ظرفیت باید بهصورت عدد وارد شود')
|
||||
.required()
|
||||
.integer('ظرفیت باید عدد صحیح باشد')
|
||||
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
|
||||
prerequisiteCourseIds: array().nullable().default([]),
|
||||
coverMediaId: number().typeError('شناسه تصویر دوره باید عدد باشد').nullable().notRequired(),
|
||||
description: string().nullable().notRequired(),
|
||||
termId: mixed().nullable(),
|
||||
termId: mixed().required(),
|
||||
isActive: boolean().nullable().notRequired(),
|
||||
})
|
||||
|
||||
export const offeredCourseSchema = object().shape({
|
||||
termId: string().required(),
|
||||
title: string().required(),
|
||||
capacity: string().required(),
|
||||
capacity: number()
|
||||
.typeError('ظرفیت باید بهصورت عدد وارد شود')
|
||||
.required()
|
||||
.integer('ظرفیت باید عدد صحیح باشد')
|
||||
.min(1, 'ظرفیت باید حداقل ۱ نفر باشد'),
|
||||
imageId: string().nullable().notRequired(),
|
||||
isActive: boolean().nullable().notRequired(),
|
||||
})
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<div class="exam-item__sub">
|
||||
<span class="exam-item__sub-label">دوره:</span>
|
||||
<span class="exam-item__sub-value">
|
||||
{{ exam.course?.title || exam.courseTitle || '—' }}
|
||||
{{ exam?.session?.course?.title || '—' }}
|
||||
</span>
|
||||
<span class="exam-item__dot">|</span>
|
||||
<span class="exam-item__sub-label">جلسه:</span>
|
||||
<span class="exam-item__sub-value">
|
||||
{{ exam.session?.title || exam.sessionTitle || '—' }}
|
||||
{{ exam.session?.title || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,16 +51,6 @@
|
||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
tooltip="ویرایش"
|
||||
bg-color="rgba(104, 104, 104, 0.05)"
|
||||
size="2.5rem"
|
||||
@click="emit('edit', exam)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="pencil" :size="18" color="var(--color-sec-gray)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<BaseButton
|
||||
text="جزئیات آزمون"
|
||||
custom-class="exam-item__details-btn"
|
||||
|
||||
@@ -35,15 +35,15 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="exam-question-builder__col exam-question-builder__col--score">
|
||||
<label class="exam-question-builder__label">ترتیب</label>
|
||||
<label class="exam-question-builder__label">بارم نمره</label>
|
||||
<input
|
||||
:value="question.position"
|
||||
:value="question.score"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="1"
|
||||
class="exam-question-builder__input"
|
||||
@input="updateQuestion(question.id, { position: $event.target.value })"
|
||||
@input="updateQuestion(question.id, { score: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ const createOption = () => ({ id: createId('option'), optionText: '', isCorrect:
|
||||
const createQuestion = () => ({
|
||||
id: createId('question'),
|
||||
questionText: '',
|
||||
position: questions.value.length + 1,
|
||||
score: '',
|
||||
options: [createOption(), createOption()],
|
||||
__local: true,
|
||||
})
|
||||
@@ -306,6 +306,7 @@ const correctOptions = (question) =>
|
||||
font-size: 0.9rem;
|
||||
color: #4b4b4b;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate, var(--color-primary));
|
||||
|
||||