2 Commits
Author SHA1 Message Date
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +03:30
176 changed files with 39921 additions and 33750 deletions
+3 -11
View File
@@ -41,19 +41,11 @@ Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) rema
6. **24-column grid only**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale + StyleProvider**
App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
7. **RTL + locale**
App root is wrapped with `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">`. Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme).
9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
-7
View File
@@ -1,7 +0,0 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,13 +38,9 @@ Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
| RTL + locale + theme | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow
1. Confirm the **single** route/view the user named.
+15
View File
@@ -0,0 +1,15 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
+131
View File
@@ -0,0 +1,131 @@
name: Build & Push Docker Image
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Start time
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
- name: Free disk space
id: prune
run: |
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
docker builder prune -af || true
docker image prune -af || true
docker system prune -af --volumes || true
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
- name: Checkout code
id: checkout
uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="$(git rev-parse --short HEAD)"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
# Registry login is unsolved. These two steps stay off so a tag run
# only proves the three images build. Remove `if: false` on both when
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
if: false
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
USER_NAME="${REGISTRY_USERNAME:-admin}"
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
- name: Push Docker images
id: push
if: false
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
for name in montact-api montact-web montact-stt; do
docker push "$reg/$name:$tag"
docker push "$reg/$name:latest"
done
- name: Notify Bale
if: always()
continue-on-error: true
env:
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
TAG: ${{ steps.meta.outputs.tag }}
REG: ${{ steps.meta.outputs.registry }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
STEP_OUTCOMES: |
Checkout code=${{ steps.checkout.outcome }}
Resolve image tag=${{ steps.meta.outcome }}
Build Docker images=${{ steps.build.outcome }}
Login to registry=${{ steps.login.outcome }}
Push Docker images=${{ steps.push.outcome }}
run: |
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
exit 0
fi
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
if [ -n "$failed_step" ]; then
title="❌ بیلد ناموفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
title="✅ بیلد موفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
else
title="⚠️ بیلد کامل نشد"
fi
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
text="${title}
پروژه: ${GITHUB_REPOSITORY}
تگ: ${TAG:-${GITHUB_REF_NAME}}"
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
text="${text}
image: ${REG}/montact-api:${TAG}
image: ${REG}/montact-web:${TAG}
image: ${REG}/montact-stt:${TAG}"
fi
[ -n "$failed_step" ] && text="${text}
مرحلهٔ خطا: ${failed_step}"
text="${text}
کامیت: ${commit}
توسط: ${GITHUB_ACTOR}
مدت: $((elapsed / 60))m$((elapsed % 60))s
لاگ: ${RUN_URL}"
msg_file=$(mktemp)
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
--data-urlencode "text@${msg_file}"
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
</g>
<g>
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<g>
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+29
View File
@@ -0,0 +1,29 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
+15
View File
@@ -0,0 +1,15 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
+16
View File
@@ -0,0 +1,16 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
-65
View File
@@ -1,65 +0,0 @@
---
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
globs: apps/web/src/**/*.{vue,ts,tsx,css}
alwaysApply: true
---
# قرارداد حاکمیتی و غیرقابل‌مذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب می‌شود:
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
1. **ممنوعیت ویرایش دسته‌جمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرت‌نکرده:** فایل‌های داخل `apps/web/src/components/ui/Ui*.vue` و استایل‌های سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحه‌ای که کاربر دستور بازنویسی آن را می‌دهد، تمام ایمپورت‌های `@/components/ui` و کلاس‌های قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننت‌های رسمی `<a-...>` جایگزین گردند.
4. **کامپوننت‌های تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسی‌شده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آن‌ها را حفظ کن اما مطمئن شو ظاهر ورودی آن‌ها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً هم‌تراز با فیلدهای Ant Design نمایش داده می‌شود.
## ۲. لیست سیاه سینتکس و تگ‌های ممنوعه در صفحات بازنویسی‌شده (Banned Syntax)
در هر فایلی که بازنویسی یا ایجاد می‌کنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
- **تگ‌های خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
- **کلاس‌های لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
- **کلاس‌های Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاس‌های ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترل‌ها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
1. **ممنوعیت تکه‌تکه کردن فرم به کارت‌های متعدد (Anti-Card-Fragmentation):**
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
- برای تفکیک بخش‌های منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیاده‌سازی شود، نه با `div` و `span` دستی.
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
- تمام فرم‌ها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دسته‌بندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
- **فیلدهای تمام‌عرض** (توضیحات، یادداشت، چک‌لیست، برچسب‌ها): `<a-col :span="24">`.
- **سوییچ‌ها و چک‌باکس‌های هم‌خانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
4. **مدیریت Margin و Padding بدون CSS دستی:**
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل می‌شود.
- فاصلهٔ افقی/عمودی دکمه‌ها و المان‌ها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل می‌شود.
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننت‌های Ant Design Vue خطاست:
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
- **الزام عرض ۱۰۰٪ روی کنترل‌های خاص:** کامپوننت‌های `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
- **فیدبک و پیام‌ها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
## ۵. حاکمیت مطلق توکن‌ها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصله‌های دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترل‌ها، پدینگ و رنگ‌ها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف می‌شوند.
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
4. استفاده از `:deep(.ant-...)` برای دست‌کاری ظاهر کامپوننت‌های انت ممنوع است.
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دست‌نخورده مانده‌اند؟
- [ ] آیا تمام تگ‌های خام فرم، کامپوننت‌های `Ui*`، کلاس‌های `.btn`/`.field`/`.crm-*` و کلاس‌های ناموجود مثل `w-full` از این صفحه حذف شده‌اند؟
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمام‌عرض خارج شده‌اند؟
- [ ] آیا تمام بایندینگ‌ها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکت‌ها و شمارنده‌ها استفاده می‌کنند؟
- [ ] آیا `<style scoped>` کاملاً از رنگ‌های Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاک‌سازی شده است؟
+20 -264
View File
@@ -1533,7 +1533,6 @@ export class ReportsService {
async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals');
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const rates = await loadOrgRates(this.prisma, orgId);
@@ -1544,7 +1543,6 @@ export class ReportsService {
const prev = addJalaliMonths(jy, jm, -1);
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
const trendWindowStart = addMonthsStart(now, -12);
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
@@ -1557,25 +1555,17 @@ export class ReportsService {
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
'issued',
'partially_paid',
'paid',
];
const [
wonDeals,
openDealRows,
lostDeals,
openDeals,
staleDeals,
peopleRecent,
peopleCur,
peoplePrev,
openActivitiesToday,
openActivitiesYesterday,
openDealsWeekAgo,
goals,
overdueCount,
invoices,
activityTypeGroups,
] = await Promise.all([
this.prisma.deal.findMany({
where: {
@@ -1583,7 +1573,7 @@ export class ReportsService {
{
organizationId: orgId,
status: 'won',
updatedAt: { gte: trendWindowStart },
updatedAt: { gte: addMonthsStart(now, -5) },
},
dealScope,
dealsPrior,
@@ -1600,7 +1590,7 @@ export class ReportsService {
person: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.deal.findMany({
this.prisma.deal.count({
where: {
AND: [
{ organizationId: orgId, status: 'open' },
@@ -1608,30 +1598,6 @@ export class ReportsService {
dealsPrior,
],
},
select: {
amount: true,
currency: true,
stageId: true,
stage: { select: { id: true, name: true, position: true } },
},
}),
this.prisma.deal.findMany({
where: {
AND: [
{
organizationId: orgId,
status: 'lost',
updatedAt: { gte: trendWindowStart },
},
dealScope,
dealsPrior,
],
},
select: {
amount: true,
currency: true,
lostReason: true,
},
}),
this.prisma.deal.findMany({
where: {
@@ -1658,13 +1624,19 @@ export class ReportsService {
orderBy: { updatedAt: 'asc' },
take: 5,
}),
this.prisma.person.findMany({
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: trendWindowStart, lte: curMonthEnd },
createdAt: { gte: curMonthStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
},
select: { createdAt: true, importSource: true },
}),
this.prisma.activity.count({
where: {
@@ -1684,7 +1656,6 @@ export class ReportsService {
AND: [
{
organizationId: orgId,
status: 'open',
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
},
activityScope,
@@ -1726,67 +1697,8 @@ export class ReportsService {
],
},
}),
this.prisma.invoice.findMany({
where: {
organizationId: orgId,
...invoiceScope,
status: { in: invoiceStatuses },
OR: [
{ issuedAt: { gte: trendWindowStart, lte: curMonthEnd } },
{ createdAt: { gte: trendWindowStart, lte: curMonthEnd } },
],
},
select: {
issuedAt: true,
createdAt: true,
lineItems: {
select: {
quantity: true,
unitPrice: true,
currency: true,
},
},
},
}),
this.prisma.activity.groupBy({
by: ['type'],
where: {
AND: [
{
organizationId: orgId,
status: 'open',
},
activityScope,
activitiesPrior,
],
},
_count: { _all: true },
}),
]);
const openDeals = openDealRows.length;
const invoiceInRange = (
inv: { issuedAt: Date | null; createdAt: Date },
from: Date,
to: Date,
) => {
const anchor = inv.issuedAt ?? inv.createdAt;
return anchor >= from && anchor <= to;
};
const sumInvoiceInRange = (from: Date, to: Date) => {
let amount = 0;
for (const inv of invoices) {
if (!invoiceInRange(inv, from, to)) continue;
for (const line of inv.lineItems) {
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
amount += toBaseAmount(lineTotal, line.currency, rates);
}
}
return amount;
};
const sumWonInRange = (start: Date, end: Date) =>
wonDeals
.filter((d) => d.updatedAt >= start && d.updatedAt <= end)
@@ -1795,21 +1707,8 @@ export class ReportsService {
0,
);
const salesInRange = (start: Date, end: Date) => {
const inv = sumInvoiceInRange(start, end);
const won = sumWonInRange(start, end);
return Math.round(inv > 0 ? inv : won);
};
const peopleCur = peopleRecent.filter(
(p) => p.createdAt >= curMonthStart && p.createdAt <= curMonthEnd,
).length;
const peoplePrev = peopleRecent.filter(
(p) => p.createdAt >= prevMonthStart && p.createdAt <= prevMonthEnd,
).length;
const salesCur = salesInRange(curMonthStart, curMonthEnd);
const salesPrev = salesInRange(prevMonthStart, prevMonthEnd);
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
const targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount),
0,
@@ -1817,7 +1716,7 @@ export class ReportsService {
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
[];
for (let i = 11; i >= 0; i -= 1) {
for (let i = 4; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
@@ -1830,28 +1729,14 @@ export class ReportsService {
const salesTrend = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
amount: salesInRange(m.start, m.end),
}));
const customerGrowth = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
value: peopleRecent.filter(
(p) => p.createdAt >= m.start && p.createdAt <= m.end,
).length,
amount: Math.round(sumWonInRange(m.start, m.end)),
}));
const sourceMap = new Map<string, number>();
for (const p of peopleRecent) {
const label = normalizeImportSource(p.importSource);
for (const d of wonDeals) {
const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
if (sourceMap.size === 0) {
for (const d of wonDeals) {
const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
}
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources = [...sourceMap.entries()]
.map(([label, count]) => ({
@@ -1895,101 +1780,6 @@ export class ReportsService {
.slice(0, 5)
.map((row, index) => ({ ...row, rank: index + 1 }));
const wonAmountAll = Math.round(
wonDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const lostAmountAll = Math.round(
lostDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const openAmountAll = Math.round(
openDealRows.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const closedCount = wonDeals.length + lostDeals.length;
const winRate = closedCount
? Math.round((wonDeals.length / closedCount) * 1000) / 10
: 0;
const salesSummary: HomeDashboardDto['salesSummary'] = {
openCount: openDeals,
wonCount: wonDeals.length,
lostCount: lostDeals.length,
openAmount: openAmountAll,
wonAmount: wonAmountAll,
lostAmount: lostAmountAll,
winRate,
};
const funnelMap = new Map<
string,
{ stageId: string; stageName: string; position: number; count: number; amount: number }
>();
for (const d of openDealRows) {
const stageId = d.stage?.id ?? d.stageId ?? 'unknown';
const stageName = d.stage?.name ?? 'بدون مرحله';
const position = d.stage?.position ?? 999;
const cur = funnelMap.get(stageId) ?? {
stageId,
stageName,
position,
count: 0,
amount: 0,
};
cur.count += 1;
cur.amount += decimalToBase(d.amount, d.currency, rates);
funnelMap.set(stageId, cur);
}
const funnel = [...funnelMap.values()]
.sort((a, b) => a.position - b.position)
.slice(0, 8)
.map((row) => ({
stageId: row.stageId,
stageName: row.stageName,
count: row.count,
amount: Math.round(row.amount),
}));
const lostReasonMap = new Map<string, { count: number; amount: number }>();
for (const d of lostDeals) {
const label = normalizeLostReason(d.lostReason);
const prevRow = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
prevRow.count += 1;
prevRow.amount += decimalToBase(d.amount, d.currency, rates);
lostReasonMap.set(label, prevRow);
}
const lostReasonTotal =
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
const lostReasons = [...lostReasonMap.entries()]
.map(([label, row]) => ({
label,
count: row.count,
amount: Math.round(row.amount),
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const activityTypeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const activityByType = activityTypeGroups
.map((row) => ({
type: row.type,
label: activityTypeLabel[row.type] ?? row.type,
count: row._count._all,
}))
.sort((a, b) => b.count - a.count);
const alerts: HomeDashboardDto['alerts'] = [];
if (staleDeals.length) {
const first = staleDeals[0]!;
@@ -2067,30 +1857,11 @@ export class ReportsService {
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
changeLabel: 'نسبت به دیروز',
},
{
key: 'win-rate',
label: 'نرخ برد',
value: winRate,
changePercent: null,
changeLabel: '۱۲ ماه اخیر',
},
{
key: 'overdue',
label: 'عقب‌افتاده',
value: overdueCount,
changePercent: null,
changeLabel: 'فعالیت باز',
},
],
alerts,
salesTrend,
customerGrowth,
sources,
topCustomers,
salesSummary,
funnel,
lostReasons,
activityByType,
};
}
}
@@ -2148,21 +1919,6 @@ function normalizeSource(raw: string | null | undefined): string {
return map[value.toLowerCase()] ?? value;
}
function normalizeImportSource(raw: string | null | undefined): string {
const value = raw?.trim().toLowerCase();
if (!value) return 'سایر';
const map: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
didar: 'دیدار',
excel: 'اکسل',
phone: 'تلفن',
lead_form: 'فرم لید',
other: 'سایر',
};
return map[value] ?? normalizeSource(raw);
}
function normalizeLostReason(raw: string | null | undefined): string {
const value = raw?.trim();
if (!value) return 'بدون دلیل ثبت‌شده';
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>منتکت | Montact CRM</title>
<link rel="icon" type="image/svg+xml" href="/brand/logo.svg" />
<link rel="icon" type="image/png" href="/brand/logo.png" />
<style>
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */
.app-frame {
-2
View File
@@ -22,9 +22,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^5.2.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
</g>
<g>
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<g>
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+113 -51
View File
@@ -4,20 +4,8 @@ import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { ThunderboltOutlined } from '@/icons/ant-bridge';
const { Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{
personId?: string;
@@ -77,53 +65,127 @@ function insertAnswer() {
</script>
<template>
<a-flex vertical :gap="8" :style="{ minWidth: 0 }">
<a-flex :gap="8" align="center" :style="{ width: '100%' }">
<a-input
v-model:value="question"
allow-clear
<section class="ask">
<form @submit.prevent="submit">
<UiIcon name="sparkle" :size="16" class="ico" />
<input
v-model="question"
type="search"
:placeholder="placeholder || brand.askPlaceholderFa"
:style="{ flex: 1, minWidth: 0 }"
@pressEnter="submit"
>
<template #prefix>
<ThunderboltOutlined />
</template>
</a-input>
<a-button
type="primary"
:disabled="asking || question.trim().length < 2"
:loading="asking"
@click="submit"
>
بپرس
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card v-else-if="result" :bordered="false">
<a-typography-paragraph :style="{ marginBottom: 0 }">
{{ result.answer }}
</a-typography-paragraph>
<a-space v-if="result.sources.length" wrap :size="6" :style="{ marginTop: '8px' }">
/>
<button type="submit" :disabled="asking || question.trim().length < 2">
{{ asking ? '…' : 'بپرس' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-else-if="result" class="answer">
<p>{{ result.answer }}</p>
<div v-if="result.sources.length" class="sources">
<RouterLink
v-for="source in result.sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
>
<a-tag color="cyan" :bordered="false">{{ source.label }}</a-tag>
{{ source.label }}
</RouterLink>
</a-space>
<a-button
</div>
<button
v-if="allowInsert && result.answer.trim()"
type="primary"
block
:style="{ marginTop: '8px' }"
type="button"
class="insert-btn"
@click="insertAnswer"
>
درج در پیام
</a-button>
</a-card>
</a-flex>
</button>
</div>
</section>
</template>
<style scoped>
.ask {
display: grid;
gap: 0.55rem;
min-width: 0;
}
form {
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
.ico {
color: #0f766e;
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.45rem 0;
}
button {
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
padding: 0.45rem 0.8rem;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
}
.answer p {
margin: 0;
color: #134e4a;
font-size: 0.88rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.55rem;
}
.sources a {
font-size: 0.74rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.18rem 0.55rem;
}
.insert-btn {
margin-top: 0.65rem;
width: 100%;
background: #0f766e;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>
+54 -56
View File
@@ -8,11 +8,11 @@ import {
InputNumber as AInputNumber,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
@@ -73,63 +73,61 @@ onMounted(load);
</script>
<template>
<CrmSkeleton :loading="loading">
<CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
<template v-else-if="fields.length">
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
فیلدهای سفارشی
</a-typography-title>
<a-row :gutter="[16, 0]">
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
<a-form-item :label="field.label" :required="field.required">
<a-select
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
allow-clear
:value="String(displayValue(field.key)) || undefined"
:options="selectOptions(field)"
placeholder="انتخاب کنید"
style="width: 100%"
@update:value="setValue(field.key, $event ?? '')"
/>
<a-spin v-if="loading" />
<template v-else-if="fields.length">
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
فیلدهای سفارشی
</a-typography-title>
<a-row :gutter="[16, 0]">
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
<a-form-item :label="field.label" :required="field.required">
<a-select
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
allow-clear
:value="String(displayValue(field.key)) || undefined"
:options="selectOptions(field)"
placeholder="انتخاب کنید"
style="width: 100%"
@update:value="setValue(field.key, $event ?? '')"
/>
<a-checkbox
v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`"
:checked="Boolean(props.modelValue[field.key])"
@update:checked="setValue(field.key, $event)"
>
فعال
</a-checkbox>
<a-checkbox
v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`"
:checked="Boolean(props.modelValue[field.key])"
@update:checked="setValue(field.key, $event)"
>
فعال
</a-checkbox>
<a-input-number
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
:value="
props.modelValue[field.key] != null
? Number(props.modelValue[field.key])
: undefined
"
style="width: 100%"
@update:value="setValue(field.key, $event == null ? null : Number($event))"
/>
<a-input-number
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
:value="
props.modelValue[field.key] != null
? Number(props.modelValue[field.key])
: undefined
"
style="width: 100%"
@update:value="setValue(field.key, $event == null ? null : Number($event))"
/>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<a-input
v-else
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</CrmSkeleton>
<a-input
v-else
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</template>
+104 -68
View File
@@ -1,17 +1,6 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
FormItem as AFormItem,
Radio as ARadio,
RadioGroup as ARadioGroup,
Select as ASelect,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import { Select as ASelect } from 'ant-design-vue';
import type {
DataAccessOwnerKind,
DataAccessRule,
@@ -26,12 +15,9 @@ import {
PERSON_MOODS,
sectionHasDataAccessLimits,
} from '@mento/shared';
export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string };
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
@@ -443,59 +429,53 @@ const filterSummary = computed(() => {
</script>
<template>
<a-flex vertical :gap="compact ? 8 : 12">
<a-radio-group v-model:value="mode" :disabled="disabled">
<a-flex vertical :gap="4">
<a-radio value="all">همهٔ {{ sectionLabel }}</a-radio>
<a-radio value="rules">محدود با فیلتر</a-radio>
</a-flex>
</a-radio-group>
<div class="dae" :class="{ compact }">
<div class="dae-mode">
<label>
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
همهٔ {{ sectionLabel }}
</label>
<label>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'">
<a-typography-text type="secondary">
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</a-typography-text>
<a-alert
v-if="hadMultipleRules"
type="warning"
show-icon
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود."
/>
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
</p>
<p class="summary">{{ filterSummary }}</p>
<a-card size="small" title="مالک / مسئول" :bordered="true">
<a-flex vertical :gap="8">
<a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text>
<a-space wrap :size="8">
<a-checkbox
:checked="filter.owners?.includes('self')"
:disabled="disabled"
@change="toggleOwner('self')"
>
{{ ownerSelfLabel }}
</a-checkbox>
<a-checkbox
:checked="filter.owners?.includes('team')"
:disabled="disabled"
@change="toggleOwner('team')"
>
تیم‌های عضو
</a-checkbox>
</a-space>
</a-flex>
</a-card>
<fieldset :disabled="disabled">
<legend>مالک / مسئول</legend>
<p class="muted">خالی = بدون محدودیت مالک</p>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('self')"
@change="toggleOwner('self')"
/>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('team')"
@change="toggleOwner('team')"
/>
تیم‌های عضو
</label>
</fieldset>
<a-form-item
v-for="picker in pickers"
:key="picker.legend"
:label="picker.legend"
:style="{ marginBottom: 0 }"
>
<a-typography-text v-if="picker.loading" type="secondary">
در حال بارگذاری منبع‌ها…
</a-typography-text>
<label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span>
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
<a-select
v-else
:value="pickerValue(picker)"
@@ -510,14 +490,70 @@ const filterSummary = computed(() => {
style="width: 100%"
@update:value="setList(picker.key, $event)"
/>
<a-typography-text v-if="picker.hint" type="secondary">
{{ picker.hint }}
</a-typography-text>
</a-form-item>
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
</label>
</template>
<a-typography-text v-else-if="!hasLimits" type="secondary">
<p v-else-if="!hasLimits" class="hint">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</a-typography-text>
</a-flex>
</p>
</div>
</template>
<style scoped>
.dae {
display: grid;
gap: 0.75rem;
}
.dae-mode {
display: grid;
gap: 0.35rem;
}
.dae-mode label,
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
margin-inline-end: 0.55rem;
margin-bottom: 0.25rem;
}
.hint,
.muted,
.summary {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.summary {
font-weight: 600;
color: #334155;
}
fieldset {
border: 1px dashed #e2e8f0;
border-radius: 8px;
padding: 0.5rem 0.65rem;
margin: 0;
}
legend {
padding-inline: 0.25rem;
font-size: 0.8rem;
color: #475569;
font-weight: 600;
}
.pick {
display: grid;
gap: 0.35rem;
}
.pick > span {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
.compact fieldset {
padding: 0.45rem 0.55rem;
}
</style>
+177 -103
View File
@@ -1,15 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import {
Tabs as ATabs,
TabPane as ATabPane,
Typography,
Radio as ARadio,
RadioGroup as ARadioGroup,
Checkbox as ACheckbox,
Space as ASpace,
Input as AInput,
} from 'ant-design-vue';
import type {
DataAccessPolicy,
DataAccessSection,
@@ -29,8 +19,6 @@ import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{
modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
@@ -127,10 +115,6 @@ function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next);
}
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
}
function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key)
@@ -245,105 +229,195 @@ onMounted(() => {
void loadOptions();
void loadTreasuryAccounts();
});
const editorSection = computed<DataAccessSection>(() =>
active.value === 'treasury' ? 'customers' : active.value,
);
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
if (active.value === 'activities') return activityStages.value;
if (active.value === 'projects') return projectStages.value;
return [];
});
const boardsForActive = computed(() => {
if (active.value === 'activities') return activityBoards.value;
if (active.value === 'projects') return projectBoards.value;
return [];
});
const typesForActive = computed(() => {
if (active.value === 'activities') return activityTypes.value;
if (active.value === 'routines') return routineCategories.value;
return [];
});
</script>
<template>
<div>
<a-typography-text v-if="!visibleSections.length" type="secondary">
<div class="dap">
<p v-if="!visibleSections.length" class="muted">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</a-typography-text>
<a-tabs v-else v-model:activeKey="active">
<a-tab-pane
v-for="s in visibleSections"
:key="s"
:tab="DATA_ACCESS_SECTION_LABELS[s]"
>
<DataAccessEditor
:section="s"
:model-value="sectionValue(s)"
:groups="customerGroups"
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
:pipelines="pipelines"
:boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
:activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(s, $event)"
/>
</a-tab-pane>
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
<a-space direction="vertical">
<a-typography-text type="secondary">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</a-typography-text>
<a-radio-group
:value="treasuryAccess == null ? 'role' : 'custom'"
</p>
<template v-else>
<div class="tabs" role="tablist">
<button
v-for="s in visibleSections"
:key="s"
type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
<button
v-if="showTreasury"
type="button"
class="tab"
:class="{ on: active === 'treasury' }"
role="tab"
:aria-selected="active === 'treasury'"
@click="active = 'treasury'"
>
مدیریت مالی
</button>
</div>
<div v-if="active === 'treasury'" class="treasury">
<p class="muted">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</p>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess == null"
:disabled="disabled"
@change="onTreasuryModeChange"
>
<a-space direction="vertical">
<a-radio value="role">مثل نقش</a-radio>
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
</a-space>
</a-radio-group>
<template v-if="treasuryAccess">
<a-checkbox
@change="patchTreasury(null)"
/>
مثل نقش
</label>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess != null"
:disabled="disabled"
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
/>
سفارشی برای این کاربر
</label>
<template v-if="treasuryAccess">
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
>
دسترسی به مدیریت مالی
</a-checkbox>
<template v-if="treasuryAccess.enabled">
<a-space direction="vertical">
<a-checkbox
v-for="perm in treasurySections"
:key="perm.key"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
>
{{ perm.label }}
</a-checkbox>
</a-space>
<a-checkbox
/>
دسترسی به مدیریت مالی
</label>
<template v-if="treasuryAccess.enabled">
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
<input
type="checkbox"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
/>
{{ perm.label }}
</label>
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
>
حساب‌هایی که خودش مسئول آن‌هاست
</a-checkbox>
<a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<a-input
v-model:value="treasuryAccountQuery"
placeholder="جستجوی حساب"
:disabled="disabled"
allow-clear
/>
<a-space direction="vertical">
<a-checkbox
v-for="account in filteredTreasuryAccounts"
:key="account.id"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</a-checkbox>
</a-space>
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
حسابی برای انتخاب نیست.
</a-typography-text>
</template>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="muted">حساب‌های دیگر</p>
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
<input
type="checkbox"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
</template>
</a-space>
</a-tab-pane>
</a-tabs>
</template>
</div>
<DataAccessEditor
v-else
:key="active"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(editorSection, $event)"
/>
</template>
</div>
</template>
<style scoped>
.dap {
display: grid;
gap: 0.75rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tab {
font: inherit;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: #475569;
}
.tab.on {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
.treasury {
display: grid;
gap: 0.45rem;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem;
align-items: center;
}
.choice.sub {
padding-inline-start: 1.2rem;
}
</style>
+3 -5
View File
@@ -10,11 +10,11 @@ import {
Input as AInput,
List as AList,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item;
@@ -259,8 +259,7 @@ onMounted(load);
:style="{ marginTop: '12px' }"
/>
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="5">
<a-spin :spinning="loading">
<a-list
v-if="items.length"
item-layout="horizontal"
@@ -305,7 +304,6 @@ onMounted(load);
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }"
/>
</CrmSkeletonList>
</CrmSkeleton>
</a-spin>
</a-card>
</template>
@@ -1,568 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ExcelFieldOption,
ExcelImportPrepareStatus,
ExcelPreviewResult,
ImportContactsResult,
} from '@mento/shared';
import {
previewExcelClient,
readMappedRowsClient,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
previewPath: string;
importPath: string;
/** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */
preparePath?: string;
/**
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده می‌شود و فقط JSON کوچک به API می‌رود.
* برای فایل‌های ده‌ها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
*/
rowsImportPath?: string;
clientAliases?: Record<string, string>;
clientFields?: ExcelFieldOption[];
/** تشخیص خروجی بدون‌هدر (مثل فعالیت دیدار) */
clientReadOptions?: ClientExcelReadOptions;
disabled?: boolean;
}>();
const useClientImport = computed(
() =>
Boolean(
props.rowsImportPath &&
props.clientAliases &&
props.clientFields?.length,
),
);
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
fail: [message: string];
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
const submitting = ref(false);
const phase = ref<'idle' | 'preview' | 'import'>('idle');
const progressLabel = ref('');
/** درصد ۰–۱۰۰؛ در مرحلهٔ خواندن فایل null */
const progressPct = ref<number | null>(null);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
const busyMessage = computed(() => {
if (phase.value === 'import') {
return progressLabel.value || 'در حال ورود اطلاعات از اکسل…';
}
return 'در حال خواندن و بررسی فایل اکسل…';
});
const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
phase.value = 'idle';
progressLabel.value = '';
progressPct.value = null;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
if (submitting.value) return;
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
function mergeImportResults(
acc: ImportContactsResult,
part: ImportContactsResult,
): ImportContactsResult {
const errors = [...acc.errors, ...(part.errors ?? [])];
return {
created: acc.created + (part.created ?? 0),
skipped: acc.skipped + (part.skipped ?? 0),
errors: errors.slice(0, 200),
boardId: part.boardId ?? acc.boardId,
boards: part.boards?.length ? part.boards : acc.boards,
sessionId: part.sessionId,
total: part.total ?? acc.total,
processed: part.processed ?? acc.processed,
hasMore: part.hasMore,
nextOffset: part.nextOffset,
};
}
async function postImportChunk(opts: {
file?: File;
map?: Record<string, string>;
sessionId?: string;
offset?: number;
}) {
const form = new FormData();
if (opts.file) form.append('file', opts.file);
if (opts.map && Object.values(opts.map).some(Boolean)) {
form.append('mapping', JSON.stringify(opts.map));
}
if (opts.sessionId) form.append('sessionId', opts.sessionId);
if (opts.offset != null) form.append('offset', String(opts.offset));
form.append('limit', '250');
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
// خواندن فایل‌های خیلی بزرگ در مرحلهٔ اول ممکن است طول بکشد
timeout: opts.file ? 10 * 60 * 1000 : 3 * 60 * 1000,
});
return data;
}
function updateImportProgress(processed: number, total: number) {
if (total <= 0) {
progressPct.value = null;
progressLabel.value = 'در حال ورود اطلاعات از اکسل…';
return;
}
const pct = Math.max(0, Math.min(100, Math.round((processed / total) * 100)));
progressPct.value = pct;
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function postPrepare(file: File, map?: Record<string, string>) {
if (!props.preparePath) {
throw new Error('preparePath تنظیم نشده');
}
const form = new FormData();
form.append('file', file);
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
// فقط آپلود — پارس روی سرور ناهمگام است؛ تایم‌اوت کوتاه کافی است
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
headers: { 'Content-Type': undefined },
timeout: 3 * 60 * 1000,
});
return data;
}
async function waitPrepareReady(sessionId: string) {
if (!props.preparePath) throw new Error('preparePath تنظیم نشده');
const statusUrl = `${props.preparePath}/${sessionId}`;
const started = Date.now();
const maxMs = 15 * 60 * 1000;
while (Date.now() - started < maxMs) {
const { data } = await api.get<ExcelImportPrepareStatus>(statusUrl, {
timeout: 30_000,
});
if (data.status === 'ready') {
progressLabel.value = `فایل آماده شد — ${data.total.toLocaleString('fa-IR')} ردیف`;
return data;
}
if (data.status === 'error') {
throw new Error(data.error || 'آماده‌سازی فایل اکسل ناموفق بود');
}
const parsed = data.parsed || 0;
progressPct.value = null;
progressLabel.value = parsed
? `در حال خواندن فایل روی سرور… ${parsed.toLocaleString('fa-IR')} ردیف`
: 'در حال خواندن فایل روی سرور… لطفاً صبر کنید';
await sleep(1200);
}
throw new Error('آماده‌سازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
}
async function runClientRowsImport(file: File, map?: Record<string, string>) {
if (!props.rowsImportPath || !props.clientAliases) {
throw new Error('پیکربندی ایمپورت مرورگر ناقص است');
}
phase.value = 'import';
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
progressPct.value = 0;
const rows = await readMappedRowsClient(
file,
props.clientAliases,
map,
props.clientReadOptions,
);
const total = rows.length;
if (total > 100_000) {
return {
created: 0,
skipped: 0,
errors: [{ row: 1, message: 'فایل بیش از ۱۰۰۰۰۰ ردیف دارد. فایل را تقسیم کنید.' }],
total,
processed: 0,
hasMore: false,
};
}
updateImportProgress(0, total);
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
const chunkSize = 60;
for (let offset = 0; offset < total; offset += chunkSize) {
const slice = rows.slice(offset, offset + chunkSize);
const { data: part } = await api.post<ImportContactsResult>(
props.rowsImportPath,
{
rows: slice,
offset,
total,
sessionId,
},
{ timeout: 120_000 },
);
acc = mergeImportResults(acc, part);
updateImportProgress(part.processed ?? offset + slice.length, total);
if (part.errors?.some((e) => e.row === 1 && /حداکثر|بیش از/.test(e.message))) {
break;
}
sessionId = part.sessionId;
if (!part.hasMore) break;
}
updateImportProgress(total, total);
return acc;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
if (useClientImport.value) {
return runClientRowsImport(file, map);
}
phase.value = 'import';
progressLabel.value = 'در حال ارسال فایل…';
progressPct.value = 0;
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
let offset = 0;
let guard = 0;
const maxChunks = 2000;
if (props.preparePath) {
const prep = await postPrepare(file, map);
if (!prep.sessionId) {
throw new Error(prep.errors[0]?.message || 'آماده‌سازی فایل ناموفق بود');
}
sessionId = prep.sessionId;
progressLabel.value = 'در حال خواندن فایل روی سرور…';
const ready = await waitPrepareReady(sessionId);
if (!ready.total) {
return {
...acc,
total: 0,
processed: 0,
hasMore: false,
errors: ready.error
? [{ row: 1, message: ready.error }]
: [{ row: 1, message: 'هیچ ردیف داده‌ای در فایل نیست' }],
};
}
updateImportProgress(0, ready.total);
}
while (guard < maxChunks) {
guard += 1;
const part = await postImportChunk({
file: sessionId ? undefined : file,
map: sessionId ? undefined : map,
sessionId,
offset: sessionId ? offset : undefined,
});
acc = mergeImportResults(acc, part);
const total = part.total ?? 0;
const processed = part.processed ?? 0;
updateImportProgress(processed, total);
if (
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
) {
break;
}
if (!part.hasMore || !part.sessionId) {
if (total > 0) updateImportProgress(total, total);
break;
}
sessionId = part.sessionId;
offset = part.nextOffset ?? processed;
}
if (guard >= maxChunks && acc.hasMore) {
acc.errors = [
...acc.errors,
{
row: 1,
message: 'ورود به‌خاطر تعداد مراحل زیاد متوقف شد. از جایی که مانده ادامه دهید یا فایل را تقسیم کنید.',
},
];
}
return acc;
}
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) {
return e instanceof Error && e.message ? e.message : fallback;
}
if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message)) {
return 'مهلت درخواست تمام شد — فایل خیلی بزرگ است یا سرور دیر جواب داد. دوباره تلاش کنید.';
}
if (!e.response) {
return 'ارتباط با سرور برقرار نشد. اتصال را بررسی کنید و دوباره تلاش کنید.';
}
const status = e.response.status;
const data = e.response.data as { message?: string | string[] } | string | undefined;
let msg: string | undefined;
if (typeof data === 'string' && data.trim() && !data.trim().startsWith('<')) {
msg = data.trim().slice(0, 280);
} else if (data && typeof data === 'object') {
const m = data.message;
msg = Array.isArray(m) ? m.join('، ') : m;
}
if (msg) return msg;
if (status === 413) return 'حجم فایل بیش از حد مجاز سرور است.';
if (status === 502 || status === 504) {
return 'سرور میانی قطع شد (معمولاً به‌خاطر حجم/زمان فایل). چند دقیقه بعد دوباره تلاش کنید.';
}
return `${fallback} (کد ${status})`;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
try {
let data: ExcelPreviewResult;
if (useClientImport.value && props.clientAliases && props.clientFields) {
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
data = await previewExcelClient(
file,
props.clientAliases,
props.clientFields,
props.clientReadOptions,
);
} else {
const form = new FormData();
form.append('file', file);
const res = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
data = res.data;
}
if (!data.needsMapping) {
try {
emit('done', await runChunkedImport(file));
reset();
emit('settled');
} catch (impErr) {
emit('fail', apiMessage(impErr, 'ایمپورت ناموفق بود'));
reset();
emit('settled');
}
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'import';
try {
emit('done', await runChunkedImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
phase.value = 'idle';
submitting.value = false;
progressLabel.value = '';
progressPct.value = null;
emit('settled');
}
}
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<slot :loading="submitting" :disabled="disabled || submitting" />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
@change="onFile"
@click.stop
/>
</span>
<UiBusyOverlay
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
:disabled="submitting"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
ورود فایل
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.excel-import.isBusy {
pointer-events: none;
opacity: 0.7;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.large-hint {
margin: -0.35rem 0 1rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
color: var(--fg, inherit);
font-size: 0.88rem;
line-height: 1.6;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
@@ -8,7 +8,7 @@ import {
Space as ASpace,
Typography,
} from 'ant-design-vue';
import { PrinterOutlined } from '@/icons/ant-bridge';
import { PrinterOutlined } from '@ant-design/icons-vue';
import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money';
+109 -60
View File
@@ -6,7 +6,7 @@ import {
ClockCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@/icons/ant-bridge';
} from '@ant-design/icons-vue';
import {
addJalaliMonths,
dateToJalali,
@@ -52,6 +52,8 @@ const hourColRef = ref<HTMLElement | null>(null);
const minuteColRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
/** بالای مودال Ant (حدود ۱۰۰۰) تا تقویم پشت پنجره نماند */
const popupZ = ref(120);
const todayJ = dateToJalali(new Date());
const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm });
const draftHour = ref(9);
@@ -318,21 +320,46 @@ function place() {
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 320);
const height = panel?.offsetHeight || (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 360);
const width =
panel?.offsetWidth ||
(props.mode === 'time' ? (isMobile.value ? 340 : 280) : 328);
const height =
panel?.offsetHeight ||
(props.mode === 'time' ? (isMobile.value ? 460 : 320) : 340);
const margin = 8;
let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width;
}
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
nextLeft = Math.min(
Math.max(margin, nextLeft),
Math.max(margin, window.innerWidth - width - margin),
);
const spaceBelow = window.innerHeight - rect.bottom - margin * 2;
const spaceAbove = rect.top - margin * 2;
let nextTop: number;
if (height <= spaceBelow) {
nextTop = rect.bottom + margin;
} else if (height <= spaceAbove) {
nextTop = rect.top - height - margin;
} else if (spaceBelow >= spaceAbove) {
nextTop = Math.max(margin, window.innerHeight - height - margin);
} else {
nextTop = margin;
}
left.value = nextLeft;
top.value = nextTop;
}
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
popupZ.value = z;
}
function onWindowChange() {
syncViewport();
if (open.value) place();
@@ -344,6 +371,7 @@ async function show() {
calView.value = 'days';
clockFace.value = 'hour';
syncViewport();
liftAboveOverlays();
open.value = true;
await nextTick();
place();
@@ -538,7 +566,7 @@ onBeforeUnmount(() => {
'is-desktop': !isMobile,
}"
role="dialog"
:style="{ top: `${top}px`, left: `${left}px` }"
:style="{ top: `${top}px`, left: `${left}px`, zIndex: popupZ }"
>
<header v-if="mode !== 'time'" class="nav">
<a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)">
@@ -726,7 +754,7 @@ onBeforeUnmount(() => {
>
پاک کردن
</a-button>
<a-button type="primary" class="done" @click="hide">تأیید</a-button>
<a-button type="primary" @click="hide">تأیید</a-button>
</footer>
</div>
</Teleport>
@@ -851,10 +879,11 @@ onBeforeUnmount(() => {
.panel {
position: fixed;
z-index: 120;
box-sizing: border-box;
width: min(20.5rem, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow-x: hidden;
overflow-y: auto;
/* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */
max-height: none;
overflow: hidden;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
@@ -862,6 +891,13 @@ onBeforeUnmount(() => {
font-family: IRANSans, Tahoma, sans-serif;
}
@media (max-height: 420px) {
.panel {
max-height: calc(100vh - 16px);
overflow: auto;
}
}
.panel-time {
width: min(22.5rem, calc(100vw - 16px));
}
@@ -893,45 +929,40 @@ onBeforeUnmount(() => {
.pick-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem;
padding: 0.25rem 0;
}
.panel :deep(.ant-btn.pick-cell) {
width: 100%;
min-width: 0;
height: 44px;
margin: 0;
padding: 0 4px;
.panel :deep(.pick-cell.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 44px !important;
padding-inline: 0 !important;
border-radius: var(--radius);
color: #0f172a;
font-size: 0.85rem;
}
.panel :deep(.ant-btn.pick-cell.today) {
.panel :deep(.pick-cell.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.ant-btn.pick-cell.on),
.panel :deep(.ant-btn.pick-cell:hover) {
.panel :deep(.pick-cell.on),
.panel :deep(.pick-cell.ant-btn:hover) {
background: #0d9488;
color: #fff;
}
.panel :deep(.ant-btn.nav-btn) {
.nav-btn {
width: 32px;
min-width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f766e;
display: inline-flex;
align-items: center;
justify-content: center;
display: grid;
place-items: center;
cursor: pointer;
}
@@ -944,6 +975,7 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.15rem;
width: 100%;
min-width: 0;
}
@@ -954,40 +986,43 @@ onBeforeUnmount(() => {
padding-bottom: 0.25rem;
}
.panel :deep(.ant-btn.day) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 0;
height: 34px;
margin: 0;
padding: 0;
line-height: 1;
.panel :deep(.day.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 34px !important;
padding: 0 !important;
border: 0;
border-radius: var(--radius);
background: none;
color: #0f172a;
font: inherit;
font-size: 0.82rem;
line-height: 1;
cursor: pointer;
box-shadow: none;
}
.panel :deep(.ant-btn.day.mute) {
.panel :deep(.day.ant-btn > span) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.panel :deep(.day.mute) {
color: #cbd5e1;
}
.panel :deep(.ant-btn.day.today) {
.panel :deep(.day.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.ant-btn.day.on),
.panel :deep(.ant-btn.day:hover) {
.panel :deep(.day.on),
.panel :deep(.day.ant-btn:hover) {
background: #0d9488;
color: #fff;
}
.panel :deep(.ant-btn.day.mute:hover) {
.panel :deep(.day.mute:hover) {
background: #f1f5f9;
color: #64748b;
}
@@ -1017,11 +1052,9 @@ onBeforeUnmount(() => {
margin-bottom: 0.85rem;
}
.panel :deep(.ant-btn.md-chip) {
.md-chip {
min-width: 5.1rem;
height: 4.1rem;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
@@ -1034,7 +1067,7 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.panel :deep(.ant-btn.md-chip.on) {
.md-chip.on {
background: #ccfbf1;
color: #0f766e;
box-shadow: inset 0 0 0 2px #99f6e4;
@@ -1108,11 +1141,10 @@ onBeforeUnmount(() => {
pointer-events: none;
}
.panel :deep(.ant-btn.md-tick) {
.md-tick {
position: absolute;
z-index: 2;
width: 36px;
min-width: 36px;
height: 36px;
margin: 0;
border: 0;
@@ -1129,12 +1161,12 @@ onBeforeUnmount(() => {
padding: 0;
}
.panel :deep(.ant-btn.md-tick.inner) {
.md-tick.inner {
font-size: 0.74rem;
color: #64748b;
}
.panel :deep(.ant-btn.md-tick.on) {
.md-tick.on {
background: transparent;
color: #fff;
}
@@ -1183,12 +1215,9 @@ onBeforeUnmount(() => {
overscroll-behavior: contain;
}
.panel :deep(.ant-btn.col-item) {
.col-item {
width: 100%;
min-width: 0;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: transparent;
@@ -1199,11 +1228,11 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.panel :deep(.ant-btn.col-item:hover) {
.col-item:hover {
background: #e2e8f0;
}
.panel :deep(.ant-btn.col-item.on) {
.col-item.on {
background: #0d9488;
color: #fff;
}
@@ -1215,8 +1244,28 @@ onBeforeUnmount(() => {
margin-top: 0.7rem;
}
.panel :deep(.ant-btn.done) {
.link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: var(--radius);
padding: 0.4rem 0.8rem;
font-weight: 600;
font-size: 0.8rem;
}
.panel.is-mobile .time-box {
+3 -5
View File
@@ -9,10 +9,10 @@ import {
List as AList,
Modal,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
@@ -163,8 +163,7 @@ onMounted(load);
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="4">
<a-spin :spinning="loading">
<a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده.
</a-typography-text>
@@ -202,7 +201,6 @@ onMounted(load);
</a-list-item>
</template>
</a-list>
</CrmSkeletonList>
</CrmSkeleton>
</a-spin>
</a-card>
</template>
+166 -95
View File
@@ -5,20 +5,6 @@ import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } =
Typography;
const props = defineProps<{
noteId: string;
@@ -162,108 +148,193 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
</script>
<template>
<a-card :bordered="true" :style="{ marginTop: '12px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text>
<a-button
<section class="ai-panel">
<header>
<strong>پیشنهاد {{ brand.aiName }}</strong>
<button
v-if="!isPending"
type="link"
type="button"
class="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? '…' : 'تحلیل مجدد' }}
</a-button>
<a-button
</button>
<button
v-else-if="!suggestion"
type="link"
type="button"
class="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</a-button>
</a-flex>
</button>
</header>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" />
<a-typography-text v-else-if="loading && !suggestion" type="secondary">
در حال تحلیل یادداشت…
</a-typography-text>
<a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4">
<a-alert
type="error"
show-icon
:message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
/>
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
</a-flex>
<a-button
v-else-if="!suggestion"
type="link"
:disabled="loading"
@click="analyze"
>
تحلیل این یادداشت با AI
</a-button>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
<p v-else-if="suggestion?.status === 'failed'" class="error">
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
<button type="button" class="link" @click="analyze">تلاش دوباره</button>
</p>
<p v-else-if="!suggestion" class="muted">
<button type="button" class="link" :disabled="loading" @click="analyze">
تحلیل این یادداشت با AI
</button>
</p>
<template v-else-if="isPending && payload">
<a-typography-paragraph
v-if="formatPayloadSummary(payload)"
type="secondary"
:style="{ marginTop: '8px' }"
>
{{ formatPayloadSummary(payload) }}
</a-typography-paragraph>
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
<a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }">
<a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox>
<a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" />
</a-flex>
<div v-if="payload.nextAction" class="block">
<label class="check">
<input v-model="applyNextAction" type="checkbox" />
کار بعدی
</label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div>
<a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">
حافظه مشتری (با تأیید شما ذخیره می‌شود)
</a-typography-title>
<a-alert
v-if="!hasPerson"
type="warning"
show-icon
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
/>
<a-flex
<div v-if="memoryItems.length" class="block">
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p>
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
<label
v-for="(_, index) in memoryItems"
:key="index"
:gap="8"
align="flex-start"
class="memory-row"
>
<a-checkbox
v-model:checked="selectedMemory[index]"
<input
v-model="selectedMemory[index]"
type="checkbox"
:disabled="!hasPerson"
/>
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
</a-flex>
</a-flex>
<textarea v-model="memoryEdits[index]" rows="2" />
</label>
</div>
<a-space :size="8" wrap :style="{ marginTop: '12px' }">
<a-button type="primary" :disabled="applying" :loading="applying" @click="apply">
اعمال انتخاب‌شده
</a-button>
<a-button :disabled="applying" @click="dismiss">رد کردن</a-button>
</a-space>
<div class="actions">
<button type="button" class="btn" :disabled="applying" @click="apply">
{{ applying ? '…' : 'اعمال انتخاب‌شده' }}
</button>
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
رد کردن
</button>
</div>
</template>
<a-typography-text
v-else-if="suggestion?.status === 'applied'"
type="success"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد اعمال شد.
</a-typography-text>
<a-typography-text
v-else-if="suggestion?.status === 'dismissed'"
type="secondary"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد رد شد.
</a-typography-text>
</a-card>
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
</section>
</template>
<style scoped>
.ai-panel {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
min-width: 0;
overflow-wrap: anywhere;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.link {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.block {
display: grid;
gap: 0.45rem;
}
.block-title {
margin: 0;
font-weight: 600;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
.memory-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem;
align-items: start;
}
input[type='text'],
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.actions .btn {
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 520px) {
.actions {
flex-direction: column;
}
.actions .btn,
.actions .btn-ghost {
width: 100%;
}
}
.summary {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.muted {
margin: 0;
color: var(--muted);
}
.ok {
color: #067647;
}
.warn {
margin: 0;
color: #b54708;
font-size: 0.88rem;
}
.error {
margin: 0;
color: #b42318;
}
</style>
+138 -295
View File
@@ -3,59 +3,35 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles';
import type { OrganizationMembershipDto } from '@mento/shared';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
const props = withDefaults(
defineProps<{
compact?: boolean;
variant?: 'default' | 'workspace';
}>(),
{
compact: false,
variant: 'default',
},
);
const emit = defineEmits<{
open: [];
close: [];
defineProps<{
compact?: boolean;
}>();
const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false);
const createOpen = ref(false);
const loading = ref(false);
const creating = ref(false);
const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0;
const currentName = computed(
() =>
auth.user?.organizationName ||
orgs.value.find((o) => o.id === auth.user?.organizationId)?.name ||
'محیط کاری',
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
);
const currentSubtitle = computed(() => {
const current = orgs.value.find((o) => o.id === auth.user?.organizationId);
if (current?.orgRoleName) return current.orgRoleName;
if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role];
return 'محیط کاری';
});
const currentInitial = computed(() => {
const name = currentName.value.trim();
return name ? name.charAt(0) : 'م';
});
const canSwitch = computed(() => orgs.value.length > 1);
const isWorkspace = computed(() => props.variant === 'workspace');
/** در حالت workspace منو همیشه باز می‌شود؛ در حالت پیش‌فرض فقط وقتی بیش از یکی باشد */
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
@@ -73,62 +49,34 @@ async function loadOrgs() {
}
}
function closeMenu() {
if (!open.value) return;
open.value = false;
emit('close');
}
async function toggle() {
if (!menuEnabled.value) return;
if (!canSwitch.value) return;
error.value = '';
if (open.value) {
closeMenu();
open.value = false;
return;
}
ignoreDocClickUntil = Date.now() + 400;
open.value = true;
emit('open');
await nextTick();
placeMenu();
await loadOrgs();
await nextTick();
placeMenu();
}
function placeMenu() {
const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect();
const minWidth = Math.round(Math.max(box.width, isWorkspace.value ? 240 : 200));
const sidebar = document.getElementById('app-sidebar');
const rail = sidebar?.getBoundingClientRect();
// لبهٔ راست منو = لبهٔ چپ سایدبار − ۴px
const edge = rail?.left ?? box.left;
const beside = {
right: 'auto',
left: `${Math.round(edge - 4)}px`,
transform: 'translateX(-100%)',
minWidth: `${minWidth}px`,
};
if (isWorkspace.value) {
menuStyle.value = {
...beside,
top: `${Math.round(box.top)}px`,
bottom: 'auto',
};
return;
}
menuStyle.value = {
...beside,
top: 'auto',
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
right: `${Math.round(window.innerWidth - box.right)}px`,
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
};
}
async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) {
closeMenu();
open.value = false;
return;
}
loading.value = true;
@@ -142,16 +90,42 @@ async function choose(org: OrganizationMembershipDto) {
}
}
function openCreate() {
open.value = false;
newName.value = '';
newField.value = 'personal';
error.value = '';
createOpen.value = true;
}
async function submitCreate() {
const name = newName.value.trim();
if (name.length < 2) {
error.value = 'نام محیط باید حداقل دو حرف باشد.';
return;
}
creating.value = true;
error.value = '';
try {
await auth.createOrganization({
name,
businessField: newField.value,
});
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
creating.value = false;
}
}
function onDocClick(event: MouseEvent) {
if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
closeMenu();
open.value = false;
}
defineExpose({ close: closeMenu });
onMounted(() => {
void loadOrgs();
document.addEventListener('click', onDocClick);
@@ -163,46 +137,27 @@ onUnmounted(() => {
</script>
<template>
<div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
<div ref="rootEl" class="org-switch">
<button
class="org-btn"
:class="{
compact,
static: !menuEnabled,
'org-ws': isWorkspace,
open,
}"
:class="{ compact, static: !canSwitch }"
type="button"
:title="currentName"
:aria-expanded="open"
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
:aria-disabled="!menuEnabled"
:aria-disabled="!canSwitch"
@click.stop="toggle"
>
<template v-if="isWorkspace">
<span class="org-mark" aria-hidden="true">{{ currentInitial }}</span>
<span class="org-copy">
<strong>{{ currentName }}</strong>
<small>{{ currentSubtitle }}</small>
</span>
<i class="org-chev" :class="{ open }" aria-hidden="true" />
</template>
<template v-else>
<UiIcon name="building" :size="18" />
<span>{{ currentName }}</span>
</template>
<UiIcon name="building" :size="18" />
<span>{{ currentName }}</span>
</button>
<Teleport to="body">
<div
v-if="open && menuEnabled"
v-if="open && canSwitch"
ref="menuEl"
class="menu"
:class="{ 'org-ws': isWorkspace }"
:style="menuStyle"
role="listbox"
aria-label="محیط‌های کاری"
>
<p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
<p class="hint">ورود به محیط</p>
<button
v-for="org in orgs"
:key="org.id"
@@ -210,25 +165,41 @@ onUnmounted(() => {
class="org-item"
:class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading"
role="option"
:aria-selected="org.id === auth.user?.organizationId"
@click="choose(org)"
>
<span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
<span class="org-item-copy">
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</span>
<UiIcon
v-if="org.id === auth.user?.organizationId"
class="org-check"
name="check"
:size="16"
/>
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</button>
<p v-if="error" class="err">{{ error }}</p>
<button type="button" class="create" :disabled="loading" @click="openCreate">
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
</div>
</Teleport>
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
<form class="form" @submit.prevent="submitCreate">
<label>
نام محیط
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
</label>
<label>
زمینه فعالیت
<select v-model="newField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
</button>
</template>
</UiModal>
</div>
</template>
@@ -263,7 +234,7 @@ onUnmounted(() => {
margin-top: 0.05rem;
}
.org-btn > span {
.org-btn span {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
@@ -282,7 +253,7 @@ onUnmounted(() => {
color: #94a3b8;
}
.org-btn.compact:not(.org-ws) {
.org-btn.compact {
display: grid;
place-items: center;
width: 100%;
@@ -291,113 +262,18 @@ onUnmounted(() => {
border-radius: var(--radius);
}
.org-btn.compact:not(.org-ws) > span {
.org-btn.compact span {
display: none;
}
.org-btn.compact:not(.org-ws):hover {
.org-btn.compact:hover {
background: #f8fafc;
}
.org-btn.compact.static:not(.org-ws):hover {
.org-btn.compact.static:hover {
background: transparent;
}
.org-btn.org-ws {
align-items: center;
gap: 0.7rem;
padding: 0.45rem 0.55rem;
border-radius: 16px;
color: #0f172a;
text-align: start;
}
.org-btn.org-ws:hover,
.org-btn.org-ws.open {
background: #f8fafc;
color: #0f172a;
}
.org-btn.org-ws.static:hover {
background: #f8fafc;
color: #0f172a;
}
.org-mark {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--accent-soft) 80%, white);
color: var(--accent-deep);
font-size: 0.92rem;
font-weight: 800;
}
.org-copy {
min-width: 0;
flex: 1;
display: grid;
gap: 0.08rem;
}
.org-copy strong {
font-size: 0.92rem;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-copy small {
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-chev {
width: 0.42rem;
height: 0.42rem;
border-right: 1.6px solid #94a3b8;
border-bottom: 1.6px solid #94a3b8;
/* RTL: اشاره به بیرون (چپ) */
transform: rotate(135deg);
transition: transform 0.15s ease;
flex-shrink: 0;
margin-inline-start: 0.15rem;
}
.org-chev.open {
transform: rotate(45deg);
}
.org-btn.compact.org-ws {
display: grid;
place-items: center;
width: 100%;
min-height: var(--control-h);
padding: 0;
border-radius: 14px;
}
.org-btn.compact.org-ws .org-copy,
.org-btn.compact.org-ws .org-chev {
display: none;
}
.org-btn.compact.org-ws .org-mark {
width: 32px;
height: 32px;
font-size: 0.82rem;
}
.menu {
position: fixed;
z-index: 90;
@@ -410,21 +286,11 @@ onUnmounted(() => {
gap: 0.2rem;
}
.menu.org-ws {
padding: 0.5rem;
border-radius: 16px;
border: 1px solid #e8eef3;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
gap: 4px;
}
.hint {
margin: 0;
padding: 0.35rem 0.65rem 0.45rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #94a3b8;
padding: 0.25rem 0.45rem;
font-size: 0.72rem;
color: var(--muted);
}
.org-item,
@@ -439,89 +305,29 @@ onUnmounted(() => {
cursor: pointer;
}
.menu.org-ws .org-item {
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
border-radius: 12px;
padding: 0.55rem 0.65rem;
color: #0f172a;
}
.org-item-mark {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
background: #eef2f7;
color: #475569;
font-size: 0.85rem;
font-weight: 800;
flex-shrink: 0;
}
.org-item-copy {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.org-item strong {
font-size: 0.88rem;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
}
.org-item span,
.org-item-copy span {
font-size: 0.74rem;
font-weight: 500;
color: #94a3b8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-check {
color: #0f172a;
flex-shrink: 0;
.org-item span {
font-size: 0.72rem;
color: var(--muted);
}
.org-item:hover,
.create:hover {
background: #f3f4f6;
}
.menu.org-ws .org-item:hover {
background: #f3f4f6;
}
.menu.org-ws .org-item:hover .org-item-mark {
background: #e2e8f0;
background: color-mix(in srgb, var(--accent-soft) 70%, white);
}
.org-item.on {
background: #f3f4f6;
background: var(--accent-soft);
}
.menu.org-ws .org-item.on {
background: #f3f4f6;
}
.menu.org-ws .org-item.on .org-item-mark {
background: color-mix(in srgb, var(--accent-soft) 85%, white);
color: var(--accent-deep);
}
.menu.org-ws .org-item.on strong {
font-weight: 800;
color: #0f172a;
.create {
margin-top: 0.15rem;
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
}
.err {
@@ -529,4 +335,41 @@ onUnmounted(() => {
color: #b91c1c;
font-size: 0.75rem;
}
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.form input,
.form select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
}
.ghost,
.primary {
border-radius: 10px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
.ghost {
border: 1px solid var(--line);
background: #fff;
}
.primary {
border: 0;
background: var(--accent);
color: #fff;
font-weight: 700;
}
</style>
@@ -18,7 +18,7 @@ import {
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@/icons/ant-bridge';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import {
filterPhoneContacts,
isContactPickerSupported,
+65 -339
View File
@@ -185,13 +185,6 @@ const fabStyle = computed(() => ({
bottom: 'auto',
}));
const panelPlacement = computed(() => {
const edge = 16;
const spaceAbove = fabPos.value.top - edge;
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
return spaceAbove >= spaceBelow ? 'above' : 'below';
});
const panelStyle = computed(() => {
const gap = 12;
const edge = 16;
@@ -200,33 +193,28 @@ const panelStyle = computed(() => {
left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left);
const placeAbove = panelPlacement.value === 'above';
const maxHeight = Math.max(
0,
(placeAbove
? fabPos.value.top - edge
: viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge) - gap,
);
const shared = {
left: `${left}px`,
right: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
transformOrigin: placeAbove ? 'bottom left' : 'top left',
};
const spaceAbove = fabPos.value.top - edge;
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
const placeAbove = spaceAbove >= spaceBelow;
const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap);
if (placeAbove) {
return {
...shared,
left: `${left}px`,
right: 'auto',
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
};
}
return {
...shared,
left: `${left}px`,
right: 'auto',
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
};
});
@@ -250,6 +238,10 @@ function onKeydown(ev: KeyboardEvent) {
}
}
watch(isQuickCreateOpen, (value) => {
document.body.style.overflow = value ? 'hidden' : '';
});
watch(hidden, (value) => {
if (value) close();
});
@@ -272,51 +264,46 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl">
<Transition name="qc-overlay">
<button
v-if="isQuickCreateOpen"
class="overlay"
type="button"
aria-label="بستن ایجاد سریع"
@click="close"
/>
</Transition>
<button
v-if="isQuickCreateOpen"
class="overlay"
type="button"
aria-label="بستن ایجاد سریع"
@click="close"
/>
<Transition name="qc-panel">
<section
v-if="isQuickCreateOpen"
class="panel"
:data-place="panelPlacement"
aria-labelledby="qc-title"
:style="panelStyle"
>
<h2 id="qc-title">ایجاد سریع</h2>
<div class="items">
<button
v-for="(item, index) in items"
:key="item.key"
class="item"
type="button"
:style="{ '--qc-i': index }"
@click="go(item.to)"
>
<span class="item-ico" :class="item.tone">
<UiIcon :name="item.icon" :size="18" />
</span>
<span class="copy">
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
</button>
</div>
</section>
</Transition>
<section
v-if="isQuickCreateOpen"
class="panel"
aria-labelledby="qc-title"
:style="panelStyle"
>
<h2 id="qc-title">ایجاد سریع</h2>
<div class="items">
<button
v-for="item in items"
:key="item.key"
class="item"
type="button"
@click="go(item.to)"
>
<span class="item-ico" :class="item.tone">
<UiIcon :name="item.icon" :size="18" />
</span>
<span class="copy">
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
</button>
</div>
</section>
<button
v-if="ready"
@@ -332,8 +319,6 @@ onBeforeUnmount(() => {
@pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp"
>
<span class="fab-pulse" aria-hidden="true" />
<span class="fab-shine" aria-hidden="true" />
<span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span>
@@ -353,12 +338,11 @@ onBeforeUnmount(() => {
z-index: 90;
border: 0;
padding: 0;
background: rgba(15, 23, 42, 0.42);
background: rgba(0, 0, 0, 0.68);
cursor: pointer;
}
.panel {
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
z-index: 92;
width: 300px;
@@ -368,90 +352,10 @@ onBeforeUnmount(() => {
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
border-radius: var(--radius);
padding: 12px 12px 10px;
box-shadow:
0 18px 40px -18px rgba(15, 23, 42, 0.35),
0 8px 18px -10px rgba(13, 148, 136, 0.18),
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
box-shadow: var(--shadow-lg);
overflow: hidden;
will-change: transform, opacity;
}
.qc-overlay-enter-active,
.qc-overlay-leave-active {
transition: opacity 0.16s ease;
}
.qc-overlay-enter-from,
.qc-overlay-leave-to {
opacity: 0;
}
.qc-panel-enter-active {
transition:
opacity 0.18s var(--qc-ease),
transform 0.2s var(--qc-ease);
}
.qc-panel-leave-active {
transition:
opacity 0.14s ease,
transform 0.16s ease;
}
.qc-panel-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
.panel[data-place='above'].qc-panel-enter-from {
transform: translateY(-8px) scale(0.97);
}
.qc-panel-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
.panel[data-place='above'].qc-panel-leave-to {
transform: translateY(-4px) scale(0.98);
}
.qc-panel-enter-active .items {
overflow: hidden;
}
.qc-panel-enter-active .item {
animation: qc-item-in 0.2s var(--qc-ease) both;
animation-delay: calc(0.02s + var(--qc-i, 0) * 0.02s);
}
.qc-panel-enter-active h2 {
animation: qc-item-in 0.16s var(--qc-ease) both;
}
@keyframes qc-item-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.qc-overlay-enter-active,
.qc-overlay-leave-active,
.qc-panel-enter-active,
.qc-panel-leave-active,
.qc-panel-enter-active .item,
.qc-panel-enter-active h2 {
transition: none !important;
animation: none !important;
}
}
.panel h2 {
@@ -465,20 +369,12 @@ onBeforeUnmount(() => {
.items {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-left: 2px;
overscroll-behavior: contain;
scrollbar-width: none;
}
.items::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.item {
@@ -554,7 +450,6 @@ onBeforeUnmount(() => {
}
.fab {
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
z-index: 93;
box-sizing: border-box;
@@ -564,226 +459,57 @@ onBeforeUnmount(() => {
margin: 0;
border: 0;
border-radius: var(--radius);
overflow: visible;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
background-size: 140% 140%;
overflow: hidden;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
color: #fff;
display: grid;
place-items: center;
line-height: 0;
direction: ltr;
isolation: isolate;
box-shadow:
0 4px 14px rgba(13, 148, 136, 0.35),
0 0 0 0 rgba(45, 212, 191, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
inset 0 1px 0 rgba(255, 255, 255, 0.22);
cursor: grab;
touch-action: none;
user-select: none;
transition:
filter 0.35s var(--fab-ease),
box-shadow 0.35s var(--fab-ease),
transform 0.35s var(--fab-ease),
background 0.4s var(--fab-ease);
animation:
fab-glow 3.4s ease-in-out infinite,
fab-gradient 7s ease-in-out infinite;
}
.fab-pulse,
.fab-shine,
.fab-face {
grid-area: 1 / 1;
}
.fab-pulse {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.42);
animation: fab-ring 2.8s var(--fab-ease) infinite;
}
.fab-shine {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.fab-shine::after {
content: '';
position: absolute;
top: -20%;
bottom: -20%;
left: -40%;
width: 42%;
background: linear-gradient(
105deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 35%,
rgba(255, 255, 255, 0.42) 50%,
rgba(255, 255, 255, 0.08) 65%,
transparent 100%
);
transform: translateX(-160%) skewX(-18deg);
animation: fab-sheen 4.8s var(--fab-ease) infinite;
filter 0.15s ease,
box-shadow 0.15s ease,
transform 0.12s ease;
}
.fab-face {
position: relative;
z-index: 2;
display: grid;
place-items: center;
place-content: center;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
pointer-events: none;
transition: transform 0.45s var(--fab-ease);
animation: fab-breathe 3.4s ease-in-out infinite;
}
.fab :deep(.ico),
.fab :deep(svg) {
.fab :deep(.ico) {
display: block;
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
margin: 0;
stroke-width: 1.7;
filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.18));
transition: transform 0.45s var(--fab-ease), opacity 0.25s ease;
}
.fab:hover {
filter: brightness(1.07) saturate(1.05);
transform: translateY(-1px) scale(1.04);
filter: brightness(1.06);
box-shadow:
0 10px 24px rgba(13, 148, 136, 0.42),
0 0 0 5px rgba(45, 212, 191, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.32);
}
.fab:hover .fab-face {
animation-play-state: paused;
transform: scale(1.06);
0 6px 18px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
box-shadow:
0 8px 22px rgba(15, 23, 42, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
animation: none;
}
.fab.open .fab-pulse,
.fab.open .fab-shine::after {
animation: none;
opacity: 0;
}
.fab.open .fab-face {
animation: none;
transform: rotate(90deg) scale(1.02);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
}
.fab.dragging {
cursor: grabbing;
transform: scale(1.08);
box-shadow:
0 12px 28px rgba(13, 148, 136, 0.48),
0 0 0 6px rgba(45, 212, 191, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
animation: none;
transition-duration: 0.12s;
}
.fab.dragging .fab-pulse,
.fab.dragging .fab-shine::after,
.fab.dragging .fab-face {
animation: none;
}
@keyframes fab-glow {
0%,
100% {
box-shadow:
0 4px 14px rgba(13, 148, 136, 0.32),
0 0 0 0 rgba(45, 212, 191, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
50% {
box-shadow:
0 8px 22px rgba(13, 148, 136, 0.48),
0 0 18px 2px rgba(45, 212, 191, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
}
@keyframes fab-gradient {
0%,
100% {
background-position: 0% 40%;
}
50% {
background-position: 100% 60%;
}
}
@keyframes fab-ring {
0% {
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.4);
opacity: 1;
}
70% {
box-shadow: 0 0 0 12px rgba(45, 212, 191, 0);
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fab-sheen {
0%,
55% {
transform: translateX(-160%) skewX(-18deg);
}
85%,
100% {
transform: translateX(320%) skewX(-18deg);
}
}
@keyframes fab-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.fab-pulse,
.fab-shine::after,
.fab-face {
animation: none !important;
}
.fab:hover,
.fab.open .fab-face {
transform: none;
}
transform: scale(1.05);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
}
</style>
-144
View File
@@ -1,144 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { OrgRoleDto, RolePermissionKey } from '@mento/shared';
import {
Avatar as AAvatar,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Empty as AEmpty,
Flex as AFlex,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Space as ASpace,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { UserOutlined } from '@/icons/ant-bridge';
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
const props = defineProps<{
role: OrgRoleDto;
childRoles: OrgRoleDto[];
permissionItems: Array<{ key: RolePermissionKey; label: string }>;
avatarOf: (id: string) => string;
tagColor: (color: string | null | undefined) => string;
}>();
const emit = defineEmits<{
'pick-role': [id: string];
}>();
const innerTab = ref('summary');
watch(
() => props.role.id,
() => {
innerTab.value = 'summary';
},
);
const grantedPermissions = computed(() =>
props.permissionItems.filter((item) => props.role.permissions.includes(item.key)),
);
const permSummary = computed(
() =>
`${toFaDigits(grantedPermissions.value.length)} از ${toFaDigits(props.permissionItems.length)}`,
);
</script>
<template>
<a-flex vertical :gap="16">
<a-space direction="vertical" :size="4">
<a-space wrap>
<a-tag v-if="role.systemKey" :bordered="false">سیستمی</a-tag>
<a-typography-text v-if="role.parentName" type="secondary">
زیرمجموعهٔ
<a-typography-link @click="emit('pick-role', role.parentId!)">
{{ role.parentName }}
</a-typography-link>
</a-typography-text>
</a-space>
<a-typography-text v-if="role.description" type="secondary">
{{ role.description }}
</a-typography-text>
</a-space>
<a-tabs v-model:activeKey="innerTab">
<a-tab-pane key="summary" tab="خلاصه">
<a-flex vertical :gap="16">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="کاربران">
{{ toFaDigits(role.userCount) }} نفر
</a-descriptions-item>
<a-descriptions-item label="مجوزها">
{{ permSummary }}
</a-descriptions-item>
<a-descriptions-item label="زیرنقش‌ها">
{{ toFaDigits(childRoles.length) }} مورد
</a-descriptions-item>
</a-descriptions>
<a-flex vertical :gap="8">
<a-typography-text strong>زیرنقش‌ها</a-typography-text>
<a-space v-if="childRoles.length" wrap>
<a-tag
v-for="child in childRoles"
:key="child.id"
:color="tagColor(child.color)"
:bordered="false"
style="cursor: pointer"
@click="emit('pick-role', child.id)"
>
{{ child.name }} · {{ toFaDigits(child.userCount) }}
</a-tag>
</a-space>
<a-empty v-else description="زیرنقشی تعریف نشده" :image="emptySimple" />
</a-flex>
</a-flex>
</a-tab-pane>
<a-tab-pane key="perms" :tab="`مجوزها (${permSummary})`">
<a-flex vertical :gap="12">
<a-typography-text type="secondary">
فقط مجوزهای فعال نمایش داده می‌شود. برای تغییر، ویرایش نقش را بزنید.
</a-typography-text>
<a-space v-if="grantedPermissions.length" wrap>
<a-tag
v-for="perm in grantedPermissions"
:key="perm.key"
color="success"
:bordered="false"
>
{{ perm.label }}
</a-tag>
</a-space>
<a-empty v-else description="هیچ مجوزی فعال نیست" :image="emptySimple" />
</a-flex>
</a-tab-pane>
<a-tab-pane key="members" :tab="`کاربران (${toFaDigits(role.userCount)})`">
<a-list v-if="role.members.length" :data-source="role.members">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.fullName">
<template #avatar>
<a-avatar :src="avatarOf(item.id)">
<template #icon><UserOutlined /></template>
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-empty v-else description="کاربری با این نقش نیست" :image="emptySimple" />
</a-tab-pane>
</a-tabs>
</a-flex>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Checkbox as ACheckbox,
+92 -79
View File
@@ -118,82 +118,77 @@ defineExpose({ refresh: load });
<div v-if="visible" class="sms-wallet">
<div
class="chip"
:class="[toneClass, (summary?.balanceRials ?? 0) < 0 ? 'is-debt' : '']"
role="button"
tabindex="0"
:class="toneClass"
:title="loading ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`"
aria-label="شارژ اعتبار پیامک"
@click="chargeOpen = true"
@keydown.enter.prevent="chargeOpen = true"
@keydown.space.prevent="chargeOpen = true"
>
<span class="label">پیامک</span>
<span class="amount">{{ balanceLabel }}</span>
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div>
<Teleport to="body">
<div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
<header>
<strong>شارژ اعتبار پیامک</strong>
<button type="button" class="x" @click="closeCharge">×</button>
</header>
<div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
<header>
<strong>شارژ اعتبار پیامک</strong>
<button type="button" class="x" @click="closeCharge">×</button>
</header>
<div v-if="chargeInfo" class="charge-body">
<div class="instruction-box">
<p>{{ chargeInfo.instruction }}</p>
</div>
<div class="info-card">
<div class="info-head">
<span>شماره کارت</span>
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
کپی
</button>
</div>
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
</div>
<div class="info-card">
<div class="info-head">
<span>شماره شبا</span>
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
کپی
</button>
</div>
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
</div>
<div class="info-card muted-card">
<span class="small">به نام</span>
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
</div>
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
<div v-if="chargeInfo" class="charge-body">
<div class="instruction-box">
<p>{{ chargeInfo.instruction }}</p>
</div>
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
<div class="info-card">
<div class="info-head">
<span>شماره کارت</span>
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
کپی
</button>
</div>
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
</div>
<div class="info-card">
<div class="info-head">
<span>شماره شبا</span>
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
کپی
</button>
</div>
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
</div>
<div class="info-card muted-card">
<span class="small">به نام</span>
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
</div>
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
</div>
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
<header>
<strong>موجودی پیامک منفی است</strong>
<button type="button" class="x" @click="warnOpen = false">×</button>
</header>
<p>
اعتبار پیامک محیط شما منفی شده
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
شارژ کنید.
</p>
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div>
<div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
<header>
<strong>موجودی پیامک منفی است</strong>
<button type="button" class="x" @click="warnOpen = false">×</button>
</header>
<p>
اعتبار پیامک محیط شما منفی شده
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
شارژ کنید.
</p>
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div>
</div>
</Teleport>
</div>
</template>
@@ -205,15 +200,15 @@ defineExpose({ refresh: load });
flex-shrink: 0;
}
/* چیپ جمع‌وجور؛ کلیک کل سطح، پنجرهٔ شارژ را باز می‌کند */
/* چیپ جمع‌وجور؛ دکمه شارژ سمت چپ، برچسب سمت راست */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--control-h);
height: 31px;
box-sizing: border-box;
margin: 0;
padding: 0 8px;
padding: 0 4px 0 2px;
border: 1px solid #c3ead6;
border-radius: 10px;
background: #f2fbf6;
@@ -222,7 +217,6 @@ defineExpose({ refresh: load });
line-height: 1;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
}
.chip.warning {
@@ -238,17 +232,6 @@ defineExpose({ refresh: load });
color: #991b1b;
}
.chip.is-debt {
border-color: var(--danger);
background: var(--danger);
color: #fff;
}
.chip.is-debt .label {
color: inherit;
opacity: 0.92;
}
.label {
font-weight: 600;
font-size: 0.7rem;
@@ -256,11 +239,43 @@ defineExpose({ refresh: load });
}
.amount {
font-weight: 700;
font-variant-numeric: tabular-nums;
font-weight: 800;
letter-spacing: -0.01em;
}
.charge {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
height: 23px;
margin: 0;
padding: 0 6px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
color: #334155;
font: inherit;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.charge:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.charge:active {
background: #f1f5f9;
}
.layer {
position: fixed;
inset: 0;
@@ -270,7 +285,6 @@ defineExpose({ refresh: load });
justify-content: center;
padding: 1rem;
overflow: auto;
box-sizing: border-box;
}
.backdrop {
@@ -291,7 +305,6 @@ defineExpose({ refresh: load });
display: grid;
gap: 0.75rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
box-sizing: border-box;
}
.modal header {
+1 -1
View File
@@ -7,7 +7,7 @@ import {
Popover as APopover,
Typography,
} from 'ant-design-vue';
import { SettingOutlined } from '@/icons/ant-bridge';
import { SettingOutlined } from '@ant-design/icons-vue';
import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali';
+364 -283
View File
@@ -19,36 +19,9 @@ import {
emptySectionDataAccess,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Radio as ARadio,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
const { Text: ATypographyText } = Typography;
const props = defineProps<{
users: OrgUserDto[];
@@ -64,7 +37,7 @@ const showPriorArchive = computed(
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
const priorSearch = ref('');
const priorPick = ref<string | undefined>(undefined);
const priorPick = ref('');
const priorSavingId = ref('');
const cutoffLocal = ref('');
const cutoffSaving = ref(false);
@@ -75,16 +48,10 @@ const availablePriorUsers = computed(() => {
return priorUsers.value.filter((u) => {
if (u.priorArchiveEnabled) return false;
if (!q) return true;
return [u.fullName, u.email, u.mobile]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q));
return [u.fullName, u.email, u.mobile].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
});
});
const availablePriorOptions = computed(() =>
availablePriorUsers.value.map((u) => ({ value: u.id, label: u.fullName })),
);
const emit = defineEmits<{
refreshUsers: [];
}>();
@@ -107,14 +74,9 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
const memberRoleOptions = [
{ value: 'member', label: 'عضو' },
{ value: 'manager', label: 'سرپرست' },
];
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
priorPick.value = undefined;
priorPick.value = '';
error.value = '';
ok.value = '';
try {
@@ -152,10 +114,7 @@ async function loadPriorArchiveCutoff() {
applyCutoffFromOrg(data);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تاریخ بایگانی ناموفق بود.',
)
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.')
: 'بارگذاری تاریخ بایگانی ناموفق بود.';
} finally {
cutoffLoading.value = false;
@@ -177,10 +136,7 @@ async function onCutoffChange(value: string) {
await auth.hydrate();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره تاریخ بایگانی ناموفق بود.',
)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.')
: 'ذخیره تاریخ بایگانی ناموفق بود.';
await loadPriorArchiveCutoff();
} finally {
@@ -188,10 +144,9 @@ async function onCutoffChange(value: string) {
}
}
function pickPriorUser(userId: unknown) {
const id = userId == null ? '' : String(userId);
if (!id) return;
void setPriorArchive(id, true);
function pickPriorUser(userId: string) {
if (!userId) return;
void setPriorArchive(userId, true);
}
async function load() {
@@ -288,19 +243,9 @@ function toggleMember(userId: string) {
}
}
function setRole(userId: string, role: unknown) {
function setRole(userId: string, role: TeamMemberRole) {
if (!memberDraft.value[userId]) return;
memberDraft.value = {
...memberDraft.value,
[userId]: String(role) as TeamMemberRole,
};
}
function teamMembersLabel(team: TeamDto) {
if (!team.members.length) return 'بدون عضو';
return team.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ');
memberDraft.value = { ...memberDraft.value, [userId]: role };
}
async function saveMembers() {
@@ -420,226 +365,362 @@ onMounted(() => {
</script>
<template>
<a-flex vertical :gap="12">
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<div class="teams-panel">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<a-card :bordered="false">
<a-form v-if="canEdit" layout="vertical" @finish="createTeam">
<a-divider orientation="right">تیم جدید</a-divider>
<a-row :gutter="[16, 0]" align="bottom">
<a-col :xs="24" :md="8">
<a-form-item label="نام تیم">
<a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="10">
<a-form-item label="توضیح">
<a-input v-model:value="newDesc" placeholder="اختیاری" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" :loading="saving" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<section v-if="canEdit" class="card">
<h3>تیم جدید</h3>
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="newDesc" placeholder="اختیاری" />
</UiField>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
</div>
</section>
<a-divider orientation="right">تیم‌ها</a-divider>
<CrmSkeleton :loading="loading">
<CrmSkeletonCard :rows="6">
<a-empty
v-if="!loading && !teams.length"
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
<section class="card">
<h3>تیم‌ها</h3>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
<p class="muted">
{{ t.description || '—' }}
· {{ toFaDigits(t.memberCount) }} عضو
</p>
</div>
<div v-if="canEdit" class="actions">
<UiButton variant="secondary" @click="startMetaEdit(t)">ویرایش</UiButton>
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
</div>
</header>
<p class="members">
{{
t.members.length
? t.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ')
: 'بدون عضو'
}}
</p>
<div v-if="editingMetaId === t.id" class="editor">
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="editName" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="editDesc" />
</UiField>
</div>
<div class="actions">
<UiButton :loading="saving" @click="saveMeta">ذخیره</UiButton>
<UiButton variant="secondary" @click="editingMetaId = null">انصراف</UiButton>
</div>
</div>
<div v-if="editingId === t.id" class="editor">
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
<input
type="checkbox"
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
/>
<span>{{ u.fullName }}</span>
<select
v-if="memberDraft[u.id]"
:value="memberDraft[u.id]"
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
>
<option value="member">عضو</option>
<option value="manager">سرپرست</option>
</select>
</label>
<div class="actions">
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
</div>
</div>
</article>
</div>
</section>
<section v-if="showPriorArchive" class="card">
<header class="prior-head">
<h3>بایگانی اطلاعات قبل</h3>
<JalaliDateInput
:model-value="cutoffLocal"
mode="date"
placeholder="تاریخ برش"
:disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange"
/>
<a-list v-else item-layout="vertical" :data-source="teams">
<template #renderItem="{ item: t }">
<a-list-item>
<template v-if="canEdit" #actions>
<a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button>
<a-button type="link" @click="startEdit(t)">اعضا</a-button>
<a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button>
</template>
<a-list-item-meta>
<template #title>
<a-space wrap>
<a-typography-text strong>{{ t.name }}</a-typography-text>
<a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag>
</a-space>
</template>
<template #description>
<a-typography-text type="secondary">
{{ t.description || 'بدون توضیح' }} · {{ teamMembersLabel(t) }}
</a-typography-text>
</template>
</a-list-item-meta>
</header>
<p class="hint">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
</p>
<div class="prior-picker">
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
<select
v-model="priorPick"
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
@change="pickPriorUser(($event.target as HTMLSelectElement).value)"
>
<option value="">
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعال‌سازی' : 'کاربر دیگری پیدا نشد' }}
</option>
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div v-if="selectedPriorUsers.length" class="prior-chips">
<span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
{{ u.fullName }}
<button
type="button"
:disabled="priorSavingId === u.id"
:aria-label="`حذف ${u.fullName}`"
@click="setPriorArchive(u.id, false)"
>
×
</button>
</span>
</div>
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
</section>
<a-form
v-if="editingMetaId === t.id"
layout="vertical"
style="width: 100%"
@finish="saveMeta"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام تیم">
<a-input v-model:value="editName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="توضیح">
<a-input v-model:value="editDesc" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button @click="editingMetaId = null">انصراف</a-button>
</a-space>
</a-form>
<a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%">
<a-typography-text type="secondary">
اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.
</a-typography-text>
<a-row :gutter="[12, 8]">
<a-col
v-for="u in users.filter((x) => x.isActive !== false)"
:key="u.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-space>
<a-checkbox
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
>
{{ u.fullName }}
</a-checkbox>
<a-select
v-if="memberDraft[u.id]"
:value="memberDraft[u.id]"
:options="memberRoleOptions"
style="width: 120px"
@update:value="setRole(u.id, $event)"
/>
</a-space>
</a-col>
</a-row>
<a-space>
<a-button type="primary" :loading="saving" @click="saveMembers">ذخیره اعضا</a-button>
<a-button @click="editingId = null">انصراف</a-button>
</a-space>
</a-flex>
</a-list-item>
</template>
</a-list>
</CrmSkeletonCard>
</CrmSkeleton>
<a-collapse :bordered="false" ghost>
<a-collapse-panel v-if="showPriorArchive" key="prior" header="بایگانی اطلاعات قبل">
<a-row :gutter="[16, 12]" align="middle">
<a-col :xs="24" :lg="8">
<JalaliDateInput
:model-value="cutoffLocal"
mode="date"
placeholder="تاریخ برش"
:disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange"
/>
</a-col>
<a-col :xs="24" :lg="8">
<a-input v-model:value="priorSearch" placeholder="جستجوی کاربر…" style="width: 100%" />
</a-col>
<a-col :xs="24" :lg="8">
<a-select
:value="priorPick"
:options="availablePriorOptions"
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
allow-clear
show-search
option-filter-prop="label"
:placeholder="
availablePriorUsers.length ? 'فعال‌سازی کاربر' : 'کاربر دیگری پیدا نشد'
"
style="width: 100%"
@update:value="pickPriorUser"
/>
</a-col>
</a-row>
<a-flex vertical :gap="8">
<a-typography-text type="secondary">
فقط این کاربران سوئیچ کار جاری / بایگانی را می‌بینند.
</a-typography-text>
<a-space v-if="selectedPriorUsers.length" wrap>
<a-tag
v-for="u in selectedPriorUsers"
:key="u.id"
closable
:disabled="priorSavingId === u.id"
@close.prevent="setPriorArchive(u.id, false)"
>
{{ u.fullName }}
</a-tag>
</a-space>
<a-typography-text v-else type="secondary">
هنوز کاربری انتخاب نشده است.
</a-typography-text>
</a-flex>
</a-collapse-panel>
<a-collapse-panel key="access" header="محدوده داده کاربران">
<a-flex vertical :gap="8">
<a-typography-text type="secondary">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر جدا بگذارید.
</a-typography-text>
<a-empty
v-if="!editableUsers.length"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
<section class="card">
<h3>محدوده داده</h3>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
<small class="vis-badge">{{ accessSummary(u) }}</small>
</span>
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
</UiButton>
</div>
<div v-if="expandedAccess === u.id" class="vis-scopes">
<label class="chip-row">
<input v-model="accessMode" type="radio" value="inherit" />
مثل نقش (بدون override)
</label>
<label class="chip-row">
<input v-model="accessMode" type="radio" value="custom" />
سفارشی برای این کاربر
</label>
<DataAccessPolicyPanel
v-model="draftAccess"
v-model:treasury-access="draftTreasury"
:show-treasury="showTreasury"
:data-locked="accessMode === 'inherit'"
compact
/>
<a-list v-else item-layout="vertical" :data-source="editableUsers">
<template #renderItem="{ item: u }">
<a-list-item>
<template v-if="canEdit" #actions>
<a-button type="link" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
</a-button>
</template>
<a-list-item-meta :description="accessSummary(u)">
<template #title>
<a-typography-text strong>{{ u.fullName }}</a-typography-text>
</template>
</a-list-item-meta>
<a-flex v-if="expandedAccess === u.id" vertical :gap="8" style="width: 100%">
<a-radio-group v-model:value="accessMode">
<a-space wrap>
<a-radio value="inherit">مثل نقش</a-radio>
<a-radio value="custom">سفارشی</a-radio>
</a-space>
</a-radio-group>
<DataAccessPolicyPanel
v-model="draftAccess"
v-model:treasury-access="draftTreasury"
:show-treasury="showTreasury"
:data-locked="accessMode === 'inherit'"
compact
/>
<a-space>
<a-button type="primary" :loading="accessSaving" @click="saveUserAccess(u.id)">
ذخیره
</a-button>
<a-button @click="expandedAccess = null">انصراف</a-button>
</a-space>
</a-flex>
</a-list-item>
</template>
</a-list>
</a-flex>
</a-collapse-panel>
</a-collapse>
</a-card>
</a-flex>
<div class="actions">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.teams-panel {
display: grid;
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 1rem;
}
.card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.prior-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.prior-head h3 {
margin: 0;
}
.prior-head :deep(.trigger) {
min-width: 12rem;
}
.prior-picker {
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(13rem, 1fr);
gap: 0.6rem;
margin-top: 0.75rem;
}
.prior-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.75rem;
}
.prior-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #ccfbf1;
border-radius: 999px;
background: #f0fdfa;
color: #0f766e;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
font-weight: 600;
}
.prior-chip button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0;
}
.prior-chip button:disabled {
opacity: 0.45;
cursor: wait;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.team-list {
display: grid;
gap: 0.75rem;
}
.team {
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 0.75rem;
}
.team header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted,
.hint,
.members {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.editor {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.45rem;
}
.person {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
}
.vis-table {
display: grid;
gap: 0.5rem;
}
.vis-user {
border-bottom: 1px solid #f1f5f9;
padding-bottom: 0.45rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
}
.vis-badge {
margin-inline-start: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #eef2ff;
color: #4338ca;
font-size: 0.72rem;
}
.vis-scopes {
display: grid;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.chip-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
}
select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.row,
.vis-row,
.prior-picker {
grid-template-columns: 1fr;
}
}
</style>
+346 -7
View File
@@ -1,10 +1,4 @@
<script setup lang="ts">
/**
* آیکون‌های legacy — حالا از پک vuesax-linear رندر می‌شوند.
* API نام‌ها برای سازگاری با صفحات قدیمی حفظ شده.
*/
import VsIcon from '@/components/VsIcon.vue';
defineProps<{
name:
| 'home'
@@ -88,7 +82,352 @@ defineProps<{
</script>
<template>
<VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
<svg
class="ico"
:width="size ?? 20"
:height="size ?? 20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'home'">
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V21h14V9.5" />
<path d="M10 21v-6h4v6" />
</template>
<template v-else-if="name === 'users'">
<circle cx="9" cy="8" r="3.2" />
<path d="M2.5 19c.8-3 2.9-4.5 6.5-4.5s5.7 1.5 6.5 4.5" />
<circle cx="17" cy="9" r="2.4" />
<path d="M15.2 14.2c2.3.3 3.9 1.5 4.8 3.8" />
</template>
<template v-else-if="name === 'briefcase'">
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5V7" />
<path d="M3 12h18" />
</template>
<template v-else-if="name === 'check'">
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.4 2.4 4.6-5" />
</template>
<template v-else-if="name === 'box'">
<path d="M12 3 3.5 7.5v9L12 21l8.5-4.5v-9L12 3Z" />
<path d="M12 12 3.5 7.5M12 12l8.5-4.5M12 12v9" />
</template>
<template v-else-if="name === 'chart'">
<path d="M4 19h16" />
<path d="M7 16V10" />
<path d="M12 16V6" />
<path d="M17 16v-4" />
</template>
<template v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</template>
<template v-else-if="name === 'search'">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</template>
<template v-else-if="name === 'sparkle'">
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
<path d="m6.5 6.5 2.5 2.5M15 15l2.5 2.5M17.5 6.5 15 9M9 15l-2.5 2.5" />
<circle cx="12" cy="12" r="2.2" />
</template>
<template v-else-if="name === 'bell'">
<path d="M6 9a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5H4.5S6 13 6 9Z" />
<path d="M10 19a2 2 0 0 0 4 0" />
</template>
<template v-else-if="name === 'calendar'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
</template>
<template v-else-if="name === 'calendar-plus'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M12 13v5M9.5 15.5h5" />
</template>
<template v-else-if="name === 'calendar-sun'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<circle cx="12" cy="15.2" r="2.1" />
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
</template>
<template v-else-if="name === 'calendar-edit'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
</template>
<template v-else-if="name === 'mail'">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" />
</template>
<template v-else-if="name === 'megaphone'">
<path d="M3 11v2a2 2 0 0 0 2 2h1l4 4V5L6 9H5a2 2 0 0 0-2 2Z" />
<path d="M14 8.5c2 .8 3.5 2.3 3.5 3.5s-1.5 2.7-3.5 3.5" />
<path d="M10 15v3a2 2 0 0 1-2 2" />
</template>
<template v-else-if="name === 'send'">
<path d="m4 12 16-8-6 16-2.5-6.5L4 12Z" />
</template>
<template v-else-if="name === 'fire'">
<path d="M12 3c2 3-1 4 1 7 1.5 2.2 4 2.5 4 6a5 5 0 1 1-10 0c0-3.5 2-5 3-7 1-2-1-4 2-6Z" />
</template>
<template v-else-if="name === 'warning'">
<path d="m12 4 9 16H3L12 4Z" />
<path d="M12 10v4" />
<path d="M12 17h.01" />
</template>
<template v-else-if="name === 'trend'">
<path d="M4 17 10 11l4 4 6-8" />
<path d="M15 7h5v5" />
</template>
<template v-else-if="name === 'phone'">
<path d="M8 3.5h2.5l1 4-2 1.2a12 12 0 0 0 5.8 5.8L16.5 12.5l4 1V16a2 2 0 0 1-2.2 2A16 16 0 0 1 4 6.2 2 2 0 0 1 6 4Z" />
</template>
<template v-else-if="name === 'trophy'">
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" />
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
</template>
<template v-else-if="name === 'chat'">
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
<path d="M8.5 11h7M8.5 14h4.5" />
</template>
<template v-else-if="name === 'dots'">
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'dots-v'">
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'document'">
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'download'">
<path d="M12 4.5v9" />
<path d="M8.5 10.5 12 14l3.5-3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'upload'">
<path d="M12 14.5v-9" />
<path d="M8.5 8.5 12 5l3.5 3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
</template>
<template v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4.2l2.8 1.8" />
</template>
<template v-else-if="name === 'user-plus'">
<circle cx="10" cy="8" r="3" />
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
<path d="M17 8v5M14.5 10.5h5" />
</template>
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'chevron-left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'chevrons-right'">
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
</template>
<template v-else-if="name === 'chevrons-left'">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'arrow-left'">
<path d="M19 12H5M12 19l-7-7 7-7" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
<template v-else-if="name === 'pin'">
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
<path d="M12 20v-5" />
</template>
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
<template v-else-if="name === 'funnel'">
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M9 18h6" />
<path d="M10 21h4" />
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
</template>
<template v-else-if="name === 'building'">
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
<path d="M4 21h16" />
</template>
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
</template>
<template v-else-if="name === 'flag'">
<path d="M6 21V4" />
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
</template>
<template v-else-if="name === 'refresh'">
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
<path d="M20 4v5h-5" />
</template>
<template v-else-if="name === 'x'">
<path d="M6 6 18 18M18 6 6 18" />
</template>
<template v-else-if="name === 'x-circle'">
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6M15 9l-6 6" />
</template>
<template v-else-if="name === 'mic'">
<rect x="9" y="3.5" width="6" height="10" rx="3" />
<path d="M7 11.5a5 5 0 0 0 10 0" />
<path d="M12 16.5V20M9 20h6" />
</template>
<template v-else-if="name === 'trash'">
<path d="M5 7h14" />
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
</template>
<template v-else-if="name === 'stop'">
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'dollar'">
<circle cx="12" cy="12" r="8.4" />
<path d="M12 6.8v10.4" />
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
<template v-else-if="name === 'layers'">
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
<path d="M4 12.2 12 16.4 20 12.2" />
<path d="M4 16.2 12 20.4 20 16.2" />
</template>
<template v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
<template v-else-if="name === 'edit'">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
</template>
<template v-else-if="name === 'grid'">
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
</template>
<template v-else-if="name === 'list'">
<path d="M8 6h12M8 12h12M8 18h12" />
<circle cx="4.5" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="18" r="1" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'eye'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
<path d="M4 20 20 4" />
</template>
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</template>
<template v-else-if="name === 'truck'">
<path d="M3 7.5h11V16H3z" />
<path d="M14 10h4.2l2.3 3.2V16H14z" />
<circle cx="7" cy="17.5" r="1.6" />
<circle cx="17" cy="17.5" r="1.6" />
<path d="M3 16h1.2M8.7 16H15" />
</template>
<template v-else-if="name === 'grip'">
<circle cx="9" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="17" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="17" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'apps-plus'">
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
</template>
<template v-else-if="name === 'crown'">
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
<path d="M5.2 18.6h13.6" />
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'gift'">
<rect x="4" y="11" width="16" height="9" rx="1.4" />
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
<path d="M12 6.8v13.2" />
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
</template>
<template v-else-if="name === 'medal'">
<circle cx="12" cy="9" r="5.2" />
<circle cx="12" cy="9" r="2.3" />
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
</template>
<template v-else-if="name === 'logout'">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</template>
<template v-else-if="name === 'pulse'">
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
</template>
<template v-else-if="name === 'sliders'">
<path d="M4 7h16M4 17h16" />
<path d="M8 4.5v5M16 14.5v5" />
</template>
<template v-else-if="name === 'camera'">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</template>
<template v-else-if="name === 'clip'">
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
</template>
</svg>
</template>
<style scoped>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
-76
View File
@@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getVuesaxSvg } from '@/icons/vuesax';
import { resolveVuesaxName } from '@/icons/name-map';
const props = withDefaults(
defineProps<{
/** کلید کوتاه vuesax (مثل add) یا نام UiIcon / Ant با map */
name: string;
size?: number | string;
/** منبع نگاشت نام؛ پیش‌فرض raw = کلید مستقیم پک */
map?: 'raw' | 'ui' | 'ant';
spin?: boolean;
title?: string;
}>(),
{
size: 20,
map: 'raw',
spin: false,
},
);
const resolved = computed(() => resolveVuesaxName(props.name, props.map));
const markup = computed(() => {
const svg = getVuesaxSvg(resolved.value);
if (!svg) return '';
// Strip fixed width/height so CSS size controls the box; keep viewBox.
return svg
.replace(/\swidth="[^"]*"/, '')
.replace(/\sheight="[^"]*"/, '')
.replace(
'<svg ',
`<svg width="${props.size}" height="${props.size}" style="width:${typeof props.size === 'number' ? props.size + 'px' : props.size};height:${typeof props.size === 'number' ? props.size + 'px' : props.size}" `,
);
});
</script>
<template>
<span
class="vs-icon"
:class="{ 'vs-icon--spin': spin }"
:style="{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}"
:title="title"
role="img"
aria-hidden="true"
v-html="markup"
/>
</template>
<style scoped>
.vs-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
flex-shrink: 0;
color: inherit;
}
.vs-icon :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.vs-icon--spin {
animation: vs-icon-spin 1s linear infinite;
}
@keyframes vs-icon-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -10,10 +10,8 @@ import type {
import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import { Flex as AFlex } from 'ant-design-vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
@@ -431,58 +429,34 @@ function openActivity(id: string) {
</script>
<template>
<div
class="kanban-wrap"
:style="{
flex: '1 1 auto',
minHeight: 0,
height: '100%',
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
<a-flex
v-if="initialLoading && !Object.keys(columns).length"
:gap="12"
:style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
>
<div
v-for="i in 4"
:key="`sk-col-${i}`"
:style="{ width: '280px', flex: '0 0 280px' }"
>
<CrmSkeletonCard :rows="6" :min-height="360" />
</div>
</a-flex>
<PipelineBoard
v-else
:stages="boardStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canManage"
:copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)"
@column-drag-end="draggingColumnId = null; dropStageId = null"
@add-stage="onAddStage"
@load-more="loadMore"
/>
</CrmSkeleton>
<div class="kanban-wrap">
<p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p>
<PipelineBoard
v-else
:stages="boardStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canManage"
:copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)"
@column-drag-end="draggingColumnId = null; dropStageId = null"
@add-stage="onAddStage"
@load-more="loadMore"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -506,4 +480,9 @@ function openActivity(id: string) {
display: flex;
flex-direction: column;
}
.muted {
margin: 0;
color: #64748b;
}
</style>
@@ -11,34 +11,12 @@ import type {
PersonDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Col as ACol,
Flex as AFlex,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Row as ARow,
Select as ASelect,
Space as ASpace,
Switch as ASwitch,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import {
CloseOutlined,
DownOutlined,
PlusOutlined,
UpOutlined,
UserOutlined,
} from '@/icons/ant-bridge';
const { Text: ATypographyText } = Typography;
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
fields: ActivityFieldDef[];
@@ -46,6 +24,8 @@ const props = defineProps<{
}>();
const model = defineModel<ActivityFieldValues>({ required: true });
const auth = useAuthStore();
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
const v = model.value[key];
@@ -66,20 +46,9 @@ function multi(key: string): string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
}
function fieldSpan(type: ActivityFieldDef['type']): number {
return ['textarea', 'checklist', 'participants', 'multiselect'].includes(type) ? 24 : 12;
}
function selectOptions(f: ActivityFieldDef) {
return (f.options ?? []).map((opt) => ({ value: opt, label: opt }));
}
function onNumberUpdate(key: string, value: number | string | null) {
if (value == null || value === '') {
set(key, null);
return;
}
set(key, Number(value));
function toggleMulti(key: string, opt: string) {
const list = multi(key);
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
}
/* ---------- چک‌لیست ---------- */
@@ -87,9 +56,7 @@ const checklistDraft = ref<Record<string, string>>({});
function checklist(key: string): ActivityChecklistItem[] {
const v = model.value[key];
return Array.isArray(v)
? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object')
: [];
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
}
function addChecklistItem(key: string) {
@@ -148,82 +115,39 @@ function addGuest(key: string) {
const personQuery = ref<Record<string, string>>({});
const personResults = ref<Record<string, PersonDto[]>>({});
const personSearching = ref<Record<string, boolean>>({});
const personOpenKey = ref('');
let personTimer: ReturnType<typeof setTimeout> | undefined;
let personSeq = 0;
function searchPeople(key: string, raw: string) {
personQuery.value = { ...personQuery.value, [key]: raw };
function searchPeople(key: string) {
personOpenKey.value = key;
if (personTimer) clearTimeout(personTimer);
const q = raw.trim();
const q = (personQuery.value[key] ?? '').trim();
if (!q) {
personResults.value = { ...personResults.value, [key]: [] };
personSearching.value = { ...personSearching.value, [key]: false };
return;
}
personSearching.value = { ...personSearching.value, [key]: true };
const seq = ++personSeq;
personTimer = setTimeout(async () => {
try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 8 },
});
if (seq === personSeq) {
personResults.value = { ...personResults.value, [key]: data.items };
}
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
} catch {
if (seq === personSeq) {
personResults.value = { ...personResults.value, [key]: [] };
}
} finally {
if (seq === personSeq) {
personSearching.value = { ...personSearching.value, [key]: false };
}
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
}
}, 280);
}
const personSelectOptions = computed(() => {
const map: Record<string, Array<{ value: string; label: string; company: string }>> = {};
for (const f of props.fields) {
if (f.type !== 'participants') continue;
const selectedIds = new Set(participants(f.key).people.map((p) => p.id));
map[f.key] = (personResults.value[f.key] ?? [])
.filter((p) => !selectedIds.has(p.id))
.map((p) => ({
value: p.id,
label: p.fullName,
company: p.company?.name ?? '',
}));
}
return map;
});
function onPersonPick(key: string, value: unknown) {
const id = value == null ? '' : String(value);
if (!id) return;
const hit =
(personResults.value[key] ?? []).find((p) => p.id === id) ??
null;
if (!hit) return;
function addPerson(key: string, p: PersonDto) {
const cur = participants(key);
if (!cur.people.some((x) => x.id === hit.id)) {
patchParticipants(key, { people: [...cur.people, { id: hit.id, name: hit.fullName }] });
if (!cur.people.some((x) => x.id === p.id)) {
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
}
personQuery.value = { ...personQuery.value, [key]: '' };
personResults.value = { ...personResults.value, [key]: [] };
}
function removePerson(key: string, id: string) {
patchParticipants(key, {
people: participants(key).people.filter((x) => x.id !== id),
});
}
function removeGuest(key: string, name: string) {
patchParticipants(key, {
guests: participants(key).guests.filter((x) => x !== name),
});
personOpenKey.value = '';
}
watch(
@@ -233,237 +157,523 @@ watch(
guestDraft.value = {};
personQuery.value = {};
personResults.value = {};
personSearching.value = {};
},
);
</script>
<template>
<a-row :gutter="[16, 0]">
<a-col
<div class="fields-grid">
<div
v-for="f in fields"
:key="f.key"
:xs="24"
:md="fieldSpan(f.type)"
class="cf"
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
>
<a-form-item :required="f.required">
<template #label>
{{ f.label }}
</template>
<label :for="`cf-${f.key}`">
{{ f.label }}
<i v-if="f.required">*</i>
</label>
<a-input
v-if="f.type === 'text' || f.type === 'url'"
:value="str(f.key)"
:type="f.type === 'url' ? 'url' : 'text'"
:dir="f.type === 'url' ? 'ltr' : undefined"
:placeholder="f.placeholder || ''"
:maxlength="1000"
style="width: 100%"
@update:value="(v: string) => set(f.key, v)"
<input
v-if="f.type === 'text' || f.type === 'url'"
:id="`cf-${f.key}`"
:type="f.type === 'url' ? 'url' : 'text'"
:dir="f.type === 'url' ? 'ltr' : undefined"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
maxlength="1000"
@input="set(f.key, ($event.target as HTMLInputElement).value)"
/>
<textarea
v-else-if="f.type === 'textarea'"
:id="`cf-${f.key}`"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
rows="3"
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
/>
<input
v-else-if="f.type === 'number'"
:id="`cf-${f.key}`"
type="number"
inputmode="decimal"
dir="ltr"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
@input="
set(
f.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
/>
<JalaliDateInput
v-else-if="f.type === 'date'"
:id="`cf-${f.key}`"
:model-value="str(f.key)"
@update:model-value="(v: string) => set(f.key, v || null)"
/>
<select
v-else-if="f.type === 'select'"
:id="`cf-${f.key}`"
:value="str(f.key)"
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
>
<option value="">— انتخاب کنید —</option>
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
</select>
<div v-else-if="f.type === 'multiselect'" class="chips">
<button
v-for="opt in f.options ?? []"
:key="opt"
type="button"
class="chip-opt"
:class="{ on: multi(f.key).includes(opt) }"
@click="toggleMulti(f.key, opt)"
>
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
{{ opt }}
</button>
</div>
<label v-else-if="f.type === 'checkbox'" class="switch">
<input
:id="`cf-${f.key}`"
type="checkbox"
:checked="get<boolean>(f.key, false) === true"
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
/>
<i />
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
</label>
<a-textarea
v-else-if="f.type === 'textarea'"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
:rows="3"
style="width: 100%"
@update:value="(v: string) => set(f.key, v)"
/>
<a-input-number
v-else-if="f.type === 'number'"
:value="str(f.key) === '' ? undefined : Number(str(f.key))"
:placeholder="f.placeholder || ''"
style="width: 100%"
@update:value="(v: number | string | null) => onNumberUpdate(f.key, v)"
/>
<JalaliDateInput
v-else-if="f.type === 'date'"
:model-value="str(f.key)"
style="width: 100%"
@update:model-value="(v: string) => set(f.key, v || null)"
/>
<a-select
v-else-if="f.type === 'select'"
:value="str(f.key) || undefined"
:options="selectOptions(f)"
allow-clear
placeholder="— انتخاب کنید —"
style="width: 100%"
@update:value="(v: unknown) => set(f.key, v == null || v === '' ? null : String(v))"
/>
<a-select
v-else-if="f.type === 'multiselect'"
mode="multiple"
:value="multi(f.key)"
:options="selectOptions(f)"
:placeholder="f.placeholder || 'انتخاب کنید'"
style="width: 100%"
@update:value="(v: unknown) => set(f.key, Array.isArray(v) ? v.map(String) : [])"
/>
<a-space v-else-if="f.type === 'checkbox'" :size="8">
<a-switch
:checked="get<boolean>(f.key, false) === true"
@update:checked="(v) => set(f.key, v === true)"
/>
<a-typography-text>
{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}
</a-typography-text>
</a-space>
<a-flex v-else-if="f.type === 'checklist'" vertical :gap="8">
<a-typography-text v-if="checklist(f.key).length" type="success">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
</a-typography-text>
<a-flex
v-for="(it, idx) in checklist(f.key)"
:key="it.id"
align="center"
:gap="8"
>
<a-checkbox
<div v-else-if="f.type === 'checklist'" class="checklist">
<p v-if="checklist(f.key).length" class="progress">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
</p>
<ul>
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
<input
type="checkbox"
:checked="it.done"
@update:checked="(v: boolean) => updateChecklistItem(f.key, it.id, { done: v })"
aria-label="انجام شد"
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
/>
<a-input
<input
class="item-text"
:value="it.text"
:maxlength="300"
:style="{
flex: 1,
textDecoration: it.done ? 'line-through' : undefined,
}"
@update:value="(v: string) => updateChecklistItem(f.key, it.id, { text: v })"
maxlength="300"
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
/>
<a-button
:disabled="idx === 0"
aria-label="بالا"
@click="moveChecklistItem(f.key, idx, -1)"
>
<template #icon><UpOutlined /></template>
</a-button>
<a-button
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
<button
type="button"
class="mini"
:disabled="idx === checklist(f.key).length - 1"
aria-label="پایین"
@click="moveChecklistItem(f.key, idx, 1)"
>
<template #icon><DownOutlined /></template>
</a-button>
<a-button
danger
aria-label="حذف"
@click="removeChecklistItem(f.key, it.id)"
>
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<a-space :size="8" style="width: 100%">
<a-input
:value="checklistDraft[f.key] ?? ''"
:maxlength="300"
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
style="width: 100%"
@update:value="(v: string) => (checklistDraft[f.key] = v)"
@pressEnter="addChecklistItem(f.key)"
/>
<a-button type="dashed" @click="addChecklistItem(f.key)">
<template #icon><PlusOutlined /></template>
افزودن
</a-button>
</a-space>
</a-flex>
▼
</button>
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
<UiIcon name="x" :size="13" />
</button>
</li>
</ul>
<div class="add-row">
<input
v-model="checklistDraft[f.key]"
maxlength="300"
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
@keydown.enter.prevent="addChecklistItem(f.key)"
/>
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
<a-flex v-else-if="f.type === 'participants'" vertical :gap="16">
<a-flex vertical :gap="4">
<a-typography-text type="secondary">
همکاران (فعالیت در لیست آن‌ها هم نمایش داده می‌شود)
</a-typography-text>
<UserMultiSelect
:model-value="participants(f.key).userIds"
:users="users"
add-label="افزودن همکار"
empty-label="همکاری انتخاب نشده"
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
/>
</a-flex>
<a-flex vertical :gap="8">
<a-typography-text type="secondary">اشخاص / مشتریان</a-typography-text>
<a-space v-if="participants(f.key).people.length" :size="6" wrap>
<a-tag
v-for="p in participants(f.key).people"
:key="p.id"
closable
color="cyan"
:bordered="false"
@close="removePerson(f.key, p.id)"
<div v-else-if="f.type === 'participants'" class="participants">
<div class="p-block">
<small>همکاران (فعالیت در لیست آن‌ها هم نمایش داده می‌شود)</small>
<UserMultiSelect
:model-value="participants(f.key).userIds"
:users="users"
:disabled="!canAssignOthers"
add-label="افزودن همکار"
empty-label="همکاری انتخاب نشده"
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
/>
</div>
<div class="p-block">
<small>اشخاص / مشتریان</small>
<div class="tags">
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
<UiIcon name="user" :size="12" />
{{ p.name }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
>
<UserOutlined />
{{ p.name }}
</a-tag>
</a-space>
<a-select
show-search
allow-clear
:filter-option="false"
:value="undefined"
:options="personSelectOptions[f.key] ?? []"
:not-found-content="
personSearching[f.key]
? 'در حال جستجو…'
: (personQuery[f.key] || '').trim()
? 'شخصی پیدا نشد.'
: 'نام یا موبایل را جستجو کنید'
"
×
</button>
</span>
</div>
<div class="person-search">
<input
v-model="personQuery[f.key]"
type="search"
placeholder="جستجوی نام یا موبایل شخص…"
style="width: 100%"
@search="(q: string) => searchPeople(f.key, q)"
@update:value="(v: unknown) => onPersonPick(f.key, v)"
>
<template #option="{ label, company }">
<a-flex vertical :gap="0">
<a-typography-text>{{ label }}</a-typography-text>
<a-typography-text v-if="company" type="secondary">{{ company }}</a-typography-text>
</a-flex>
</template>
</a-select>
</a-flex>
<a-flex vertical :gap="8">
<a-typography-text type="secondary">مهمانان دیگر (نام آزاد)</a-typography-text>
<a-space v-if="participants(f.key).guests.length" :size="6" wrap>
<a-tag
v-for="g in participants(f.key).guests"
:key="g"
closable
:bordered="false"
@close="removeGuest(f.key, g)"
@input="searchPeople(f.key)"
@focus="personOpenKey = f.key"
/>
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
<li v-for="p in personResults[f.key]" :key="p.id">
<button type="button" @click="addPerson(f.key, p)">
<b>{{ p.fullName }}</b>
<small v-if="p.company?.name">{{ p.company.name }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="p-block">
<small>مهمانان دیگر (نام آزاد)</small>
<div class="tags">
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
{{ g }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
>
{{ g }}
</a-tag>
</a-space>
<a-space :size="8" style="width: 100%">
<a-input
:value="guestDraft[f.key] ?? ''"
:maxlength="120"
placeholder="نام مهمان و Enter"
style="width: 100%"
@update:value="(v: string) => (guestDraft[f.key] = v)"
@pressEnter="addGuest(f.key)"
/>
<a-button type="dashed" @click="addGuest(f.key)">
<template #icon><PlusOutlined /></template>
افزودن
</a-button>
</a-space>
</a-flex>
</a-flex>
</a-form-item>
</a-col>
</a-row>
×
</button>
</span>
</div>
<div class="add-row">
<input
v-model="guestDraft[f.key]"
maxlength="120"
placeholder="نام مهمان و Enter"
@keydown.enter.prevent="addGuest(f.key)"
/>
<button type="button" class="add-btn" @click="addGuest(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.fields-grid {
display: grid;
gap: 0.85rem;
}
.cf {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.cf > label {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: var(--fs-xs, 0.78rem);
font-weight: 600;
}
.cf > label i {
color: var(--color-danger, #dc2626);
font-style: normal;
}
.cf input:not([type='checkbox']),
.cf select,
.cf textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.cf textarea {
min-height: 5rem;
padding: 0.6rem 0.85rem;
resize: vertical;
}
.chips,
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip-opt {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.75rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.chip-opt.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
}
.switch input {
display: none;
}
.switch i {
width: 44px;
height: 26px;
border-radius: 999px;
background: #e2e8f0;
position: relative;
}
.switch i::after {
content: '';
position: absolute;
inset-inline-start: 3px;
top: 3px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
transition: 0.18s;
}
.switch input:checked + i {
background: #14b8a6;
}
.switch input:checked + i::after {
inset-inline-start: 21px;
}
.checklist ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.checklist li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.checklist li input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: #14b8a6;
flex-shrink: 0;
}
.checklist .item-text {
flex: 1;
min-height: 2.2rem !important;
}
.checklist li.done .item-text {
text-decoration: line-through;
color: #94a3b8;
}
.progress {
margin: 0;
font-size: 0.78rem;
color: #0f766e;
font-weight: 600;
}
.mini {
width: 1.9rem;
height: 1.9rem;
flex-shrink: 0;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 0.65rem;
color: #64748b;
cursor: pointer;
}
.mini:disabled {
opacity: 0.35;
cursor: default;
}
.mini.danger {
color: #dc2626;
}
.add-row {
display: flex;
gap: 0.4rem;
margin-top: 0.35rem;
}
.add-row input {
flex: 1;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px dashed #5eead4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0 0.8rem;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.participants {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
border: 1px dashed #e2e8f0;
border-radius: var(--radius);
}
.p-block {
display: grid;
gap: 0.35rem;
}
.p-block > small {
color: #94a3b8;
font-size: 0.74rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
}
.tag.guest {
background: #f1f5f9;
color: #334155;
}
.tag button {
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.1rem;
}
.person-search {
position: relative;
}
.results {
position: absolute;
inset-inline: 0;
top: calc(100% + 4px);
z-index: 20;
list-style: none;
margin: 0;
padding: 0.3rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
max-height: 14rem;
overflow: auto;
}
.results button {
width: 100%;
display: grid;
gap: 0.1rem;
text-align: start;
border: 0;
background: none;
padding: 0.45rem 0.55rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.results button:hover {
background: #f8fafc;
}
.results b {
font-size: 0.86rem;
font-weight: 600;
}
.results small {
color: #94a3b8;
font-size: 0.74rem;
}
@media (min-width: 960px) {
.fields-grid {
grid-template-columns: 1fr 1fr;
}
.cf.wide {
grid-column: 1 / -1;
}
}
</style>
@@ -12,25 +12,10 @@ import {
ACTIVITY_STAGE_COLORS,
ACTIVITY_STAGE_KIND_LABELS,
} from '@mento/shared';
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
} from '@/icons/ant-bridge';
import {
Alert as AAlert,
Button as AButton,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
} from 'ant-design-vue';
import { Modal } from 'ant-design-vue';
import { api } from '@/lib/api';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
open: boolean;
@@ -77,19 +62,6 @@ function apiMessage(e: unknown, fallback: string) {
return fallback;
}
function confirmAsync(content: string, title = 'تأیید'): Promise<boolean> {
return new Promise((resolve) => {
Modal.confirm({
title,
content,
okText: 'ادامه',
cancelText: 'انصراف',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
}
function isDirty(stage: ActivityStageDto) {
const d = drafts.value[stage.id];
return Boolean(
@@ -106,9 +78,9 @@ async function save(stage: ActivityStageDto) {
}
if (
d.kind !== stage.kind &&
!(await confirmAsync(
!window.confirm(
'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟',
))
)
) {
return;
}
@@ -147,25 +119,23 @@ async function move(index: number, dir: -1 | 1) {
}
async function remove(stage: ActivityStageDto) {
Modal.confirm({
title: 'حذف ستون',
content: `ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busyId.value = stage.id;
error.value = '';
try {
await api.delete(`/activities/stages/${stage.id}`);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
busyId.value = '';
}
},
});
if (
!window.confirm(
`ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
)
) {
return;
}
busyId.value = stage.id;
error.value = '';
try {
await api.delete(`/activities/stages/${stage.id}`);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function create() {
@@ -186,163 +156,226 @@ async function create() {
busyId.value = '';
}
}
function dotColor(stage: ActivityStageDto) {
const color = drafts.value[stage.id]?.color ?? stage.color;
return PIPELINE_ACCENT[color].solid;
}
</script>
<template>
<a-modal
<UiModal
:open="open"
:title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'"
:footer="null"
@cancel="emit('close')"
@close="emit('close')"
>
<a-flex vertical gap="middle">
<a-typography-text type="secondary">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <strong>انجام‌شده</strong> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <strong>لغو شده</strong> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</a-typography-text>
<div class="manager">
<p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <b>لغو شده</b> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</p>
<a-alert v-if="error" type="error" :message="error" show-icon />
<p v-if="error" class="error">{{ error }}</p>
<a-flex vertical gap="small">
<a-row
<div class="rows">
<div
v-for="(stage, index) in stages"
:key="stage.id"
:gutter="[8, 8]"
align="middle"
class="row"
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }"
>
<a-col flex="none">
<span
aria-hidden="true"
:style="{
display: 'inline-block',
width: '0.65rem',
height: '0.65rem',
borderRadius: '999px',
background: dotColor(stage),
}"
/>
</a-col>
<a-col :xs="24" :sm="8">
<a-input
v-if="drafts[stage.id]"
v-model:value="drafts[stage.id]!.name"
:maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="save(stage)"
/>
</a-col>
<a-col :xs="12" :sm="6">
<a-select
v-if="drafts[stage.id]"
v-model:value="drafts[stage.id]!.kind"
aria-label="نوع ستون"
style="width: 100%"
<span class="dot" aria-hidden="true" />
<input
v-if="drafts[stage.id]"
v-model="drafts[stage.id]!.name"
class="name"
maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="save(stage)"
/>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.kind" aria-label="نوع ستون">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.color" aria-label="رنگ ستون">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<div class="row-actions">
<button
type="button"
class="icon"
:disabled="index === 0 || Boolean(busyId)"
title="انتقال به قبل"
aria-label="انتقال به قبل"
@click="move(index, -1)"
>
<a-select-option v-for="k in KINDS" :key="k" :value="k">
{{ ACTIVITY_STAGE_KIND_LABELS[k] }}
</a-select-option>
</a-select>
</a-col>
<a-col :xs="12" :sm="5">
<a-select
v-if="drafts[stage.id]"
v-model:value="drafts[stage.id]!.color"
aria-label="رنگ ستون"
style="width: 100%"
▲
</button>
<button
type="button"
class="icon"
:disabled="index === stages.length - 1 || Boolean(busyId)"
title="انتقال به بعد"
aria-label="انتقال به بعد"
@click="move(index, 1)"
>
<a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</a-select-option>
</a-select>
</a-col>
<a-col :xs="24" :sm="5">
<a-flex align="center" gap="small" wrap="wrap" justify="flex-end">
<a-button
type="text"
:disabled="index === 0 || Boolean(busyId)"
title="انتقال به قبل"
aria-label="انتقال به قبل"
@click="move(index, -1)"
>
<template #icon><ArrowUpOutlined /></template>
</a-button>
<a-button
type="text"
:disabled="index === stages.length - 1 || Boolean(busyId)"
title="انتقال به بعد"
aria-label="انتقال به بعد"
@click="move(index, 1)"
>
<template #icon><ArrowDownOutlined /></template>
</a-button>
<a-button
type="primary"
:disabled="!isDirty(stage) || Boolean(busyId)"
:loading="busyId === stage.id"
@click="save(stage)"
>
ذخیره
</a-button>
<a-button
type="text"
danger
:disabled="Boolean(busyId)"
title="حذف ستون"
aria-label="حذف ستون"
@click="remove(stage)"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-flex>
</a-col>
</a-row>
</a-flex>
▼
</button>
<UiButton
size="sm"
type="button"
:disabled="!isDirty(stage) || Boolean(busyId)"
:loading="busyId === stage.id"
@click="save(stage)"
>
ذخیره
</UiButton>
<button
type="button"
class="icon danger"
:disabled="Boolean(busyId)"
title="حذف ستون"
aria-label="حذف ستون"
@click="remove(stage)"
>
<UiIcon name="trash" :size="15" />
</button>
</div>
</div>
</div>
<a-divider />
<a-flex vertical gap="small" @submit.prevent="create">
<a-typography-text strong>ستون جدید</a-typography-text>
<a-row :gutter="[8, 8]" align="middle">
<a-col :xs="24" :sm="10">
<a-input
v-model:value="createForm.name"
:maxlength="80"
placeholder="مثلاً: منتظر پاسخ مشتری"
/>
</a-col>
<a-col :xs="12" :sm="5">
<a-select v-model:value="createForm.kind" aria-label="نوع ستون جدید" style="width: 100%">
<a-select-option v-for="k in KINDS" :key="k" :value="k">
{{ ACTIVITY_STAGE_KIND_LABELS[k] }}
</a-select-option>
</a-select>
</a-col>
<a-col :xs="12" :sm="5">
<a-select v-model:value="createForm.color" aria-label="رنگ ستون جدید" style="width: 100%">
<a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</a-select-option>
</a-select>
</a-col>
<a-col :xs="24" :sm="4">
<a-button
type="primary"
html-type="button"
block
:disabled="!createForm.name.trim() || Boolean(busyId)"
:loading="busyId === 'create'"
@click="create"
>
افزودن
</a-button>
</a-col>
</a-row>
</a-flex>
</a-flex>
</a-modal>
<form class="create" @submit.prevent="create">
<strong>ستون جدید</strong>
<div class="create-fields">
<input v-model="createForm.name" maxlength="80" placeholder="مثلاً: منتظر پاسخ مشتری" />
<select v-model="createForm.kind" aria-label="نوع ستون جدید">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-model="createForm.color" aria-label="رنگ ستون جدید">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<UiButton
variant="primary"
type="submit"
:disabled="!createForm.name.trim() || Boolean(busyId)"
:loading="busyId === 'create'"
>
افزودن
</UiButton>
</div>
</form>
</div>
</UiModal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.9rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.84rem;
line-height: 1.8;
}
.error {
margin: 0;
color: #b91c1c;
}
.rows {
display: grid;
gap: 0.5rem;
}
.row {
display: grid;
grid-template-columns: 0.7rem minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.dot {
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
background: var(--dot);
}
input,
select {
min-height: var(--control-h);
padding: 0 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-size: 0.86rem;
min-width: 0;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.icon {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: #475569;
font-size: 0.7rem;
cursor: pointer;
}
.icon:disabled {
opacity: 0.4;
cursor: default;
}
.icon.danger {
color: #dc2626;
}
.create {
display: grid;
gap: 0.5rem;
padding-top: 0.8rem;
border-top: 1px solid var(--line);
}
.create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 0.7rem minmax(0, 1fr) auto;
}
.row select {
grid-column: 2 / -1;
}
.row-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.create-fields {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import AutomationNode from '@/components/automations/AutomationNode.vue';
import {
AUTOMATION_ACTION_LABELS,
@@ -21,23 +22,6 @@ import {
type TeamDto,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { Button as AButton, Divider as ADivider, Space as ASpace, Tooltip as ATooltip } from 'ant-design-vue';
import {
BellOutlined,
DeleteOutlined,
DragOutlined,
ExpandOutlined,
FilterOutlined,
FlagOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
GiftOutlined,
PlusOutlined,
SelectOutlined,
ThunderboltOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@/icons/ant-bridge';
export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction }
@@ -58,15 +42,8 @@ const props = withDefaults(
form?: LeadFormDto | null;
users?: OrgUserDto[];
teams?: TeamDto[];
fullscreen?: boolean;
}>(),
{
chainMode: 'stop',
form: null,
users: () => [],
teams: () => [],
fullscreen: false,
},
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
);
const emit = defineEmits<{
@@ -75,7 +52,6 @@ const emit = defineEmits<{
remove: [id: string];
removeRange: [start: number, end: number];
addBranch: [];
'update:fullscreen': [value: boolean];
}>();
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
@@ -84,116 +60,6 @@ const actionCount = computed(
() => props.steps.filter((s) => !isAutomationCondition(s)).length,
);
type CanvasTool = 'select' | 'hand';
const Z_MIN = 0.4;
const Z_MAX = 2;
const Z_STEP = 0.1;
const tool = ref<CanvasTool>('select');
const zoom = ref(1);
const pan = ref({ x: 0, y: 0 });
const viewportRef = ref<HTMLElement | null>(null);
const panning = ref(false);
const panOrigin = ref({ x: 0, y: 0, panX: 0, panY: 0 });
const didPan = ref(false);
const zoomLabel = computed(() => `${toFaDigits(Math.round(zoom.value * 100))}٪`);
const worldStyle = computed(() => ({
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
}));
function clampZoom(v: number) {
return Math.min(Z_MAX, Math.max(Z_MIN, Math.round(v * 100) / 100));
}
function setTool(next: CanvasTool) {
tool.value = next;
addAt.value = null;
}
function zoomBy(delta: number) {
zoom.value = clampZoom(zoom.value + delta);
}
function resetView() {
zoom.value = 1;
pan.value = { x: 0, y: 0 };
}
function onWheel(e: WheelEvent) {
const el = viewportRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const prev = zoom.value;
const next = clampZoom(prev * (e.deltaY > 0 ? 0.9 : 1.1));
if (next === prev) return;
const wx = (mx - pan.value.x) / prev;
const wy = (my - pan.value.y) / prev;
pan.value = { x: mx - wx * next, y: my - wy * next };
zoom.value = next;
}
function onPointerDown(e: PointerEvent) {
const hand = tool.value === 'hand' || e.button === 1 || e.altKey;
if (!hand || e.button === 2) return;
e.preventDefault();
panning.value = true;
didPan.value = false;
panOrigin.value = {
x: e.clientX,
y: e.clientY,
panX: pan.value.x,
panY: pan.value.y,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!panning.value) return;
const dx = e.clientX - panOrigin.value.x;
const dy = e.clientY - panOrigin.value.y;
if (Math.abs(dx) + Math.abs(dy) > 3) didPan.value = true;
pan.value = {
x: panOrigin.value.panX + dx,
y: panOrigin.value.panY + dy,
};
}
function onPointerUp(e: PointerEvent) {
if (!panning.value) return;
panning.value = false;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
function onViewportClick() {
if (didPan.value) {
didPan.value = false;
return;
}
addAt.value = null;
}
function onDocPointerDown(e: PointerEvent) {
if (!addAt.value) return;
const target = e.target;
if (!(target instanceof Element)) return;
if (target.closest('.picker') || target.closest('.plus')) return;
addAt.value = null;
}
onMounted(() => {
document.addEventListener('pointerdown', onDocPointerDown, true);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown, true);
});
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
const steps = props.steps;
const { prefixEnd, branches } = splitAutomationBranches(steps);
@@ -285,10 +151,10 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده';
}
function actionIcon(action: AutomationAction): Component {
if (action === 'create_notification') return BellOutlined;
if (action === 'add_loyalty_points') return GiftOutlined;
return FlagOutlined;
function actionIcon(action: AutomationAction) {
if (action === 'create_notification') return 'bell';
if (action === 'add_loyalty_points') return 'gift';
return 'flag';
}
function stepTitle(step: AutomationStep) {
@@ -342,20 +208,13 @@ function stepHint(step: AutomationStep) {
}
function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value || tool.value === 'hand') return;
if (!canAdd.value) return;
addAt.value =
addAt.value?.key === item.key
? null
: { key: item.key, index: item.index, allow: item.allow };
}
function actionShort(action: AutomationAction) {
if (action === 'create_activity') return 'فعالیت';
if (action === 'create_notification') return 'اعلان / پیامک';
if (action === 'add_loyalty_points') return 'امتیاز باشگاه';
return AUTOMATION_ACTION_LABELS[action];
}
function pickAction(action: AutomationAction) {
if (!addAt.value) return;
emit('add', { kind: 'action', action }, addAt.value.index);
@@ -375,19 +234,7 @@ function removeBranch(lane: BranchLane) {
</script>
<template>
<div class="canvas-shell">
<div
ref="viewportRef"
class="canvas"
:class="{ hand: tool === 'hand', panning }"
@wheel.prevent="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@click.self="onViewportClick"
>
<div class="world" :style="worldStyle" :class="{ locked: tool === 'hand' }">
<div class="canvas" @click.self="addAt = null">
<div class="flow">
<button
class="node trigger"
@@ -396,7 +243,7 @@ function removeBranch(lane: BranchLane) {
@click="emit('select', 'trigger')"
>
<span class="icon">
<ThunderboltOutlined />
<UiIcon name="sparkle" :size="18" />
</span>
<span class="copy">
<span class="kind">اگر</span>
@@ -418,46 +265,8 @@ function removeBranch(lane: BranchLane) {
title="افزودن گره"
@click.stop="openAdd(item)"
>
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
<UiIcon name="plus" :size="item.end ? 16 : 14" />
</button>
<Transition name="auto-picker">
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
</span>
</button>
</template>
</div>
</Transition>
</div>
<AutomationNode
v-else
@@ -483,7 +292,7 @@ function removeBranch(lane: BranchLane) {
title="حذف این شاخه"
@click.stop="removeBranch(lane)"
>
<DeleteOutlined :style="{ fontSize: '12px' }" />
<UiIcon name="trash" :size="12" />
</button>
</div>
<template v-for="item in lane.items" :key="item.key">
@@ -499,46 +308,8 @@ function removeBranch(lane: BranchLane) {
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
@click.stop="openAdd(item)"
>
<PlusOutlined :style="{ fontSize: '14px' }" />
<UiIcon name="plus" :size="14" />
</button>
<Transition name="auto-picker">
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
</span>
</button>
</template>
</div>
</Transition>
</div>
<AutomationNode
v-else
@@ -559,146 +330,65 @@ function removeBranch(lane: BranchLane) {
:title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')"
>
<PlusOutlined :style="{ fontSize: '14px' }" />
<UiIcon name="plus" :size="14" />
شاخهٔ جدید
</button>
</div>
</template>
</div>
<div v-if="addAt" class="picker" @click.stop>
<p>
{{
addAt.allow === 'condition'
? 'شرط جدید با «و» به شرط‌های این شاخه اضافه می‌شود'
: 'گره بعدی را انتخاب کنید'
}}
</p>
<div class="picks">
<button
v-if="addAt.allow !== 'action'"
type="button"
class="condition"
@click="pickCondition"
>
<UiIcon name="filter" :size="16" />
شرط
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
:class="action"
@click="pickAction(action)"
>
<UiIcon :name="actionIcon(action)" :size="16" />
{{ AUTOMATION_ACTION_LABELS[action] }}
</button>
</template>
</div>
<button class="cancel" type="button" @click="addAt = null">انصراف</button>
</div>
<p v-if="!canAdd" class="cap">
حداکثر {{ toFaDigits(MAX_AUTOMATION_STEPS) }} گره در یک قانون
</p>
</div>
</div>
<div class="island" @pointerdown.stop @click.stop>
<a-space :size="4">
<a-tooltip title="انتخاب">
<a-button
type="text"
:class="{ on: tool === 'select' }"
@click="setTool('select')"
>
<template #icon><SelectOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="جابه‌جایی (دست)">
<a-button
type="text"
:class="{ on: tool === 'hand' }"
@click="setTool('hand')"
>
<template #icon><DragOutlined /></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" :style="{ height: '20px', margin: 0 }" />
<a-tooltip title="کوچک‌نمایی">
<a-button type="text" :disabled="zoom <= Z_MIN" @click="zoomBy(-Z_STEP)">
<template #icon><ZoomOutOutlined /></template>
</a-button>
</a-tooltip>
<a-button type="text" class="zoom-label" @click="resetView">
{{ zoomLabel }}
</a-button>
<a-tooltip title="بزرگ‌نمایی">
<a-button type="text" :disabled="zoom >= Z_MAX" @click="zoomBy(Z_STEP)">
<template #icon><ZoomInOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="بازنشانی نما">
<a-button type="text" @click="resetView">
<template #icon><ExpandOutlined /></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" :style="{ height: '20px', margin: 0 }" />
<a-tooltip :title="fullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<a-button
type="text"
:class="{ on: fullscreen }"
@click="emit('update:fullscreen', !fullscreen)"
>
<template #icon>
<FullscreenExitOutlined v-if="fullscreen" />
<FullscreenOutlined v-else />
</template>
</a-button>
</a-tooltip>
</a-space>
</div>
</div>
</template>
<style scoped>
.canvas-shell {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 14rem;
flex: 1 1 auto;
}
.canvas {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 22rem;
border-radius: var(--radius);
overflow: hidden;
overflow: auto;
background-color: #f3f6f5;
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
background-size: 16px 16px;
touch-action: none;
}
.canvas.hand {
cursor: grab;
}
.canvas.hand.panning {
cursor: grabbing;
}
.world {
transform-origin: 0 0;
width: max-content;
min-height: 100%;
padding: 1.1rem 1rem 4rem;
will-change: transform;
}
.world.locked {
pointer-events: none;
}
.island {
position: absolute;
z-index: 6;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: #fff;
border: 1px solid #d5ddd8;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
.island :deep(.ant-btn.on) {
color: #0f766e;
background: #ecfdf8;
}
.island :deep(.zoom-label) {
min-width: 3.2rem;
padding-inline: 4px;
font-variant-numeric: tabular-nums;
font-size: 12px;
font-weight: 700;
color: #334155;
padding: 1.6rem 1.1rem 1.25rem;
}
.flow {
@@ -706,7 +396,7 @@ function removeBranch(lane: BranchLane) {
flex-direction: row;
align-items: center;
min-width: max-content;
min-height: calc(100% - 2rem);
min-height: 16rem;
}
.node.trigger {
@@ -743,7 +433,6 @@ function removeBranch(lane: BranchLane) {
flex-shrink: 0;
color: #c2410c;
background: #ffedd5;
font-size: 18px;
}
.copy {
@@ -846,7 +535,7 @@ strong {
position: relative;
display: flex;
align-items: center;
padding-block: 0.7rem;
padding-block: 1.3rem;
}
.branch::before {
@@ -919,114 +608,66 @@ strong {
}
.picker {
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
z-index: 30;
width: 220px;
padding: 6px;
display: grid;
gap: 2px;
margin-top: 1rem;
background: #fff;
border: 1px solid #d5ddd8;
border-radius: 12px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.auto-picker-enter-active,
.auto-picker-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: top center;
will-change: opacity, transform;
pointer-events: none;
}
.auto-picker-enter-from,
.auto-picker-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(6px) scale(0.97);
}
.auto-picker-enter-to,
.auto-picker-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
.picker-head {
padding: 6px 8px 8px;
font-size: 11px;
font-weight: 700;
color: #94a3b8;
}
.pick {
border-radius: var(--radius);
padding: 0.85rem;
display: grid;
grid-template-columns: 32px 1fr;
gap: 0.65rem;
max-width: 40rem;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
}
.picker p {
margin: 0;
font-size: 0.82rem;
font-weight: 700;
color: #334155;
}
.picks {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.picks button {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
padding: 6px 8px;
text-align: start;
cursor: pointer;
gap: 0.4rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.45rem 0.75rem;
font: inherit;
color: inherit;
}
.pick:hover {
background: #f1f5f9;
}
.pick-ico {
width: 32px;
height: 32px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 15px;
background: #ecfdf8;
color: #0f766e;
}
.pick.condition .pick-ico {
background: #fff7ed;
color: #b45309;
}
.pick.create_notification .pick-ico {
background: #eff6ff;
color: #1d4ed8;
}
.pick.add_loyalty_points .pick-ico {
background: #f5f3ff;
color: #7c3aed;
}
.pick-copy {
display: grid;
gap: 1px;
min-width: 0;
}
.pick-copy strong {
font-size: 13px;
font-weight: 700;
font-size: 0.8rem;
cursor: pointer;
color: #0f172a;
}
.pick-copy small {
font-size: 11px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.picks .create_activity {
color: #0f766e;
}
.picks .create_notification {
color: #1d4ed8;
}
.picks .add_loyalty_points {
color: #7c3aed;
}
.picks .condition {
color: #b45309;
}
.cancel {
justify-self: start;
border: 0;
background: none;
color: #64748b;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.cap {
@@ -1039,10 +680,7 @@ strong {
@media (max-width: 700px) {
.canvas {
min-height: 0;
}
.world {
padding: 1rem 0.75rem 4rem;
padding: 1rem 0.75rem;
}
.flow,
@@ -1111,7 +749,7 @@ strong {
}
.picker {
width: min(220px, 70vw);
max-width: none;
}
}
</style>
@@ -31,24 +31,6 @@ import {
formConditionValues,
isAutomationCondition,
} from '@mento/shared';
import {
Alert as AAlert,
Checkbox as ACheckbox,
CheckboxGroup as ACheckboxGroup,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
RadioGroup as ARadioGroup,
Select as ASelect,
Textarea as ATextarea,
Typography,
theme,
} from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = defineProps<{
selected: 'trigger' | string;
@@ -142,87 +124,6 @@ function toggleUser(step: AutomationStep, id: string, on: boolean) {
step.assigneeUserIds = [...cur];
}
function onUserCheck(step: AutomationStep, id: string, e: { target: { checked: boolean } }) {
toggleUser(step, id, e.target.checked);
}
function onConditionFieldChange(v: unknown) {
if (typeof v === 'string' && selectedStep.value) {
setConditionField(selectedStep.value, v as AutomationConditionField);
}
}
function onConditionKeyChange(v: unknown) {
if (typeof v === 'string' && selectedStep.value) setConditionKey(selectedStep.value, v);
}
function onActionChange(v: unknown) {
if (typeof v === 'string' && selectedStep.value) {
setAction(selectedStep.value, v as AutomationAction);
}
}
function onDueUnitChange(v: unknown) {
if (!selectedStep.value || typeof v !== 'string') return;
setDue(selectedStep.value, v as DueUnit, dueAmountOf(selectedStep.value) || 1);
}
function onDueAmountChange(v: string | number | null) {
if (!selectedStep.value) return;
setDue(selectedStep.value, dueUnitOf(selectedStep.value), Number(v ?? 1));
}
function onFormOptionValuesChange(vals: Array<string | number | boolean>) {
if (!selectedStep.value) return;
const next = vals.map(String);
const order = conditionFormField.value?.options ?? [];
next.sort((a, b) => order.indexOf(a) - order.indexOf(b));
selectedStep.value.conditionValues = next;
selectedStep.value.conditionValue = next[0] ?? '';
}
const dealAmountValue = computed({
get: () =>
typeof selectedStep.value?.conditionValue === 'number'
? selectedStep.value.conditionValue
: Number(selectedStep.value?.conditionValue ?? 0),
set: (v: number | null) => {
if (selectedStep.value) selectedStep.value.conditionValue = Number(v ?? 0);
},
});
const formTextValue = computed({
get: () => String(selectedStep.value?.conditionValue ?? ''),
set: (v: string) => {
if (selectedStep.value) selectedStep.value.conditionValue = v;
},
});
function nullableId(value: string | null | undefined) {
return value ?? '';
}
function setNullableId(
key: 'activityTypeDefId' | 'assigneeId' | 'assigneeTeamId' | 'notifyUserId',
v: unknown,
) {
if (!selectedStep.value) return;
selectedStep.value[key] = typeof v === 'string' && v ? v : null;
}
function onActivityTypeDefChange(v: unknown) {
setNullableId('activityTypeDefId', v);
}
function onAssigneeIdChange(v: unknown) {
setNullableId('assigneeId', v);
}
function onAssigneeTeamIdChange(v: unknown) {
setNullableId('assigneeTeamId', v);
}
function onNotifyUserIdChange(v: unknown) {
setNullableId('notifyUserId', v);
}
function setConditionKey(step: AutomationStep, key: string) {
step.conditionKey = key || null;
const field = formFields.value.find((f) => f.key === key);
@@ -230,6 +131,15 @@ function setConditionKey(step: AutomationStep, key: string) {
step.conditionValues = step.conditionValue ? [step.conditionValue] : [];
}
function toggleConditionValue(step: AutomationStep, option: string, on: boolean) {
const cur = formConditionValues(step).filter((v) => v !== option);
if (on) cur.push(option);
const order = conditionFormField.value?.options ?? [];
cur.sort((a, b) => order.indexOf(a) - order.indexOf(b));
step.conditionValues = cur;
step.conditionValue = cur[0] ?? '';
}
function multiValueHint(op: AutomationConditionOp | undefined) {
if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینه‌ها انتخاب شده باشد.';
if (op === 'not_contains') return 'هیچ‌کدام از این گزینه‌ها نباید انتخاب شده باشد.';
@@ -252,94 +162,6 @@ const ticketPriorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'urgent', label: 'فوری' },
];
const triggerOptions = AUTOMATION_TRIGGER_GROUPS.map((group) => ({
label: group.label,
options: group.items.map((t) => ({
value: t,
label: AUTOMATION_TRIGGER_LABELS[t],
})),
}));
const formOptions = computed(() => [
{ value: '', label: 'همه فرم‌ها' },
...props.forms.map((f) => ({ value: f.id, label: f.name })),
]);
const sourceIdSelect = computed({
get: () => sourceId.value ?? '',
set: (v: string) => {
sourceId.value = v || null;
},
});
const chainModeOptions = [
{ value: 'branches' as const, label: AUTOMATION_CHAIN_MODE_LABELS.branches },
{ value: 'stop' as const, label: AUTOMATION_CHAIN_MODE_LABELS.stop },
];
const conditionFieldOptions = computed(() =>
conditionFields.value.map((f) => ({
value: f,
label: AUTOMATION_CONDITION_FIELD_LABELS[f],
})),
);
const conditionOpOptions = computed(() =>
conditionOps.value.map((op) => ({
value: op,
label: AUTOMATION_CONDITION_OP_LABELS[op],
})),
);
const formFieldOptions = computed(() =>
formFields.value.map((f) => ({ value: f.key, label: f.label })),
);
const actionOptions = AUTOMATION_ACTIONS.map((a) => ({
value: a,
label: AUTOMATION_ACTION_LABELS[a],
}));
const activityTypeOptions = activityTypes.map((t) => ({
value: t.value,
label: t.label,
}));
const activityTypeDefOptions = computed(() => [
{ value: '', label: '— بدون نوع سفارشی —' },
...props.activityTypeDefs.map((t) => ({ value: t.id, label: t.name })),
]);
const assignModeOptions = computed(() =>
assignModes.value.map((m) => ({
value: m,
label: AUTOMATION_ASSIGN_MODE_LABELS[m],
})),
);
const userOptions = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
);
const teamOptions = computed(() =>
props.teams.map((t) => ({
value: t.id,
label: `${t.name} (${t.memberCount} نفر)`,
})),
);
const notifyUserOptions = computed(() => [
{ value: '', label: 'مسئول معامله / تیکت' },
...userOptions.value,
]);
const notifyChannelOptions = AUTOMATION_NOTIFY_CHANNELS.map((ch) => ({
value: ch,
label: AUTOMATION_NOTIFY_CHANNEL_LABELS[ch],
}));
const dueUnitOptions = dueUnits.map((u) => ({ value: u.value, label: u.label }));
function setAction(step: AutomationStep, action: AutomationAction) {
step.kind = 'action';
step.action = action;
@@ -380,273 +202,440 @@ watch(
</script>
<template>
<a-form layout="vertical" :style="{ marginBottom: 0 }">
<aside class="inspector">
<template v-if="selected === 'trigger'">
<a-form-item label="اگر" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select v-model:value="trigger" :options="triggerOptions" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="trigger === 'lead_submitted'"
label="فرم"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select v-model:value="sourceIdSelect" :options="formOptions" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="advanced || chainMode === 'branches'"
label="رفتار شرط‌ها"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select v-model:value="chainMode" :options="chainModeOptions" style="width: 100%" />
</a-form-item>
<a-alert
v-if="trigger === 'lead_submitted' && !sourceId"
type="info"
show-icon
message="برای شرط روی پاسخ فرم، یک فرم مشخص انتخاب کنید."
/>
<h2>گره محرک</h2>
<p class="hint">وقتی این اتفاق بیفتد، زنجیره از راست به چپ اجرا می‌شود.</p>
<label>
اگر
<select v-model="trigger">
<optgroup
v-for="group in AUTOMATION_TRIGGER_GROUPS"
:key="group.label"
:label="group.label"
>
<option v-for="t in group.items" :key="t" :value="t">
{{ AUTOMATION_TRIGGER_LABELS[t] }}
</option>
</optgroup>
</select>
</label>
<label v-if="trigger === 'lead_submitted'">
فرم
<select v-model="sourceId">
<option :value="null">همه فرم‌ها</option>
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</label>
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p>
<label v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها
<select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
</select>
</label>
<p v-if="chainMode === 'branches'" class="hint">
شاخه‌ها روی بوم موازی نمایش داده می‌شوند و هر کدام جدا بررسی می‌شود. شرط‌های یک شاخه با «و»
ترکیب می‌شوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا می‌شود. برای شاخهٔ تازه از
دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
</p>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p>
</template>
<template v-else-if="selectedStep && isCondition">
<a-form-item label="فیلد" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
<h2>گره شرط</h2>
<p class="hint">
{{
chainMode === 'branches'
? 'اگر شرط برقرار نباشد، فقط عمل‌های این شاخه (تا شرط بعدی) اجرا نمی‌شوند.'
: 'اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند.'
}}
</p>
<label>
فیلد
<select
:value="selectedStep.conditionField"
:options="conditionFieldOptions"
style="width: 100%"
@change="onConditionFieldChange"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.conditionField === 'form_field' && selectedForm"
label="فیلد فرم"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="selectedStep.conditionKey ?? undefined"
:options="formFieldOptions"
placeholder="انتخاب فیلد…"
style="width: 100%"
@change="onConditionKeyChange"
/>
</a-form-item>
<a-form-item label="مقایسه" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.conditionOp"
:options="conditionOpOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.conditionField === 'deal_amount'"
label="مبلغ"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-input-number v-model:value="dealAmountValue" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="selectedStep.conditionField === 'ticket_priority'"
label="اولویت"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
v-model:value="selectedStep.conditionValue"
:options="ticketPriorities"
style="width: 100%"
/>
</a-form-item>
<a-alert
v-if="selectedStep.conditionField === 'form_field' && !selectedForm"
type="warning"
show-icon
message="ابتدا در گره محرک یک فرم مشخص انتخاب کنید."
/>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<a-form-item
v-if="conditionFormField?.options?.length"
label="گزینه‌ها"
:style="{ marginBottom: `${token.marginSM}px` }"
@change="
setConditionField(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationConditionField,
)
"
>
<a-checkbox-group
:value="formConditionValues(selectedStep)"
:options="conditionFormField.options.map((o) => ({ value: o, label: o }))"
@change="onFormOptionValuesChange"
/>
<a-typography-text
v-if="formConditionValues(selectedStep).length > 1"
type="secondary"
:style="{ display: 'block', marginTop: `${token.marginXXS}px` }"
<option v-for="f in conditionFields" :key="f" :value="f">
{{ AUTOMATION_CONDITION_FIELD_LABELS[f] }}
</option>
</select>
</label>
<template v-if="selectedStep.conditionField === 'form_field'">
<p v-if="!selectedForm" class="hint warn">
ابتدا در گره محرک یک فرم مشخص انتخاب کنید.
</p>
<label v-else>
فیلد فرم
<select
:value="selectedStep.conditionKey ?? ''"
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>انتخاب فیلد…</option>
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option>
</select>
</label>
</template>
<label>
مقایسه
<select v-model="selectedStep.conditionOp">
<option v-for="op in conditionOps" :key="op" :value="op">
{{ AUTOMATION_CONDITION_OP_LABELS[op] }}
</option>
</select>
</label>
<label v-if="selectedStep.conditionField === 'deal_amount'">
مبلغ
<input v-model.number="selectedStep.conditionValue" type="number" min="0" />
</label>
<label v-if="selectedStep.conditionField === 'ticket_priority'">
اولویت
<select v-model="selectedStep.conditionValue">
<option v-for="p in ticketPriorities" :key="p.value" :value="p.value">
{{ p.label }}
</option>
</select>
</label>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<div v-if="conditionFormField?.options?.length" class="user-picks">
<p class="field-label">گزینه‌ها (یک یا چند مورد)</p>
<label v-for="o in conditionFormField.options" :key="o" class="ch">
<input
type="checkbox"
:checked="formConditionValues(selectedStep).includes(o)"
@change="toggleConditionValue(selectedStep, o, ($event.target as HTMLInputElement).checked)"
/>
{{ o }}
</label>
<p v-if="formConditionValues(selectedStep).length > 1" class="hint">
{{ multiValueHint(selectedStep.conditionOp) }}
</a-typography-text>
</a-form-item>
<a-form-item v-else label="مقدار" :style="{ marginBottom: `${token.marginSM}px` }">
<a-input v-model:value="formTextValue" :maxlength="200" />
</a-form-item>
</p>
</div>
<label v-else>
مقدار
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
</label>
</template>
</template>
<template v-else-if="selectedStep">
<a-form-item label="آنگاه" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
<h2>گره عمل</h2>
<p class="hint">بعد از محرک (و شرط‌ها)، این کار به ترتیب انجام می‌شود.</p>
<label>
آنگاه
<select
:value="selectedStep.action"
:options="actionOptions"
style="width: 100%"
@change="onActionChange"
/>
</a-form-item>
<a-form-item label="عنوان" :style="{ marginBottom: `${token.marginSM}px` }">
<a-input
v-model:value="selectedStep.title"
:maxlength="200"
placeholder="اختیاری"
/>
</a-form-item>
<a-form-item label="توضیح / متن" :style="{ marginBottom: `${token.marginSM}px` }">
<a-textarea
v-model:value="selectedStep.body"
:rows="2"
:maxlength="1000"
placeholder="اختیاری"
/>
</a-form-item>
@change="
setAction(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationAction,
)
"
>
<option v-for="a in AUTOMATION_ACTIONS" :key="a" :value="a">
{{ AUTOMATION_ACTION_LABELS[a] }}
</option>
</select>
</label>
<template v-if="selectedStep.action === 'create_activity'">
<a-form-item label="نوع فعالیت" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.activityType"
:options="activityTypeOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="activityTypeDefs.length"
label="نوع سفارشی"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="nullableId(selectedStep.activityTypeDefId)"
:options="activityTypeDefOptions"
style="width: 100%"
@change="onActivityTypeDefChange"
/>
</a-form-item>
<a-form-item label="تخصیص به" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.assignMode"
:options="assignModeOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.assignMode === 'user'"
label="کاربر"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="nullableId(selectedStep.assigneeId)"
:options="userOptions"
placeholder="انتخاب کاربر…"
style="width: 100%"
@change="onAssigneeIdChange"
/>
</a-form-item>
<a-form-item
v-else-if="selectedStep.assignMode === 'team_round_robin'"
label="تیم"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="nullableId(selectedStep.assigneeTeamId)"
:options="teamOptions"
placeholder="انتخاب تیم…"
style="width: 100%"
@change="onAssigneeTeamIdChange"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.assignMode === 'users_round_robin'"
label="کاربران"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-flex vertical :gap="token.marginXXS">
<a-checkbox
v-for="u in users"
:key="u.id"
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
@change="onUserCheck(selectedStep, u.id, $event)"
>
{{ u.fullName }}
</a-checkbox>
</a-flex>
</a-form-item>
<a-form-item label="موعد" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
:value="dueUnitOf(selectedStep)"
:options="dueUnitOptions"
style="width: 100%"
@change="onDueUnitChange"
/>
</a-form-item>
<a-form-item
v-if="dueUnitOf(selectedStep) !== 'now'"
label="تعداد"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-input-number
:min="1"
:value="dueAmountOf(selectedStep)"
style="width: 100%"
@change="onDueAmountChange"
/>
</a-form-item>
<a-alert
v-if="selectedStep.assignMode === 'team_round_robin' && !teams.length"
type="warning"
show-icon
message="هنوز تیمی تعریف نشده."
<label>
عنوان
<input
v-model="selectedStep.title"
type="text"
maxlength="200"
placeholder="اختیاری"
/>
</label>
<label>
توضیح / متن پیام
<textarea
v-model="selectedStep.body"
rows="3"
maxlength="1000"
placeholder="اختیاری"
/>
</label>
<label v-if="selectedStep.action === 'create_activity'">
نوع فعالیت
<select v-model="selectedStep.activityType">
<option v-for="t in activityTypes" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</label>
<template v-if="selectedStep.action === 'create_activity'">
<label v-if="activityTypeDefs.length">
نوع سفارشی (اختیاری)
<select v-model="selectedStep.activityTypeDefId">
<option :value="null">— بدون نوع سفارشی —</option>
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</label>
<label>
تخصیص به
<select v-model="selectedStep.assignMode">
<option v-for="m in assignModes" :key="m" :value="m">
{{ AUTOMATION_ASSIGN_MODE_LABELS[m] }}
</option>
</select>
</label>
<label v-if="selectedStep.assignMode === 'user'">
کاربر
<select v-model="selectedStep.assigneeId">
<option :value="null" disabled>انتخاب کاربر…</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
</label>
<template v-else-if="selectedStep.assignMode === 'team_round_robin'">
<label>
تیم
<select v-model="selectedStep.assigneeTeamId">
<option :value="null" disabled>انتخاب تیم…</option>
<option v-for="t in teams" :key="t.id" :value="t.id">
{{ t.name }} ({{ t.memberCount }} نفر)
</option>
</select>
</label>
<p v-if="!teams.length" class="hint warn">
هنوز تیمی تعریف نشده؛ از «تنظیمات ← تیم‌ها و سطح دسترسی» بسازید.
</p>
</template>
<div v-else-if="selectedStep.assignMode === 'users_round_robin'" class="user-picks">
<p class="field-label">کاربران</p>
<label v-for="u in users" :key="u.id" class="ch">
<input
type="checkbox"
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
@change="toggleUser(selectedStep, u.id, ($event.target as HTMLInputElement).checked)"
/>
{{ u.fullName }}
</label>
</div>
<p
v-if="selectedStep.assignMode === 'team_round_robin' || selectedStep.assignMode === 'users_round_robin'"
class="hint"
>
هر بار نفر بعدی به نوبت انتخاب می‌شود تا کارها مساوی تقسیم شوند.
</p>
<div class="due">
<label>
موعد
<select
:value="dueUnitOf(selectedStep)"
@change="
setDue(
selectedStep,
($event.target as HTMLSelectElement).value as DueUnit,
dueAmountOf(selectedStep) || 1,
)
"
>
<option v-for="u in dueUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
</select>
</label>
<label v-if="dueUnitOf(selectedStep) !== 'now'">
تعداد
<input
type="number"
min="1"
:value="dueAmountOf(selectedStep)"
@input="
setDue(
selectedStep,
dueUnitOf(selectedStep),
Number(($event.target as HTMLInputElement).value),
)
"
/>
</label>
</div>
</template>
<p v-if="selectedStep.action === 'create_activity' || selectedStep.action === 'create_notification'" class="hint">
در عنوان و متن می‌توانید از
<code v-pre>{{title}}</code>، <code v-pre>{{mobile}}</code>، <code v-pre>{{form}}</code> و
<code v-pre>{{field.کلید}}</code> استفاده کنید.
<template v-if="formFields.length">
کلیدها:
<span v-for="(f, i) in formFields" :key="f.key">
<code>{{ f.key }}</code> ({{ f.label }})<template v-if="i < formFields.length - 1">، </template>
</span>
</template>
</p>
<template v-if="selectedStep.action === 'create_notification'">
<a-form-item label="نحوه ارسال" :style="{ marginBottom: `${token.marginSM}px` }">
<a-radio-group
v-model:value="selectedStep.notifyChannel"
:options="notifyChannelOptions"
/>
</a-form-item>
<a-form-item label="گیرنده" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.notifyTarget"
:options="[
{ value: 'staff', label: 'کاربر تیم' },
{ value: 'person', label: 'مخاطب معامله / تیکت' },
]"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.notifyTarget !== 'person'"
label="کاربر"
:style="{ marginBottom: `${token.marginSM}px` }"
<p class="field-label">نحوه ارسال</p>
<div class="channels">
<label
v-for="ch in AUTOMATION_NOTIFY_CHANNELS"
:key="ch"
class="ch"
>
<input
v-model="selectedStep.notifyChannel"
type="radio"
:value="ch"
/>
{{ AUTOMATION_NOTIFY_CHANNEL_LABELS[ch] }}
</label>
</div>
<label>
گیرنده
<select v-model="selectedStep.notifyTarget">
<option value="staff">کاربر تیم</option>
<option value="person">مخاطب معامله / تیکت</option>
</select>
</label>
<label v-if="selectedStep.notifyTarget !== 'person'">
کاربر
<select v-model="selectedStep.notifyUserId">
<option :value="null">مسئول معامله / تیکت</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</label>
<p v-if="selectedStep.notifyTarget === 'person'" class="hint">
پیامک به موبایل مخاطب ارسال می‌شود. ناتیفیکیشن داخلی برای مخاطب نیست.
</p>
<p
v-else-if="selectedStep.notifyChannel === 'sms' || selectedStep.notifyChannel === 'both'"
class="hint"
>
<a-select
:value="nullableId(selectedStep.notifyUserId)"
:options="notifyUserOptions"
style="width: 100%"
@change="onNotifyUserIdChange"
/>
</a-form-item>
پیامک به موبایل همان کاربر ارسال می‌شود.
</p>
</template>
<a-form-item
v-if="selectedStep.action === 'add_loyalty_points'"
label="امتیاز"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-input-number v-model:value="selectedStep.points" :min="1" style="width: 100%" />
</a-form-item>
<label v-if="selectedStep.action === 'add_loyalty_points'">
امتیاز
<input v-model.number="selectedStep.points" type="number" min="1" />
</label>
</template>
<a-typography-text v-else type="secondary">یک گره را از بوم انتخاب کنید.</a-typography-text>
</a-form>
<p v-else class="hint">یک گره را از بوم انتخاب کنید.</p>
</aside>
</template>
<style scoped>
.inspector {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
gap: 0.75rem;
padding: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-width: 0;
}
h2 {
margin: 0;
font-size: 0.98rem;
font-weight: 600;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.82rem;
line-height: 1.6;
}
label,
.field-label {
display: grid;
gap: 0.35rem;
font-size: 0.84rem;
color: #64748b;
min-width: 0;
}
.field-label {
margin: 0;
font-weight: 700;
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
color: #0f172a;
background: #fff;
min-width: 0;
max-width: 100%;
text-overflow: ellipsis;
}
.channels {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.85rem;
}
.ch {
display: inline-flex !important;
flex-direction: row;
align-items: center;
gap: 0.32rem;
color: #334155;
cursor: pointer;
white-space: nowrap;
}
.ch input {
accent-color: #14b8a6;
width: 14px;
height: 14px;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.hint code {
font-size: 0.76rem;
background: #f1f5f9;
border-radius: 4px;
padding: 0 0.25rem;
direction: ltr;
unicode-bidi: embed;
}
.user-picks {
display: grid;
gap: 0.4rem;
max-height: 12rem;
overflow: auto;
}
.due {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
align-items: end;
}
.due input {
width: 5.5rem;
}
</style>
@@ -1,12 +1,6 @@
<script setup lang="ts">
import { computed, type Component } from 'vue';
import {
BellOutlined,
DeleteOutlined,
FilterOutlined,
FlagOutlined,
GiftOutlined,
} from '@/icons/ant-bridge';
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{
@@ -21,11 +15,11 @@ const emit = defineEmits<{ select: []; remove: [] }>();
const isCondition = computed(() => isAutomationCondition(props.step));
const icon = computed((): Component => {
if (isCondition.value) return FilterOutlined;
if (props.step.action === 'create_notification') return BellOutlined;
if (props.step.action === 'add_loyalty_points') return GiftOutlined;
return FlagOutlined;
const icon = computed(() => {
if (isCondition.value) return 'filter';
if (props.step.action === 'create_notification') return 'bell';
if (props.step.action === 'add_loyalty_points') return 'gift';
return 'flag';
});
const kindClass = computed(() =>
@@ -42,7 +36,7 @@ const kindClass = computed(() =>
@click="emit('select')"
>
<span class="icon">
<component :is="icon" />
<UiIcon :name="icon" :size="18" />
</span>
<span class="copy">
<span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span>
@@ -57,7 +51,7 @@ const kindClass = computed(() =>
title="حذف"
@click.stop="emit('remove')"
>
<DeleteOutlined />
<UiIcon name="trash" :size="14" />
</button>
</div>
</template>
@@ -131,7 +125,6 @@ const kindClass = computed(() =>
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
font-size: 18px;
}
.node.create_notification .icon {
@@ -191,7 +184,6 @@ em {
cursor: pointer;
opacity: 0;
pointer-events: none;
font-size: 14px;
}
.node-wrap:hover .trash,
@@ -1,81 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardActivityTypeDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
items: HomeDashboardActivityTypeDto[];
}>();
const rows = computed(() => props.items);
const hasData = computed(() => rows.value.some((r) => r.count > 0));
const total = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params;
const point = rows.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.label}<br/>${toFaDigits(point.count)} فعالیت باز`;
},
},
xAxis: {
type: 'category',
data: rows.value.map((r) => r.label),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#94a3b8',
fontFamily: 'IRANSans, Tahoma, sans-serif',
fontSize: 11,
},
},
yAxis: {
type: 'value',
minInterval: 1,
...(hasData.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false },
},
series: [
{
type: 'bar',
data: rows.value.map((r) => r.count),
barMaxWidth: 36,
itemStyle: {
color: '#14b8a6',
borderRadius: [8, 8, 0, 0],
},
label: {
show: true,
position: 'top',
color: '#0f172a',
fontWeight: 700,
fontFamily: 'IRANSans, Tahoma, sans-serif',
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
},
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">ترکیب فعالیت‌های باز</h3>
<span v-if="hasData" class="text-xs text-slate-400">{{ toFaDigits(total) }} مورد</span>
</div>
<a-empty v-if="!hasData" description="فعالیت بازی نیست" />
<div v-else class="h-56">
<UiChart :option="option" height="224px" />
</div>
</section>
</template>
@@ -2,30 +2,13 @@
import { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
import { LinkOutlined } from '@/icons/ant-bridge';
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
const props = defineProps<{
askQuery: string;
@@ -43,53 +26,11 @@ const emit = defineEmits<{
notice: [value: { error: string; done: string }];
}>();
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } = Typography;
const { token } = theme.useToken();
const AFormItem = AForm.Item;
const ATextarea = AInput.TextArea;
type AnswerMetric = { label: string; value: string };
const answerText = computed(() => rewriteGregorianDates(props.answer || '').trim());
/** وقتی پاسخ شبیه «برچسب: مقدار» است، به‌صورت کارت متریک نشان بده */
const answerMetrics = computed((): AnswerMetric[] | null => {
const text = answerText.value;
if (!text) return null;
const linePairs = text
.split(/\n+/)
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const m = line.match(/^([^::]+)\s*[::]\s*(.+)$/);
return m ? { label: m[1].trim(), value: m[2].trim() } : null;
});
if (linePairs.length >= 1 && linePairs.every(Boolean)) {
return linePairs as AnswerMetric[];
}
const inline = [...text.matchAll(/([^::\n]+?)\s*[::]\s*([۰-۹0-9٪%.,+\-]+)/g)].map((m) => ({
label: m[1].trim(),
value: m[2].trim(),
}));
return inline.length >= 2 ? inline : null;
});
const shortcuts = [
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
{ label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' },
{ label: 'پیگیری امروز', prompt: 'مهم‌ترین پیگیری امروز چیست؟' },
{ label: 'خلاصه فروش', prompt: 'خلاصه وضعیت فروش را بده' },
];
const activityTypeOptions = [
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'task', label: 'کار' },
{ value: 'note', label: 'یادداشت' },
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
{ label: 'فعالیت‌های عقب‌افتاده چیست؟', icon: 'calendar' as const },
{ label: 'مهم‌ترین پیگیری امروز چیست؟', icon: 'send' as const },
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
];
function sourceTo(source: AskMentoSourceDto) {
@@ -104,6 +45,13 @@ function pick(label: string) {
emit('ask', label);
}
const ACTIVITY_LABEL: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const voiceBuf = ref('');
const voiceBusy = ref(false);
const voiceError = ref('');
@@ -112,13 +60,6 @@ const draft = ref<VoiceCommandDraft | null>(null);
const confirming = ref(false);
const answerOpen = ref(false);
const draftOpen = computed({
get: () => Boolean(draft.value && draft.value.intent !== 'none'),
set: (open: boolean) => {
if (!open) cancelDraft();
},
});
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -331,43 +272,12 @@ watch(
</script>
<template>
<section id="home-ai-assistant" class="home-ai">
<div class="home-ai__pattern" aria-hidden="true" />
<div class="home-ai__orb home-ai__orb--a" aria-hidden="true" />
<div class="home-ai__orb home-ai__orb--b" aria-hidden="true" />
<header class="home-ai__head">
<div class="home-ai__identity">
<div class="home-ai__avatar">
<MentoAiAvatar :size="42" :show-bubble="false" />
</div>
<div class="home-ai__titles min-w-0">
<h3>{{ brand.aiNameFa }}</h3>
<p>همراه هوشمند کسب‌وکار شما</p>
</div>
</div>
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
</header>
<div class="home-ai__tip">
<template v-if="answer">{{ answer }}</template>
<template v-else-if="error">{{ error }}</template>
<template v-else>
<strong>پیشنهاد هوشمند امروز:</strong>
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
</template>
</div>
<div class="home-ai__composer">
<a-input
:value="askQuery"
:placeholder="brand.askPlaceholderFa"
:bordered="false"
:disabled="asking || voiceBusy || confirming"
class="home-ai__input"
@update:value="emit('update:askQuery', $event)"
@press-enter="onTypedSubmit"
/>
<article id="home-ai-assistant" class="panel">
<header class="panel-head">
<h2>
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
{{ brand.aiNameFa }}
</h2>
<VoiceToTextButton
variant="toolbar"
hide-error
@@ -376,526 +286,322 @@ watch(
@recording="onVoiceRecording"
@error="voiceError = $event"
/>
<a-button
type="primary"
:disabled="asking || voiceBusy || confirming"
:loading="asking || voiceBusy"
aria-label="ارسال"
@click="onTypedSubmit"
</header>
<p class="ai-lead">چه کمکی می‌تونم بکنم؟</p>
<div class="ai-shortcuts">
<button
v-for="item in shortcuts"
:key="item.label"
class="ai-chip"
type="button"
:disabled="asking"
@click="pick(item.label)"
>
بپرس
</a-button>
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
<span>{{ item.label }}</span>
</button>
</div>
<div class="home-ai__chips">
<span class="home-ai__chips-label">پیشنهادهای سریع</span>
<div class="home-ai__chip-list">
<button
v-for="item in shortcuts"
:key="item.label"
type="button"
class="home-ai__chip"
:disabled="asking"
@click="pick(item.prompt)"
>
{{ item.label }}
</button>
</div>
<div v-if="voiceBusy" class="ai-answer"><p>در حال فهمیدن دستور…</p></div>
<div v-else-if="confirming" class="ai-answer"><p>در حال ثبت…</p></div>
<div v-else-if="voiceDone" class="ai-answer"><p>{{ voiceDone }}</p></div>
<p v-else-if="voiceError" class="ai-error">{{ voiceError }}</p>
<p v-else-if="error" class="ai-error">{{ error }}</p>
<div v-else-if="asking" class="ai-answer">
<p>در حال پاسخ…</p>
</div>
<div v-if="voiceBusy || confirming || voiceDone || voiceError" class="home-ai__status">
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
</div>
</section>
<a-modal
v-model:open="draftOpen"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
destroy-on-close
@cancel="cancelDraft"
>
<a-typography-paragraph v-if="draft">
{{ rewriteGregorianDates(draft.summary) }}
</a-typography-paragraph>
<a-typography-text type="secondary">قبل از تأیید می‌توانید متن را اصلاح کنید.</a-typography-text>
<a-form v-if="draft?.person" layout="vertical" style="margin-top: 16px">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام">
<a-input v-model:value="draft.person.firstName" @update:value="onPersonNameEdit" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام خانوادگی">
<a-input v-model:value="draft.person.lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-input v-model:value="draft.person.mobile" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن">
<a-input v-model:value="draft.person.phone" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل">
<a-input v-model:value="draft.person.email" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="وب‌سایت">
<a-input v-model:value="draft.person.website" dir="ltr" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="معرف">
<a-input v-model:value="draft.person.referrer" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سمت">
<a-input v-model:value="draft.person.position" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="draft.person.notes" :rows="2" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-form v-else-if="draft?.activity" layout="vertical" style="margin-top: 16px">
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان">
<a-input v-model:value="draft.activity.title" @update:value="onActivityTitleEdit" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نوع">
<a-select
v-model:value="draft.activity.type"
:options="activityTypeOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="زمان">
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="مشتری">
<a-input
v-model:value="draft.activity.personName"
@update:value="onActivityPersonEdit"
@blur="rematchActivityPerson"
/>
</a-form-item>
<a-alert
v-if="draft.activity.personName && !draft.activity.personId"
type="warning"
show-icon
message="در فهرست مشتریان پرونده‌ای با این نام پیدا نشد. فعالیت ذخیره می‌شود، ولی داخل پروندهٔ آن شخص دیده نمی‌شود."
/>
</a-col>
<a-col :span="24">
<a-form-item label="توضیح">
<a-textarea v-model:value="draft.activity.description" :rows="2" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-alert
v-if="draft?.missing.length"
type="error"
show-icon
:message="`ناقص است: ${draft.missing.join('، ')}`"
style="margin-top: 12px"
<form class="ai-input" @submit.prevent="onTypedSubmit">
<input
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking || voiceBusy || confirming"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
<UiIcon name="send" :size="15" />
</button>
</form>
<template #footer>
<a-button :disabled="confirming" @click="cancelDraft">انصراف</a-button>
<a-button
type="primary"
:disabled="!draft?.canConfirm || confirming"
:loading="confirming"
@click="confirmDraft"
>
تأیید و انجام
</a-button>
</template>
</a-modal>
<a-modal
v-model:open="answerOpen"
:width="560"
centered
destroy-on-close
:body-style="{ paddingTop: `${token.paddingSM}px` }"
@cancel="closeAnswer"
<UiModal
:open="Boolean(draft && draft.intent !== 'none')"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft"
>
<template #title>
<a-flex align="center" :gap="token.marginSM">
<div
:style="{
width: '44px',
height: '44px',
borderRadius: '999px',
display: 'grid',
placeItems: 'center',
background: `linear-gradient(145deg, ${token.colorSuccessBg}, ${token.colorBgContainer})`,
border: `1px solid ${token.colorBorderSecondary}`,
overflow: 'hidden',
flexShrink: 0,
}"
>
<MentoAiAvatar :size="36" :show-bubble="false" />
</div>
<a-flex vertical :gap="0">
<a-typography-text strong :style="{ fontSize: `${token.fontSizeLG}px`, lineHeight: 1.3 }">
پاسخ دستیار
</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ brand.aiNameFa }}
</a-typography-text>
</a-flex>
</a-flex>
</template>
<a-flex vertical :gap="token.margin">
<a-row v-if="answerMetrics?.length" :gutter="[token.marginSM, token.marginSM]">
<a-col
v-for="(metric, idx) in answerMetrics"
:key="`${metric.label}-${idx}`"
:xs="24"
:sm="answerMetrics.length === 1 ? 24 : 12"
>
<a-card
:bordered="false"
:style="{
background: token.colorFillAlter,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: `inset 0 0 0 1px ${token.colorBorderSecondary}`,
}"
>
<a-flex vertical :gap="token.paddingXXS">
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ metric.label }}
</a-typography-text>
<a-typography-title
:level="3"
:style="{
margin: 0,
color: token.colorPrimary,
fontVariantNumeric: 'tabular-nums',
}"
>
{{ metric.value }}
</a-typography-title>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-card
v-else
:bordered="false"
:style="{
background: token.colorFillAlter,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: `inset 0 0 0 1px ${token.colorBorderSecondary}`,
}"
>
<a-typography-paragraph
:style="{
marginBottom: 0,
whiteSpace: 'pre-wrap',
lineHeight: 1.85,
color: token.colorText,
}"
>
{{ answerText }}
</a-typography-paragraph>
</a-card>
<template v-if="sources?.length">
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
<a-flex vertical :gap="token.paddingXS">
<a-flex align="center" :gap="token.paddingXXS">
<LinkOutlined />
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
منابع مرتبط
</a-typography-text>
</a-flex>
<a-space wrap :size="[8, 8]">
<RouterLink
v-for="source in sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
@click="closeAnswer"
>
<a-tag
color="processing"
:bordered="false"
:style="{ cursor: 'pointer', borderRadius: `${token.borderRadiusSM}px` }"
>
{{ source.label }}
</a-tag>
</RouterLink>
</a-space>
</a-flex>
</template>
</a-flex>
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p>
<p class="edit-hint">قبل از تأیید می‌توانید متن را اصلاح کنید.</p>
<div v-if="draft?.person" class="modal-fields">
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label>
<label>نام خانوادگی <input v-model="draft.person.lastName" /></label>
<label>موبایل <input v-model="draft.person.mobile" dir="ltr" /></label>
<label>تلفن <input v-model="draft.person.phone" dir="ltr" /></label>
<label>ایمیل <input v-model="draft.person.email" dir="ltr" /></label>
<label>وب‌سایت <input v-model="draft.person.website" dir="ltr" /></label>
<label>معرف <input v-model="draft.person.referrer" /></label>
<label>سمت <input v-model="draft.person.position" /></label>
<label>یادداشت <textarea v-model="draft.person.notes" rows="2" /></label>
</div>
<div v-else-if="draft?.activity" class="modal-fields">
<label>عنوان <input v-model="draft.activity.title" @input="onActivityTitleEdit" /></label>
<label>
نوع
<select v-model="draft.activity.type">
<option v-for="(label, key) in ACTIVITY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<label>
زمان
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
</label>
<label>
مشتری
<input v-model="draft.activity.personName" @input="onActivityPersonEdit" @change="rematchActivityPerson" />
</label>
<p v-if="draft.activity.personName && !draft.activity.personId" class="warn">
در فهرست مشتریان پرونده‌ای با این نام پیدا نشد. فعالیت ذخیره می‌شود، ولی داخل پروندهٔ آن شخص دیده نمی‌شود.
</p>
<label>توضیح <textarea v-model="draft.activity.description" rows="2" /></label>
</div>
<p v-if="draft?.missing.length" class="ai-error">ناقص است: {{ draft.missing.join('، ') }}</p>
<template #footer>
<a-flex justify="end" :gap="token.paddingXS">
<a-button type="primary" @click="closeAnswer">متوجه شدم</a-button>
</a-flex>
<UiButton variant="ghost" :disabled="confirming" @click="cancelDraft">انصراف</UiButton>
<UiButton variant="primary" :disabled="!draft?.canConfirm || confirming" :loading="confirming" @click="confirmDraft">
تأیید و انجام
</UiButton>
</template>
</a-modal>
</UiModal>
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer">
<p class="modal-lead answer-body">{{ rewriteGregorianDates(answer || '') }}</p>
<div v-if="sources?.length" class="sources">
<RouterLink
v-for="source in sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
@click="closeAnswer"
>
{{ source.label }}
</RouterLink>
</div>
<template #footer>
<UiButton variant="primary" @click="closeAnswer">بستن</UiButton>
</template>
</UiModal>
</article>
</template>
<style scoped>
.home-ai {
--home-ai-h: 330px;
position: relative;
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
box-sizing: border-box;
width: 100%;
height: var(--home-ai-h);
min-height: var(--home-ai-h);
max-height: var(--home-ai-h);
padding: 0.85rem;
overflow: hidden;
border-radius: 1rem;
border: 1px solid rgb(226 232 240 / 0.9);
background:
linear-gradient(165deg, #ffffff 0%, #f8fffc 48%, #ffffff 100%);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
.home-ai__pattern,
.home-ai__orb {
pointer-events: none;
position: absolute;
}
.home-ai__pattern {
inset: 0;
opacity: 0.55;
background-image:
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.18) 1px, transparent 0),
linear-gradient(
125deg,
rgba(45, 212, 191, 0.06) 0%,
transparent 42%,
rgba(16, 185, 129, 0.05) 100%
);
background-size:
16px 16px,
auto;
mask-image: linear-gradient(140deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2));
}
.home-ai__orb {
border-radius: 999px;
filter: blur(28px);
}
.home-ai__orb--a {
top: -2.5rem;
inset-inline-end: -1.5rem;
width: 7rem;
height: 7rem;
background: rgb(52 211 153 / 0.22);
}
.home-ai__orb--b {
bottom: -2rem;
inset-inline-start: -1.25rem;
width: 6rem;
height: 6rem;
background: rgb(45 212 191 / 0.16);
}
.home-ai__head,
.home-ai__identity,
.home-ai__composer,
.home-ai__chips,
.home-ai__chip-list {
position: relative;
z-index: 1;
.panel-head {
margin-bottom: 0.55rem;
display: flex;
align-items: center;
}
.home-ai__head {
justify-content: space-between;
gap: 0.75rem;
flex-shrink: 0;
gap: 0.5rem;
}
.home-ai__identity {
gap: 0.7rem;
min-width: 0;
.modal-lead {
margin: 0 0 0.85rem;
color: #334155;
font-size: 0.9rem;
line-height: 1.7;
}
.home-ai__avatar {
flex-shrink: 0;
width: 2.75rem;
height: 2.75rem;
display: grid;
place-items: center;
border-radius: 0.9rem;
background:
radial-gradient(circle at 35% 25%, rgba(255, 255, 255, 0.9), transparent 55%),
linear-gradient(145deg, #ecfdf5 0%, #d1fae5 100%);
border: 1px solid rgb(167 243 208 / 0.8);
box-shadow: 0 4px 12px rgb(16 185 129 / 0.12);
overflow: hidden;
.answer-body {
white-space: pre-wrap;
}
.home-ai__avatar :deep(.mento-bot) {
width: 42px;
}
.home-ai__avatar :deep(.bot-img) {
filter: drop-shadow(0 4px 10px rgba(34, 211, 238, 0.28));
}
.home-ai__titles {
min-width: 0;
}
.home-ai__titles h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 800;
color: #0f172a;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.home-ai__titles p {
margin: 0.1rem 0 0;
font-size: 0.72rem;
.edit-hint {
margin: -0.35rem 0 0.75rem;
color: #64748b;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.home-ai__tip {
position: relative;
z-index: 1;
flex: 1 1 auto;
min-height: 0;
padding: 0.75rem 0.85rem;
border-radius: 0.85rem;
border: 1px solid rgb(167 243 208 / 0.55);
background: rgb(236 253 245 / 0.55);
color: #475569;
font-size: 0.78rem;
line-height: 1.65;
overflow: auto;
}
.home-ai__tip strong {
color: #0f172a;
.modal-fields {
display: grid;
gap: 0.55rem;
}
.modal-fields label {
display: grid;
gap: 0.25rem;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
}
.home-ai__composer {
flex-shrink: 0;
gap: 0.45rem;
padding: 0.3rem 0.35rem 0.3rem 0.55rem;
border-radius: 0.85rem;
.modal-fields input,
.modal-fields select,
.modal-fields textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #e2e8f0;
background: rgb(248 250 252 / 0.92);
}
.home-ai__input {
flex: 1 1 auto;
min-width: 0;
background: transparent !important;
}
.home-ai__chips {
flex-shrink: 0;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.45rem 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid #f1f5f9;
}
.home-ai__chips-label {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
}
.home-ai__chip-list {
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
}
.home-ai__chip {
appearance: none;
margin: 0;
border: 1px solid rgb(167 243 208 / 0.7);
background: rgb(236 253 245 / 0.75);
color: #047857;
border-radius: 0.45rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
font-size: 0.88rem;
font-weight: 500;
padding: 0.5rem 0.65rem;
}
.home-ai__chip:hover:not(:disabled) {
background: #d1fae5;
border-color: #6ee7b7;
.modal-fields textarea {
resize: vertical;
}
.home-ai__chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.home-ai__status {
position: absolute;
z-index: 2;
inset-inline: 0.85rem;
bottom: 0.7rem;
max-height: 4.5rem;
overflow: auto;
}
.home-ai__status :deep(.ant-alert) {
padding-block: 0.35rem;
.warn {
display: inline-block;
margin-inline-start: 0.35rem;
color: #b45309;
font-size: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.92rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
}
.spark {
color: #eab308;
display: grid;
}
.ai-lead {
margin: 0 0 0.75rem;
padding: 0.55rem 0.75rem;
width: fit-content;
max-width: 100%;
background: #d1fae5;
color: #115e59;
font-size: 0.8rem;
font-weight: 600;
border-radius: 14px 14px 4px 14px;
}
.ai-shortcuts {
display: grid;
gap: 0.4rem;
margin-bottom: 0.8rem;
flex: 1;
}
.ai-chip {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.58rem 0.7rem;
border-radius: var(--radius);
background: #fff;
border: 1px solid #eef2f7;
color: #334155;
font-size: 0.76rem;
text-align: start;
font: inherit;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.ai-chip:hover:not(:disabled) {
background: #f0fdfa;
border-color: #99f6e4;
}
.ai-chip:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-ico {
width: 26px;
height: 26px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
flex-shrink: 0;
}
.ai-input {
display: flex;
gap: 0.4rem;
margin-top: auto;
}
.ai-input input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
}
.ai-input button {
width: 40px;
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.ai-input button:disabled {
opacity: 0.55;
}
.ai-answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
}
.ai-answer p {
margin: 0;
color: #134e4a;
font-size: 0.82rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.sources a {
font-size: 0.72rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.15rem 0.5rem;
}
.ai-error {
margin: 0 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
}
</style>
@@ -1,200 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { Component } from 'vue';
import {
AlertOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
LeftOutlined,
} from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardAlertDto } from '@mento/shared';
const props = defineProps<{
alerts: HomeDashboardAlertDto[];
}>();
type Tone = HomeDashboardAlertDto['tone'];
type AlertRow = {
key: string;
tone: Tone;
title: string;
subtitle?: string;
link: string;
};
const TONE_ORDER: Record<Tone, number> = {
urgent: 0,
warning: 1,
info: 2,
success: 3,
};
const rows = computed<AlertRow[]>(() =>
[...(props.alerts ?? [])]
.map((a) => {
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
const title = parts[0]?.trim() || a.text;
const subtitle = parts.slice(1).join(' ').trim() || undefined;
return {
key: a.key,
tone: a.tone,
title,
subtitle,
link: a.link || '/',
};
})
.sort((a, b) => TONE_ORDER[a.tone] - TONE_ORDER[b.tone]),
);
const urgentCount = computed(() => rows.value.filter((r) => r.tone === 'urgent').length);
const warningCount = computed(() => rows.value.filter((r) => r.tone === 'warning').length);
function toneIcon(tone: Tone): Component {
if (tone === 'urgent') return ExclamationCircleOutlined;
if (tone === 'warning') return AlertOutlined;
if (tone === 'success') return CheckCircleOutlined;
return InfoCircleOutlined;
}
function toneLabel(tone: Tone) {
if (tone === 'urgent') return 'فوری';
if (tone === 'warning') return 'هشدار';
if (tone === 'success') return 'موفق';
return 'اطلاع';
}
function actionLabel(tone: Tone) {
if (tone === 'urgent') return 'اقدام';
if (tone === 'warning') return 'بررسی';
return 'مشاهده';
}
function rowClass(tone: Tone) {
if (tone === 'urgent') return 'border-rose-200/80 bg-rose-50/50 hover:bg-rose-50';
if (tone === 'warning') return 'border-amber-200/80 bg-amber-50/50 hover:bg-amber-50';
if (tone === 'success') return 'border-emerald-200/70 bg-emerald-50/40 hover:bg-emerald-50/80';
return 'border-slate-200/80 bg-slate-50/70 hover:bg-slate-100/80';
}
function barClass(tone: Tone) {
if (tone === 'urgent') return 'bg-rose-500';
if (tone === 'warning') return 'bg-amber-500';
if (tone === 'success') return 'bg-emerald-500';
return 'bg-blue-500';
}
function iconWrapClass(tone: Tone) {
if (tone === 'urgent') return 'bg-rose-100 text-rose-600';
if (tone === 'warning') return 'bg-amber-100 text-amber-600';
if (tone === 'success') return 'bg-emerald-100 text-emerald-600';
return 'bg-blue-100 text-blue-600';
}
function badgeClass(tone: Tone) {
if (tone === 'urgent') return 'bg-rose-100 text-rose-700';
if (tone === 'warning') return 'bg-amber-100 text-amber-700';
if (tone === 'success') return 'bg-emerald-100 text-emerald-700';
return 'bg-blue-100 text-blue-700';
}
function actionClass(tone: Tone) {
if (tone === 'urgent') return 'text-rose-600 group-hover:text-rose-700';
if (tone === 'warning') return 'text-amber-600 group-hover:text-amber-700';
if (tone === 'success') return 'text-emerald-600 group-hover:text-emerald-700';
return 'text-blue-600 group-hover:text-blue-700';
}
</script>
<template>
<section
v-if="rows.length"
class="bg-white rounded-2xl border border-slate-200/80 p-5 shadow-sm flex flex-col h-[330px]"
>
<header class="flex items-center justify-between gap-3 pb-3 mb-3 border-b border-slate-100 shrink-0">
<div class="flex items-center gap-2.5 min-w-0">
<span class="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75" />
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-rose-500" />
</span>
<div class="min-w-0">
<h3 class="m-0 font-bold text-slate-800 text-base leading-6">نیازمند توجه</h3>
<p class="m-0 mt-0.5 text-xs text-slate-400 truncate">
موارد با اولویت بالاتر اول آمده‌اند
</p>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<span
v-if="urgentCount"
class="text-[11px] font-bold bg-rose-50 text-rose-600 border border-rose-200/70 px-2 py-0.5 rounded-full"
>
{{ toFaDigits(urgentCount) }} فوری
</span>
<span
v-if="warningCount"
class="text-[11px] font-bold bg-amber-50 text-amber-700 border border-amber-200/70 px-2 py-0.5 rounded-full"
>
{{ toFaDigits(warningCount) }} هشدار
</span>
<span class="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2 py-0.5 rounded-full">
{{ toFaDigits(rows.length) }} مورد
</span>
</div>
</header>
<div class="flex-1 overflow-y-auto space-y-2 pe-1 min-h-0">
<RouterLink
v-for="item in rows"
:key="item.key"
:to="item.link"
class="group relative overflow-hidden rounded-xl border px-3 py-2.5 flex items-center gap-3 transition-all"
:class="rowClass(item.tone)"
>
<span
class="absolute inset-y-0 right-0 w-1 rounded-s-full"
:class="barClass(item.tone)"
aria-hidden="true"
/>
<span
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-sm"
:class="iconWrapClass(item.tone)"
aria-hidden="true"
>
<component :is="toneIcon(item.tone)" />
</span>
<div class="min-w-0 flex-1">
<h4 class="m-0 font-bold text-[13px] text-slate-800 leading-5 truncate">
{{ item.title }}
</h4>
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0"
:class="badgeClass(item.tone)"
>
{{ toneLabel(item.tone) }}
</span>
<span v-if="item.subtitle" class="text-[11px] text-slate-500 truncate">
{{ item.subtitle }}
</span>
</div>
</div>
<div
class="shrink-0 flex items-center gap-1 text-[11px] font-semibold"
:class="actionClass(item.tone)"
>
<span class="opacity-0 group-hover:opacity-100 transition-opacity hidden sm:inline">
{{ actionLabel(item.tone) }}
</span>
<LeftOutlined class="text-slate-400 group-hover:-translate-x-0.5 transition-transform" />
</div>
</RouterLink>
</div>
</section>
</template>
@@ -1,18 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { GiftOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Flex as AFlex,
List as AList,
Modal as AModal,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
type BirthdayItem = {
@@ -36,10 +28,6 @@ type BirthdayMonth = {
const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null);
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>();
@@ -66,78 +54,63 @@ onMounted(async () => {
</script>
<template>
<section v-if="data" class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm">
<a-flex align="center" wrap="wrap" gap="middle" justify="space-between">
<a-flex align="center" gap="middle">
<a-typography-text type="secondary"><GiftOutlined /></a-typography-text>
<div>
<a-typography-text strong>تولدهای {{ data.monthName }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند
</a-typography-text>
</div>
</a-flex>
<section v-if="data" class="birthdays">
<div class="head">
<span class="ico"><UiIcon name="gift" :size="18" /></span>
<div>
<strong>تولدهای {{ data.monthName }}</strong>
<p>{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند</p>
</div>
</div>
<a-space wrap>
<RouterLink
v-for="person in data.preview"
:key="person.id"
:to="{ name: 'person-detail', params: { id: person.id } }"
<div class="chips">
<RouterLink
v-for="person in data.preview"
:key="person.id"
class="chip"
:title="person.fullName"
:to="{ name: 'person-detail', params: { id: person.id } }"
>
<b>{{ toFaDigits(person.day) }}</b>
<span>{{ person.fullName }}</span>
</RouterLink>
</div>
<button type="button" class="more" @click="listOpen = true">
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
</button>
<UiModal :open="listOpen" :title="`تولدهای ${data.monthName}`" @close="listOpen = false">
<div class="days">
<section
v-for="[day, people] in groups"
:key="day"
class="day"
:class="{ past: day < data.today, today: day === data.today }"
>
<a-tag>
{{ toFaDigits(person.day) }} — {{ person.fullName }}
</a-tag>
</RouterLink>
<a-button type="link" @click="listOpen = true">
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
</a-button>
</a-space>
</a-flex>
<a-modal
v-model:open="listOpen"
:title="`تولدهای ${data.monthName}`"
:footer="null"
destroy-on-close
>
<a-list item-layout="vertical" :data-source="groups">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag v-if="item[0] < data!.today">گذشته</a-tag>
<a-tag v-else-if="item[0] === data!.today" color="processing">امروز</a-tag>
</template>
<a-list-item-meta>
<template #title>
{{ toFaDigits(item[0]) }} {{ data!.monthName }}
</template>
</a-list-item-meta>
<a-list :data-source="item[1]">
<template #renderItem="{ item: person }">
<a-list-item>
<RouterLink
:to="{ name: 'person-detail', params: { id: person.id } }"
@click="listOpen = false"
>
{{ person.fullName }}
</RouterLink>
<template #actions>
<a-button
type="link"
:disabled="!person.mobile"
@click="openSms(person)"
>
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-list-item>
</template>
</a-list>
</a-modal>
<h3>
<b>{{ toFaDigits(day) }}</b>
<span>{{ data.monthName }}</span>
<em v-if="day < data.today">گذشته</em>
<em v-else-if="day === data.today">امروز</em>
</h3>
<ul>
<li v-for="person in people" :key="person.id">
<RouterLink :to="{ name: 'person-detail', params: { id: person.id } }" @click="listOpen = false">
{{ person.fullName }}
</RouterLink>
<button
type="button"
class="sms"
:disabled="!person.mobile"
@click="openSms(person)"
>
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
</button>
</li>
</ul>
</section>
</div>
</UiModal>
<SmsComposeModal
nest
@@ -153,3 +126,289 @@ onMounted(async () => {
/>
</section>
</template>
<style scoped>
.birthdays {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
max-width: 100%;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.35), transparent 42%),
linear-gradient(100deg, var(--accent-subtle) 0%, #fff 58%, #fff7f4 100%);
border: 1px solid color-mix(in srgb, var(--accent) 22%, #fff);
border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(15, 118, 110, 0.05);
padding: 0.55rem 0.75rem;
}
.head {
display: flex;
align-items: center;
gap: 0.55rem;
flex-shrink: 0;
}
.ico {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
background: #fff;
color: #e11d48;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, #fff);
}
.head strong {
display: block;
color: var(--accent-deep);
font-size: 0.88rem;
font-weight: 700;
}
.head p {
margin: 0.05rem 0 0;
color: #64748b;
font-size: 0.75rem;
}
.chips {
display: flex;
flex: 1 1 auto;
flex-wrap: nowrap;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.35rem;
max-width: 11.5rem;
min-width: 0;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, #fff);
background: rgba(255, 255, 255, 0.86);
color: #134e4a;
padding: 0.22rem 0.55rem 0.22rem 0.28rem;
text-decoration: none;
font-size: 0.78rem;
}
.chip b {
flex-shrink: 0;
font-weight: 700;
font-size: 0.72rem;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 0.12rem 0.38rem;
}
.chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more {
flex-shrink: 0;
border: 0;
border-radius: 999px;
background: var(--accent);
color: #fff;
padding: 0.32rem 0.7rem;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.more:hover {
background: var(--accent-hover);
}
.days {
display: grid;
gap: 0.85rem;
max-height: 62vh;
overflow: auto;
padding-inline-end: 0.15rem;
}
.day {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 14px;
overflow: hidden;
}
.day h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.55rem 0.8rem;
background: var(--accent-subtle);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 16%, #fff);
font-size: 0.86rem;
font-weight: 600;
color: var(--accent-deep);
}
.day h3 b {
display: grid;
place-items: center;
min-width: 1.7rem;
height: 1.7rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.78rem;
}
.day em {
margin-inline-start: auto;
font-style: normal;
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
background: #fff;
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.day.today h3 {
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
}
.day.today em {
color: var(--accent-deep);
}
.day.past {
opacity: 0.82;
}
.day ul {
list-style: none;
margin: 0;
padding: 0;
}
.day li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.62rem 0.85rem;
border-top: 1px solid #eef2f6;
}
.day li:first-child {
border-top: 0;
}
.day a {
flex: 1 1 auto;
color: #0f172a;
text-decoration: none;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.day a:hover {
color: var(--accent-deep);
}
.sms {
flex-shrink: 0;
border: 1px solid color-mix(in srgb, var(--accent) 35%, #fff);
background: #fff;
color: var(--accent-deep);
border-radius: 999px;
padding: 0.28rem 0.65rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.sms:hover:not(:disabled) {
background: var(--accent-subtle);
}
.sms:disabled {
border-color: #e2e8f0;
color: #94a3b8;
cursor: default;
}
.lead {
margin: 0 0 0.6rem;
}
.hint,
.err,
.ok {
margin: 0.55rem 0 0;
font-size: 0.82rem;
}
.hint {
color: #94a3b8;
}
.err {
color: #dc2626;
}
.ok {
color: #16a34a;
}
@media (max-width: 720px) {
.birthdays {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.45rem 0.6rem;
}
.head {
min-width: 0;
}
.head p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more {
grid-column: 2;
grid-row: 1;
}
.chips {
grid-column: 1 / -1;
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
.day li {
gap: 0.45rem;
padding-inline: 0.7rem;
}
}
</style>
@@ -1,98 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSeriesPointDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
series: HomeDashboardSeriesPointDto[];
}>();
const points = computed(() => props.series);
const hasData = computed(() => points.value.some((p) => p.value > 0));
const total = computed(() => points.value.reduce((a, p) => a + p.value, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 450,
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
tooltip: {
trigger: 'axis',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params;
const point = points.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.monthLabel}<br/><strong>${toFaDigits(point.value)}</strong> مشتری جدید`;
},
},
xAxis: {
type: 'category',
data: points.value.map((p) => p.monthLabel),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#94a3b8',
fontFamily: 'IRANSans, Tahoma, sans-serif',
fontSize: 11,
},
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
...(hasData.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false },
},
series: [
{
type: 'bar',
data: points.value.map((p) => p.value),
barMaxWidth: 32,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#34d399' },
{ offset: 1, color: '#059669' },
],
},
borderRadius: [8, 8, 0, 0],
},
label: {
show: true,
position: 'top',
color: '#64748b',
fontFamily: 'IRANSans, Tahoma, sans-serif',
fontSize: 11,
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
},
},
{
type: 'line',
data: points.value.map((p) => p.value),
smooth: 0.35,
symbol: 'none',
lineStyle: { color: '#0f766e', width: 2, opacity: 0.55 },
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">روند رشد مشتریان</h3>
<span v-if="hasData" class="text-xs font-bold bg-emerald-50 text-emerald-700 px-2.5 py-1 rounded-lg">
{{ toFaDigits(total) }} در ۱۲ ماه
</span>
</div>
<div class="h-56">
<UiChart :option="option" height="224px" />
</div>
</section>
</template>
@@ -1,102 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSalesSummaryDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
summary: HomeDashboardSalesSummaryDto;
formatAmount: (value: number) => string;
}>();
const slices = computed(() => [
{ name: 'باز', value: props.summary.openCount, color: '#3b82f6' },
{ name: 'برنده', value: props.summary.wonCount, color: '#10b981' },
{ name: 'باخته', value: props.summary.lostCount, color: '#f43f5e' },
]);
const total = computed(() => slices.value.reduce((a, s) => a + s.value, 0));
const hasData = computed(() => total.value > 0);
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: slices.value.map((s) => s.color),
tooltip: {
trigger: 'item',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (p) => {
const item = Array.isArray(p) ? p[0] : p;
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪`;
},
},
series: [
{
type: 'pie',
radius: ['58%', '82%'],
center: ['50%', '50%'],
silent: !hasData.value,
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
labelLine: { show: false },
data: hasData.value
? slices.value.map((s) => ({ name: s.name, value: s.value }))
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<div class="flex items-center justify-between mb-3">
<h3 class="!m-0 !leading-6 font-bold text-slate-800 text-base">وضعیت معاملات</h3>
<span class="text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-200/60 px-2.5 py-1 rounded-lg">
نرخ برد {{ toFaDigits(summary.winRate) }}٪
</span>
</div>
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-full sm:w-[48%] sm:max-w-[240px] shrink-0">
<div class="relative w-full aspect-square">
<UiChart :option="option" height="100%" />
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span class="text-2xl font-black text-slate-800 leading-none">{{ toFaDigits(total) }}</span>
<span class="text-xs text-slate-400 mt-1">معامله کل</span>
</div>
</div>
</div>
<div class="w-full sm:flex-1 min-w-0 space-y-2.5">
<div class="p-3 rounded-xl bg-slate-50/80 border border-slate-100 flex items-center justify-between gap-2">
<div>
<div class="text-xs font-bold text-slate-700">باز</div>
<div class="text-[11px] text-slate-400">در جریان</div>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800">{{ toFaDigits(summary.openCount) }}</div>
<div class="text-xs text-slate-500">{{ formatAmount(summary.openAmount) }}</div>
</div>
</div>
<div class="p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 flex items-center justify-between gap-2">
<div>
<div class="text-xs font-bold text-emerald-900">برنده (۱۲ ماه)</div>
<div class="text-[11px] text-emerald-600/80">تکمیل موفق</div>
</div>
<div class="text-left">
<div class="text-sm font-black text-emerald-700">{{ toFaDigits(summary.wonCount) }}</div>
<div class="text-xs text-emerald-600">{{ formatAmount(summary.wonAmount) }}</div>
</div>
</div>
<div class="p-3 rounded-xl bg-rose-50/40 border border-rose-100 flex items-center justify-between gap-2">
<div>
<div class="text-xs font-bold text-rose-900">باخته (۱۲ ماه)</div>
<div class="text-[11px] text-rose-500">لغو یا عدم توافق</div>
</div>
<div class="text-left">
<div class="text-sm font-black text-rose-600">{{ toFaDigits(summary.lostCount) }}</div>
<div class="text-xs text-rose-500">{{ formatAmount(summary.lostAmount) }}</div>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty, Progress as AProgress } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardFunnelStageDto } from '@mento/shared';
const props = defineProps<{
funnel: HomeDashboardFunnelStageDto[];
formatAmount: (value: number) => string;
}>();
const rows = computed(() => props.funnel);
const hasData = computed(() => rows.value.some((r) => r.count > 0));
const openTotal = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
function share(count: number) {
if (!openTotal.value) return 0;
return Math.round((count / openTotal.value) * 100);
}
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">قیف فروش باز</h3>
<span v-if="hasData" class="text-xs font-semibold text-slate-500">
{{ toFaDigits(openTotal) }} معامله باز
</span>
</div>
<a-empty v-if="!hasData" description="معاملهٔ بازی در مسیر فروش نیست" />
<div v-else class="space-y-4">
<div v-for="row in rows" :key="row.stageId ?? row.stageName">
<div class="flex items-center justify-between gap-2 mb-1">
<span class="text-xs font-bold text-slate-700">{{ row.stageName }}</span>
<span class="text-xs font-semibold text-slate-500">
{{ toFaDigits(row.count) }} · {{ toFaDigits(share(row.count)) }}٪
</span>
</div>
<a-progress :percent="share(row.count)" :show-info="false" stroke-color="#10b981" />
<div class="text-[11px] text-slate-400">{{ formatAmount(row.amount) }}</div>
</div>
</div>
</section>
</template>
+356 -282
View File
@@ -1,18 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue';
import { SendOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Input as AInput,
Typography,
} from 'ant-design-vue';
import { brand } from '@mento/shared';
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
greeting: string;
firstName: string;
userInitial: string;
alerts: HomeDashboardAlertDto[];
askQuery: string;
asking?: boolean;
askAnswer?: string;
@@ -26,368 +24,444 @@ const emit = defineEmits<{
}>();
const auth = useAuthStore();
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
/** فقط عکس واقعی کاربر؛ پیش‌فرض عمومی را نشان نده */
const hasCustomPhoto = computed(() => Boolean(auth.user?.hasAvatar));
type HeroChip = {
key: string;
tone: HomeDashboardAlertDto['tone'];
title: string;
subtitle?: string;
link: string;
};
const chips = computed<HeroChip[]>(() =>
props.alerts.slice(0, 3).map((a) => {
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
const title = parts[0]?.trim() || a.text;
const subtitle = parts.slice(1).join(' ').trim() || undefined;
return {
key: a.key,
tone: a.tone,
title,
subtitle,
link: a.link || '/',
};
}),
);
function alertIcon(tone: string) {
if (tone === 'urgent') return 'fire' as const;
if (tone === 'warning') return 'warning' as const;
if (tone === 'success') return 'trend' as const;
return 'sparkle' as const;
}
const shortcutLabel = computed(() =>
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform)
? '⌘K'
: 'Ctrl+K',
);
const statusMessage = computed(() => {
if (props.asking) return props.statusText || 'در حال پاسخ…';
if (props.askError) return props.askError;
if (props.askAnswer) return props.askAnswer;
return '';
});
</script>
<template>
<div class="flex flex-col gap-3">
<div class="home-hero">
<div class="home-hero__gradient" aria-hidden="true" />
<div class="home-hero__pattern" aria-hidden="true" />
<div class="home-hero__orb home-hero__orb--start" aria-hidden="true" />
<div class="home-hero__orb home-hero__orb--end" aria-hidden="true" />
<header class="hero home-hero" style="position: relative">
<div
class="hero-surface"
aria-hidden="true"
style="position: absolute; inset: 0; overflow: hidden; pointer-events: none"
>
<div class="hero-glow" style="position: absolute" />
<div class="hero-mist" style="position: absolute; inset: 0" />
</div>
<div class="home-hero__row">
<div class="home-hero__greet">
<div class="home-hero__identity" :aria-label="firstName">
<img
v-if="hasCustomPhoto"
:src="auth.avatarSrc"
:alt="firstName"
class="home-hero__photo"
/>
<span v-else class="home-hero__monogram" aria-hidden="true">
{{ userInitial }}
</span>
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
</div>
<div class="home-hero__copy">
<p class="home-hero__brand">{{ brand.nameFa }}</p>
<a-typography-title :level="4" class="home-hero__title">
{{ greeting }}، {{ firstName }}
</a-typography-title>
<a-typography-text class="home-hero__subtitle">
از داده‌ها بپرسید یا کار امروز را از همین‌جا شروع کنید
</a-typography-text>
</div>
<div class="hero-body">
<div class="hero-greet">
<span class="hero-avatar">
<img :src="auth.avatarSrc" :alt="firstName" width="72" height="72" />
<i class="online" />
</span>
<div class="greet-text">
<h1>{{ greeting }} {{ firstName }} 👋</h1>
<p v-if="chips.length">{{ brand.nameFa }} اینا رو برات پیدا کرده</p>
<p v-else>از {{ brand.nameFa }} بپرس یا کار امروز را شروع کن</p>
</div>
</div>
<div class="home-hero__ask">
<a-input
<div v-if="chips.length" class="hero-chips">
<RouterLink
v-for="chip in chips"
:key="chip.key"
class="chip"
:class="`chip-${chip.tone}`"
:to="chip.link"
>
<span class="chip-ico"><UiIcon :name="alertIcon(chip.tone)" :size="13" /></span>
<span class="chip-text">
<strong>{{ chip.title }}</strong>
<small v-if="chip.subtitle">{{ chip.subtitle }}</small>
</span>
</RouterLink>
</div>
<div class="hero-ask-wrap">
<form class="hero-ask" @submit.prevent="emit('ask')">
<UiIcon name="sparkle" :size="18" class="ask-ico" />
<input
id="home-hero-ask"
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking"
:bordered="false"
class="home-hero__ask-input"
@update:value="emit('update:askQuery', $event)"
@press-enter="emit('ask')"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</kbd>
<a-button
type="primary"
shape="round"
class="home-hero__ask-btn"
:disabled="asking"
:loading="asking"
aria-label="ارسال"
@click="emit('ask')"
>
<span class="home-hero__ask-btn-inner">
<SendOutlined />
<span class="home-hero__ask-label">بپرس</span>
</span>
</a-button>
</div>
<kbd>{{ shortcutLabel }}</kbd>
<button type="submit" class="ask-send" aria-label="ارسال" :disabled="asking">
<UiIcon name="send" :size="15" />
</button>
</form>
<p v-if="asking" class="hero-ask-status">{{ statusText || 'در حال پاسخ…' }}</p>
<p v-else-if="askError" class="hero-ask-status err">{{ askError }}</p>
<p v-else-if="askAnswer" class="hero-ask-status">{{ askAnswer }}</p>
</div>
</div>
<div
v-if="statusMessage"
class="rounded-xl border px-3.5 py-2.5"
:class="
askError
? 'border-rose-200 bg-rose-50/70'
: asking
? 'border-emerald-200/70 bg-emerald-50/50'
: 'border-slate-200 bg-slate-50/80'
"
>
<a-typography-paragraph
:type="askError ? 'danger' : 'secondary'"
:ellipsis="{ rows: 3 }"
class="!mb-0 !text-sm"
>
{{ statusMessage }}
</a-typography-paragraph>
</div>
</div>
<MentoAiAvatar class="hero-bot" :size="156" />
</header>
</template>
<style scoped>
.home-hero {
.hero {
position: relative;
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
border-radius: 1rem;
border: 1px solid rgb(16 137 125 / 0.28);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
display: grid;
/* RTL: body سمت راست، ربات سمت چپ — فضای اضافه برای پاپ‌آپ */
grid-template-columns: minmax(0, 1fr) 210px;
gap: 0.7rem 1rem;
align-items: start;
align-self: start;
height: auto;
min-height: 0;
padding: 0.8rem 1.5rem 0.9rem;
border-radius: var(--radius);
overflow: visible;
color: #fff;
background: transparent;
isolation: isolate;
}
.home-hero__gradient,
.home-hero__pattern,
.home-hero__orb {
pointer-events: none;
.hero-surface {
position: absolute;
}
.home-hero__gradient,
.home-hero__pattern {
inset: 0;
}
.home-hero__gradient {
background:
linear-gradient(
115deg,
#eef8f7 0%,
#d0ebe8 28%,
#c5e6e2 55%,
#eef8f7 78%,
#ffffff 100%
);
}
.home-hero__pattern {
opacity: 0.45;
z-index: 0;
border-radius: var(--radius);
overflow: hidden;
background-color: #134e4a;
background-image:
radial-gradient(circle at 1px 1px, rgba(16, 137, 125, 0.28) 1px, transparent 0),
radial-gradient(ellipse 55% 90% at 8% 55%, rgba(103, 232, 249, 0.55) 0%, rgba(45, 212, 191, 0.22) 35%, transparent 68%),
radial-gradient(ellipse 50% 80% at 92% 40%, rgba(153, 246, 228, 0.28) 0%, transparent 55%),
radial-gradient(ellipse 70% 60% at 45% 30%, rgba(45, 212, 191, 0.18) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 60% 85%, rgba(15, 23, 42, 0.28) 0%, transparent 55%),
linear-gradient(
135deg,
rgba(16, 137, 125, 0.08) 25%,
transparent 25%,
transparent 50%,
rgba(16, 137, 125, 0.08) 50%,
rgba(16, 137, 125, 0.08) 75%,
transparent 75%,
transparent
105deg,
#0b3d3a 0%,
#115e59 22%,
#0f766e 48%,
#134e4a 72%,
#0f3f3c 100%
);
background-size:
18px 18px,
28px 28px;
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
box-shadow:
0 16px 36px rgba(15, 118, 110, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.home-hero__orb {
.hero-glow {
position: absolute;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(48px);
inset-inline-end: -70px;
top: 40%;
transform: translateY(-50%);
background:
radial-gradient(circle, rgba(165, 243, 252, 0.65) 0%, rgba(34, 211, 238, 0.28) 32%, transparent 68%);
filter: blur(8px);
pointer-events: none;
}
.home-hero__orb--start {
inset-inline-end: -5rem;
top: -6rem;
width: 13rem;
height: 13rem;
background: rgb(22 193 168 / 0.22);
.hero-mist {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 40% 35% at 25% 20%, rgba(255, 255, 255, 0.14) 0%, transparent 70%),
radial-gradient(ellipse 35% 40% at 70% 75%, rgba(204, 251, 241, 0.12) 0%, transparent 70%),
radial-gradient(ellipse 50% 45% at 50% 50%, rgba(15, 118, 110, 0.35) 0%, transparent 75%);
pointer-events: none;
}
.home-hero__orb--end {
inset-inline-start: -4rem;
bottom: -5rem;
width: 12rem;
height: 12rem;
background: rgb(16 137 125 / 0.16);
}
.home-hero__row {
.hero-body,
.hero-bot {
position: relative;
z-index: 1;
}
.hero-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1rem;
width: 100%;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
min-width: 0;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 1rem 1.25rem;
padding-top: 0;
}
.home-hero__greet {
.hero-greet {
display: flex;
align-items: center;
gap: 0.875rem;
gap: 0.9rem;
min-width: 0;
max-width: 22rem;
max-width: 100%;
}
.home-hero__identity {
.greet-text {
min-width: 0;
}
.hero-avatar {
position: relative;
width: 72px;
height: 72px;
border-radius: 999px;
flex-shrink: 0;
width: 3rem;
height: 3rem;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
}
.home-hero__photo,
.home-hero__monogram {
display: flex;
.hero-avatar img {
width: 100%;
height: 100%;
border-radius: 0.85rem;
object-fit: cover;
}
.home-hero__monogram {
align-items: center;
justify-content: center;
font-size: 1.1rem;
font-weight: 800;
color: #eef8f7;
background: linear-gradient(160deg, #16c1a8 0%, #10897d 100%);
letter-spacing: -0.02em;
user-select: none;
}
.home-hero__online {
position: absolute;
inset-inline-start: 0;
bottom: 0;
width: 0.7rem;
height: 0.7rem;
display: block;
border-radius: 999px;
background: #10897d;
border: 2px solid #fff;
border: 3px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.22);
box-sizing: border-box;
}
.home-hero__copy {
.online {
position: absolute;
bottom: 3px;
inset-inline-start: 3px;
width: 14px;
height: 14px;
border-radius: 999px;
background: #22c55e;
border: 2px solid #fff;
z-index: 1;
}
.greet-text h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
font-weight: 600;
letter-spacing: -0.01em;
color: #fff;
}
.greet-text p {
margin: 0.25rem 0 0;
color: rgba(255, 255, 255, 0.88);
font-size: 0.9rem;
font-weight: 500;
}
.hero-chips {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
max-width: min(580px, 100%);
min-width: 0;
}
.home-hero__brand {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: rgb(4 120 87 / 0.8);
}
.home-hero__title {
margin: 0.125rem 0 0 !important;
color: #1e293b !important;
font-size: 1.125rem !important;
font-weight: 800 !important;
line-height: 1.75rem !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.home-hero__subtitle {
display: block !important;
margin-top: 0.125rem !important;
color: #64748b !important;
font-size: 0.875rem !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.home-hero__ask {
.chip {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
min-width: 0;
padding: 0.4rem 0.5rem;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
text-decoration: none;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.07);
}
.chip-text {
display: grid;
gap: 0.04rem;
min-width: 0;
}
.chip-text strong {
font-size: 0.68rem;
font-weight: 700;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-text small {
font-size: 0.6rem;
color: #64748b;
font-weight: 500;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-ico {
width: 22px;
height: 22px;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
}
.chip-urgent .chip-ico {
background: #fee2e2;
color: #ef4444;
}
.chip-warning .chip-ico {
background: #fef3c7;
color: #d97706;
}
.chip-success .chip-ico,
.chip-info .chip-ico {
background: #dcfce7;
color: #16a34a;
}
.hero-ask-wrap {
display: grid;
gap: 0.45rem;
max-width: min(520px, 100%);
width: 100%;
min-width: 0;
box-sizing: border-box;
padding-block: 0.375rem;
padding-inline: 0.75rem 0.5rem;
background: #fff;
border: 1px solid rgb(255 255 255 / 0.8);
border-radius: 1rem;
box-shadow:
0 4px 12px rgb(6 78 59 / 0.05),
0 1px 2px rgb(15 23 42 / 0.04);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.home-hero__ask:focus-within {
border-color: #16c1a8;
box-shadow:
0 0 0 2px rgb(16 137 125 / 0.18),
0 4px 12px rgb(10 92 84 / 0.06);
}
.home-hero__ask-input {
flex: 1 1 auto;
.hero-ask {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
min-width: 0;
background: transparent !important;
max-width: 100%;
background: #fff;
color: #0f172a;
border-radius: 999px;
padding: 0.45rem 0.45rem 0.45rem 0.9rem;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
}
.home-hero__ask-kbd {
display: none;
.ask-ico {
color: #14b8a6;
flex-shrink: 0;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.hero-ask input {
border: 0;
background: transparent;
flex: 1;
outline: none;
font-size: 0.9rem;
min-width: 0;
}
.hero-ask input::placeholder {
color: #94a3b8;
font-size: 11px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.home-hero__ask-btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
.hero-ask kbd {
font-size: 0.68rem;
color: #94a3b8;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.14rem 0.38rem;
flex-shrink: 0;
}
.home-hero__ask-btn-inner {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
line-height: 1;
.ask-send {
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
}
.home-hero__ask-label {
display: none;
.ask-send:disabled {
opacity: 0.55;
cursor: wait;
}
@media (min-width: 640px) {
.home-hero__ask-kbd {
display: inline-flex;
.hero-ask-status {
margin: 0;
padding: 0.55rem 0.85rem;
border-radius: 14px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
font-size: 0.82rem;
line-height: 1.7;
max-height: 7.5rem;
overflow: auto;
}
.hero-ask-status.err {
background: rgba(185, 28, 28, 0.28);
}
.hero-bot {
position: relative;
z-index: 1;
/* در RTL ستون ربات سمت چپ است؛ end = لبه بیرونی چپ */
justify-self: end;
align-self: center;
margin-inline-start: 0;
margin-inline-end: 0.35rem;
overflow: visible;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: minmax(0, 1fr) 160px;
}
.home-hero__ask-label {
display: inline;
}
.home-hero__title {
font-size: 1.25rem !important;
}
}
@media (max-width: 1023px) {
.home-hero__row {
.hero-chips {
grid-template-columns: minmax(0, 1fr);
max-width: min(420px, 100%);
}
}
@media (max-width: 700px) {
.hero {
grid-template-columns: minmax(0, 1fr);
min-height: auto;
padding-inline: 0.85rem;
overflow: hidden;
}
.home-hero__greet {
max-width: none;
.hero-bot {
display: none;
}
.hero-ask kbd {
display: none;
}
}
</style>
+106 -78
View File
@@ -1,97 +1,125 @@
<script setup lang="ts">
import {
CalendarOutlined,
CheckCircleOutlined,
FundOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@/icons/ant-bridge';
import type { Component } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import type { HomeDashboardKpiDto } from '@mento/shared';
import { currencySuffix } from '@/lib/money';
const props = defineProps<{
defineProps<{
kpis: HomeDashboardKpiDto[];
currency: string;
formatValue: (key: string, value: number) => string;
formatChange: (value: number | null) => string;
}>();
function salesAmount(value: number) {
return new Intl.NumberFormat('fa-IR').format(value);
}
function salesUnit() {
return currencySuffix(props.currency);
}
const icons: Record<string, Component> = {
sales: FundOutlined,
customers: TeamOutlined,
'open-deals': CalendarOutlined,
followups: CheckCircleOutlined,
'win-rate': RiseOutlined,
overdue: WarningOutlined,
const icons: Record<string, 'calendar' | 'users' | 'briefcase' | 'check'> = {
sales: 'calendar',
customers: 'users',
'open-deals': 'briefcase',
followups: 'check',
};
function pillClass(kpi: HomeDashboardKpiDto) {
if (kpi.key === 'overdue') return 'bg-rose-100/70 text-rose-600';
if (kpi.changePercent == null) return 'text-slate-400';
return kpi.changePercent < 0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600';
}
const iconTones: Record<string, string> = {
sales: '#14b8a6',
customers: '#38bdf8',
'open-deals': '#fb923c',
followups: '#a78bfa',
};
</script>
<template>
<section class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<div
v-for="kpi in kpis"
:key="kpi.key"
class="rounded-2xl p-4 border shadow-sm flex flex-col justify-between transition duration-200 hover:-translate-y-0.5"
:class="
kpi.key === 'overdue'
? 'border-rose-200/70 bg-gradient-to-b from-white to-rose-50/20'
: 'bg-white border-slate-200/80'
"
>
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-semibold" :class="kpi.key === 'overdue' ? 'text-rose-700' : 'text-slate-500'">
{{ kpi.label }}
</span>
<div class="kpi-row">
<article v-for="kpi in kpis" :key="kpi.key" class="kpi-card">
<div class="kpi-icon" :style="{ color: iconTones[kpi.key] ?? '#14b8a6' }">
<UiIcon :name="icons[kpi.key] ?? 'chart'" :size="20" />
</div>
<div class="kpi-text">
<p class="kpi-label">{{ kpi.label }}</p>
<strong class="kpi-value">{{ formatValue(kpi.key, kpi.value) }}</strong>
<span
class="w-8 h-8 rounded-xl flex items-center justify-center"
:class="kpi.key === 'overdue' ? 'bg-rose-100/80 text-rose-600' : 'bg-emerald-50 text-emerald-600'"
v-if="kpi.changePercent != null"
class="kpi-change"
:class="{ down: kpi.changePercent < 0 }"
>
<component :is="icons[kpi.key] ?? FundOutlined" />
<span class="arrow">{{ kpi.changePercent >= 0 ? '↑' : '↓' }}</span>
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
</span>
</div>
<div>
<div
class="!font-bold !text-[24px] !leading-8 tracking-tight"
:class="kpi.key === 'overdue' ? 'text-rose-600' : 'text-slate-800'"
>
<template v-if="kpi.key === 'sales'">
<span>{{ salesAmount(kpi.value) }}</span><span class="text-[16px] leading-6 font-semibold ms-1.5">{{ salesUnit() }}</span>
</template>
<template v-else>{{ formatValue(kpi.key, kpi.value) }}</template>
</div>
<div
v-if="kpi.changePercent != null"
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md"
:class="pillClass(kpi)"
>
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
</div>
<div
v-else-if="kpi.key === 'overdue'"
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md bg-rose-100/70 text-rose-600"
>
نیازمند اقدام فوری
</div>
<div v-else class="mt-2.5 text-[11px] font-medium text-slate-400">
{{ kpi.changeLabel || '—' }}
</div>
</div>
</div>
</section>
</article>
</div>
</template>
<style scoped>
.kpi-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.9rem;
}
.kpi-card {
display: flex;
gap: 0.85rem;
align-items: flex-start;
background: #fff;
border-radius: var(--radius);
padding: 1.1rem 1.15rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
}
.kpi-icon {
width: 48px;
height: 48px;
border-radius: var(--radius);
background: #f8fafc;
display: grid;
place-items: center;
flex-shrink: 0;
}
.kpi-label {
margin: 0;
color: #94a3b8;
font-size: 0.84rem;
}
.kpi-value {
display: block;
margin-top: 0.25rem;
font-size: 1.18rem;
color: #0f172a;
letter-spacing: -0.02em;
}
.kpi-change {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-top: 0.45rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: #dcfce7;
color: #16a34a;
font-size: 0.72rem;
font-weight: 700;
}
.arrow {
font-size: 0.82rem;
line-height: 1;
}
.kpi-change.down {
background: #fee2e2;
color: #dc2626;
}
@media (max-width: 1200px) {
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.kpi-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,83 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSliceDto } from '@mento/shared';
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
reasons: HomeDashboardSliceDto[];
formatAmount: (value: number) => string;
}>();
const rows = computed(() => props.reasons);
const hasData = computed(() => rows.value.some((r) => r.count > 0));
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0));
function share(count: number) {
if (!total.value) return 0;
return Math.round((count / total.value) * 100);
}
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: CHART_PALETTE,
tooltip: {
trigger: 'item',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (p) => {
const item = Array.isArray(p) ? p[0] : p;
const row = rows.value.find((r) => r.label === item.name);
const amount =
row?.amount != null ? `<br/>${props.formatAmount(row.amount)}` : '';
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪${amount}`;
},
},
legend: { show: false },
series: [
{
type: 'pie',
radius: ['58%', '78%'],
center: ['50%', '50%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: rows.value.map((r) => ({ name: r.label, value: r.count })),
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<div class="flex items-center justify-between mb-2">
<h3 class="font-bold text-slate-800 text-base">دلایل باخت</h3>
<span v-if="hasData" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
</div>
<a-empty v-if="!hasData" description="معاملهٔ باخته‌ای ثبت نشده" />
<template v-else>
<div class="relative w-36 h-36 mx-auto">
<UiChart :option="option" height="144px" />
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
<span class="text-[11px] text-slate-400">معامله باخته</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mt-3">
<div
v-for="(item, index) in rows"
:key="item.label"
class="rounded-xl border border-slate-200/70 bg-slate-50 px-2.5 py-2"
>
<div class="flex items-center gap-1.5">
<span
class="w-2 h-2 rounded-full shrink-0"
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
></span>
<span class="text-[11px] text-slate-600 truncate">{{ item.label }}</span>
</div>
<div class="text-sm font-black text-slate-800 mt-1">{{ toFaDigits(share(item.count)) }}٪</div>
</div>
</div>
</template>
</section>
</template>
@@ -1,17 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import {
FundOutlined,
LeftOutlined,
PlusOutlined,
} from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
import type { PipelineBoardDto } from '@mento/shared';
import {
PIPELINE_ACCENT,
type PipelineAccent,
} from '@/lib/pipeline-board';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
const props = defineProps<{
@@ -19,100 +10,41 @@ const props = defineProps<{
formatMoney: (amount: number, currency: string) => string;
}>();
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'blue', 'orange'];
type Card = { id: string; name: string; amount: string; when: string };
type Col = { id: string; name: string; count: number; cards: Card[] };
type Card = {
id: string;
name: string;
initial: string;
amount: string;
when: string;
isToday: boolean;
};
type Col = {
id: string;
name: string;
count: number;
totalLabel: string;
accent: PipelineAccent;
cards: Card[];
};
function relativeWhen(iso: string | null | undefined): { label: string; isToday: boolean } {
if (!iso) return { label: '', isToday: false };
function relativeWhen(iso: string | null | undefined) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days <= 0) return { label: 'امروز', isToday: true };
if (days === 1) return { label: 'دیروز', isToday: false };
return { label: `${toFaDigits(days)} روز پیش`, isToday: false };
}
function initialOf(name: string | undefined | null) {
const t = (name ?? '').trim();
return t ? t.slice(0, 1) : '؟';
if (days <= 0) return 'امروز';
if (days === 1) return 'دیروز';
return `${toFaDigits(days)} روز پیش`;
}
const cols = computed<Col[]>(() => {
const stages = props.board?.stages ?? [];
const hasDeals = stages.some((s) => s.deals.length);
if (!hasDeals) return [];
return stages.slice(0, 3).map((stage, index) => {
const accent = ACCENT_CYCLE[index % ACCENT_CYCLE.length]!;
const currency = stage.deals[0]?.currency ?? 'IRR';
return {
id: stage.id,
name: stage.name,
count: stage.deals.length,
totalLabel:
stage.totalAmount > 0 ? props.formatMoney(stage.totalAmount, currency) : '',
accent,
cards: stage.deals.slice(0, 2).map((deal) => {
const name = deal.company?.name || deal.person?.fullName || deal.title;
const when = relativeWhen(deal.updatedAt || deal.createdAt);
return {
id: deal.id,
name,
initial: initialOf(name),
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
when: when.label,
isToday: when.isToday,
};
}),
};
});
return stages.slice(0, 3).map((stage) => ({
id: stage.id,
name: stage.name,
count: stage.deals.length,
cards: stage.deals.slice(0, 2).map((deal) => ({
id: deal.id,
name: deal.company?.name || deal.person?.fullName || deal.title,
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
when: relativeWhen(deal.updatedAt || deal.createdAt),
})),
}));
});
function accentOf(key: PipelineAccent) {
return PIPELINE_ACCENT[key];
}
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-5 sm:p-6 shadow-sm">
<header class="flex items-center justify-between gap-3 mb-5">
<div class="flex items-center gap-2.5 min-w-0">
<div
class="w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
aria-hidden="true"
>
<FundOutlined />
</div>
<div class="min-w-0">
<h3 class="font-bold text-slate-800 text-base m-0">جریان فروش</h3>
<p class="text-xs text-slate-400 m-0 mt-0.5">
نمای سریع معاملات در مراحل کلیدی
</p>
</div>
</div>
<RouterLink
:to="{ name: 'pipeline' }"
class="shrink-0 inline-flex items-center gap-1 text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-3 py-1.5 rounded-xl transition-colors"
>
<span>نمایش همه</span>
<LeftOutlined />
</RouterLink>
<article class="panel">
<header class="panel-head">
<h2>جریان فروش</h2>
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink>
</header>
<HomePanelEmpty
@@ -122,85 +54,132 @@ function accentOf(key: PipelineAccent) {
action-label="معامله جدید"
:action-to="{ name: 'deal-new' }"
/>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
<article
v-for="col in cols"
:key="col.id"
class="rounded-2xl border border-slate-200/70 bg-slate-50/90 p-4 flex flex-col gap-3 min-w-0"
>
<div class="flex items-start justify-between gap-2 pb-2 border-b border-slate-200/80">
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2.5 h-2.5 rounded-full shrink-0"
:style="{ background: accentOf(col.accent).solid }"
aria-hidden="true"
/>
<h4 class="font-bold text-sm text-slate-700 m-0 truncate">{{ col.name }}</h4>
</div>
<p v-if="col.totalLabel" class="text-[11px] text-slate-400 m-0 mt-1 truncate">
مجموع {{ col.totalLabel }}
</p>
</div>
<span
class="shrink-0 bg-white border border-slate-200 text-slate-700 text-xs font-bold px-2.5 py-0.5 rounded-full"
>
{{ toFaDigits(col.count) }}
</span>
</div>
<div class="flex flex-col gap-2.5 flex-1">
<div v-else class="mini-pipeline">
<div v-for="col in cols" :key="col.id" class="pipe-col">
<header>
<strong>{{ col.name }} ({{ toFaDigits(col.count) }})</strong>
</header>
<div class="pipe-cards">
<RouterLink
v-for="item in col.cards"
:key="item.id"
:to="{ name: 'deal-detail', params: { id: item.id } }"
class="group bg-white p-3 rounded-xl border border-slate-200/80 shadow-sm hover:border-emerald-400 hover:shadow transition-all flex items-center gap-3 min-w-0"
v-for="card in col.cards"
:key="card.id"
class="pipe-card"
:to="{ name: 'deal-detail', params: { id: card.id } }"
>
<span
class="w-9 h-9 rounded-full font-bold text-xs flex items-center justify-center shrink-0 border"
:style="{
background: accentOf(col.accent).soft,
color: accentOf(col.accent).text,
borderColor: accentOf(col.accent).border,
}"
>
{{ item.initial }}
</span>
<div class="min-w-0 flex-1">
<div class="font-bold text-sm text-slate-800 truncate">{{ item.name }}</div>
<div class="text-xs text-slate-500 mt-0.5 truncate">
<template v-if="item.amount">
<span class="font-medium">{{ item.amount }}</span>
<span v-if="item.when" class="text-slate-300 mx-1">·</span>
</template>
<span
v-if="item.when"
:class="item.isToday ? 'text-emerald-600 font-semibold' : 'text-slate-400'"
>
{{ item.when }}
</span>
</div>
<span class="deal-mark">{{ card.name.slice(0, 1) }}</span>
<div>
<strong>{{ card.name }}</strong>
<span v-if="card.amount">{{ card.amount }}</span>
<em>{{ card.when }}</em>
</div>
</RouterLink>
<div
v-if="!col.cards.length"
class="border border-dashed border-slate-200 rounded-xl px-3 py-5 text-center text-xs text-slate-400"
>
معامله‌ای در این مرحله نیست
</div>
<RouterLink
v-else-if="col.cards.length < 2"
:to="{ name: 'deal-new', query: { stageId: col.id } }"
class="mt-auto border border-dashed border-slate-200 hover:border-emerald-300 hover:bg-white rounded-xl px-3 py-3 text-center text-xs text-slate-400 hover:text-emerald-700 transition-colors inline-flex items-center justify-center gap-1"
>
<PlusOutlined />
افزودن معامله
</RouterLink>
</div>
</article>
</div>
</div>
</section>
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
}
.mini-pipeline {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 0.65rem;
overflow-x: auto;
}
.pipe-col > header {
margin-bottom: 0.5rem;
font-size: 0.78rem;
color: #64748b;
}
.pipe-cards {
display: grid;
gap: 0.4rem;
}
.pipe-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: var(--radius);
background: #f8fafc;
border: 1px solid #eef2f7;
color: inherit;
font-size: 0.78rem;
}
.pipe-card div {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pipe-card strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
}
.pipe-card span {
color: #94a3b8;
font-size: 0.68rem;
}
.pipe-card em {
color: #cbd5e1;
font-size: 0.66rem;
font-style: normal;
}
.deal-mark {
width: 28px;
height: 28px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 600;
flex-shrink: 0;
}
@media (max-width: 700px) {
.mini-pipeline {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import { Button as AButton, Empty as AEmpty, Typography } from 'ant-design-vue';
defineProps<{
title: string;
@@ -9,19 +8,70 @@ defineProps<{
actionLabel: string;
actionTo: RouteLocationRaw;
}>();
const { Text: ATypographyText } = Typography;
</script>
<template>
<a-empty :description="null">
<template #description>
<a-typography-text strong>{{ title }}</a-typography-text>
<br />
<a-typography-text type="secondary">{{ description }}</a-typography-text>
</template>
<RouterLink :to="actionTo">
<a-button type="primary">{{ actionLabel }}</a-button>
</RouterLink>
</a-empty>
<div class="empty-card">
<div class="art" aria-hidden="true">
<svg viewBox="0 0 168 148" fill="none">
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<strong>{{ title }}</strong>
<p>{{ description }}</p>
<RouterLink class="cta" :to="actionTo">{{ actionLabel }}</RouterLink>
</div>
</template>
<style scoped>
.empty-card {
flex: 1;
min-height: 0;
display: grid;
justify-items: center;
align-content: center;
text-align: center;
gap: 0.45rem;
padding: 0.4rem 0.2rem 0.15rem;
}
.art svg {
width: 92px;
height: auto;
display: block;
}
strong {
font-size: 0.88rem;
color: #0f172a;
}
p {
margin: 0;
max-width: 16rem;
color: #94a3b8;
font-size: 0.75rem;
line-height: 1.7;
}
.cta {
margin-top: 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 0.9rem;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
}
</style>
@@ -1,19 +1,7 @@
<script setup lang="ts">
import { computed, type Component } from 'vue';
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import {
CalendarOutlined,
CheckOutlined,
MailOutlined,
PhoneOutlined,
} from '@/icons/ant-bridge';
import {
Flex as AFlex,
Tag as ATag,
Timeline as ATimeline,
Typography,
theme,
} from 'ant-design-vue';
import UiIcon from '@/components/UiIcon.vue';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
import type { ActivityDto } from '@mento/shared';
@@ -22,21 +10,16 @@ const props = defineProps<{
formatWhen: (value: string | null) => string;
}>();
const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
const ATimelineItem = ATimeline.Item;
type Row = {
id: string;
title: string;
when: string;
typeLabel: string;
type: string;
tone: 'done' | 'pending' | 'held' | 'late';
label: string;
};
const typeLabelMap: Record<string, string> = {
const typeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
@@ -48,50 +31,23 @@ function activityStatus(act: ActivityDto) {
if (act.dueAt && new Date(act.dueAt) < new Date()) {
return { label: 'عقب‌افتاده', tone: 'late' as const };
}
return { label: 'در انتظار', tone: 'pending' as const };
return { label: 'در انتظار پاسخ', tone: 'pending' as const };
}
function activityIcon(type: string): Component {
if (type === 'call') return PhoneOutlined;
if (type === 'meeting') return CalendarOutlined;
if (type === 'task') return CheckOutlined;
return MailOutlined;
}
function tagColor(tone: Row['tone']) {
if (tone === 'late') return 'error';
if (tone === 'done' || tone === 'held') return 'success';
return 'processing';
}
function timelineColor(tone: Row['tone']) {
if (tone === 'late') return 'red';
if (tone === 'done' || tone === 'held') return 'green';
return 'blue';
}
function typeBg(type: string) {
if (type === 'call') return token.value.colorInfoBg;
if (type === 'meeting') return token.value.colorWarningBg;
if (type === 'task') return token.value.colorSuccessBg;
return token.value.colorFillAlter;
}
function typeFg(type: string) {
if (type === 'call') return token.value.colorInfo;
if (type === 'meeting') return token.value.colorWarning;
if (type === 'task') return token.value.colorSuccess;
return token.value.colorTextSecondary;
function activityIcon(type: string) {
if (type === 'call') return 'phone' as const;
if (type === 'meeting') return 'calendar' as const;
if (type === 'task') return 'check' as const;
return 'mail' as const;
}
const rows = computed<Row[]>(() =>
props.activities.slice(0, 5).map((act) => {
props.activities.slice(0, 3).map((act) => {
const st = activityStatus(act);
return {
id: act.id,
title: act.title,
when: props.formatWhen(act.dueAt || act.createdAt),
typeLabel: typeLabelMap[act.type] ?? act.type,
when: `${typeLabel[act.type] ?? act.type} · ${props.formatWhen(act.dueAt || act.createdAt)}`,
type: act.type,
tone: st.tone,
label: st.label,
@@ -101,16 +57,11 @@ const rows = computed<Row[]>(() =>
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h3 class="font-bold text-slate-800 text-base">فعالیت‌های اخیر</h3>
<a-tag v-if="rows.length" :bordered="false">{{ rows.length }}</a-tag>
</div>
<RouterLink :to="{ name: 'activities' }" class="text-sm font-bold text-emerald-600">
مشاهده همه
</RouterLink>
</div>
<article class="panel">
<header class="panel-head">
<h2>فعالیت‌های اخیر</h2>
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink>
</header>
<HomePanelEmpty
v-if="!rows.length"
@@ -119,44 +70,117 @@ const rows = computed<Row[]>(() =>
action-label="فعالیت جدید"
:action-to="{ name: 'activity-new' }"
/>
<a-timeline v-else>
<a-timeline-item v-for="item in rows" :key="item.id" :color="timelineColor(item.tone)">
<a-flex
align="flex-start"
justify="space-between"
:gap="token.marginSM"
:style="{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: `${token.borderRadius}px`,
background: token.colorFillAlter,
minWidth: 0,
}"
>
<a-flex :gap="token.marginSM" :style="{ minWidth: 0, flex: 1 }">
<a-flex
align="center"
justify="center"
:style="{
width: '32px',
height: '32px',
borderRadius: `${token.borderRadius}px`,
background: typeBg(item.type),
color: typeFg(item.type),
flexShrink: 0,
}"
>
<component :is="activityIcon(item.type)" />
</a-flex>
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
<a-typography-text strong ellipsis>{{ item.title }}</a-typography-text>
<a-typography-text type="secondary" ellipsis>
{{ item.typeLabel }} · {{ item.when }}
</a-typography-text>
</a-flex>
</a-flex>
<a-tag :color="tagColor(item.tone)" style="flex-shrink: 0">{{ item.label }}</a-tag>
</a-flex>
</a-timeline-item>
</a-timeline>
</section>
<ul v-else class="activity-list">
<li v-for="act in rows" :key="act.id">
<span class="act-icon">
<UiIcon :name="activityIcon(act.type)" :size="16" />
</span>
<div class="act-body">
<strong>{{ act.title }}</strong>
<span>{{ act.when }}</span>
</div>
<span class="status-pill" :class="act.tone">{{ act.label }}</span>
</li>
</ul>
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
font-weight: 600;
}
.activity-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.activity-list li {
display: flex;
align-items: center;
gap: 0.65rem;
}
.act-icon {
width: 38px;
height: 38px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
flex-shrink: 0;
}
.act-body {
flex: 1;
min-width: 0;
display: grid;
gap: 0.12rem;
}
.act-body strong {
font-size: 0.84rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.act-body span {
color: #94a3b8;
font-size: 0.72rem;
}
.status-pill {
font-size: 0.68rem;
font-weight: 700;
padding: 0.22rem 0.5rem;
border-radius: 999px;
white-space: nowrap;
}
.status-pill.done,
.status-pill.held {
background: #dcfce7;
color: #166534;
}
.status-pill.pending {
background: #dbeafe;
color: #1d4ed8;
}
.status-pill.late {
background: #fee2e2;
color: #b91c1c;
}
</style>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Segmented as ASegmented } from 'ant-design-vue';
import { computed } from 'vue';
import type { HomeDashboardTrendPointDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
@@ -11,57 +10,26 @@ const props = defineProps<{
formatAmount: (value: number) => string;
}>();
type PeriodMonths = 1 | 3 | 6 | 12;
const period = ref<PeriodMonths>(6);
const periodOptions = [
{ value: 1, label: 'این ماه' },
{ value: 3, label: '۳ ماه' },
{ value: 6, label: '۶ ماه' },
{ value: 12, label: '۱۲ ماه' },
];
const filteredTrend = computed(() => {
const all = props.trend;
if (!all.length) return [];
const n = period.value;
return all.slice(-Math.min(n, all.length));
});
const hasTrend = computed(() => filteredTrend.value.some((p) => p.amount > 0));
/** تغییر آخرین ماه نسبت به ماه قبلِ همان بازه */
const periodChange = computed(() => {
const rows = filteredTrend.value;
if (rows.length < 2) return props.changePercent;
const last = rows[rows.length - 1]!.amount;
const prev = rows[rows.length - 2]!.amount;
if (prev === 0) return last === 0 ? 0 : 100;
return Math.round(((last - prev) / Math.abs(prev)) * 1000) / 10;
});
const changeHint = computed(() => {
if (period.value === 1) return 'نسبت به ماه قبل';
return 'آخرین ماه نسبت به ماه قبل';
});
const trend = computed(() => props.trend);
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
const change = computed(() => props.changePercent);
const option = computed<UiChartOption>(() => ({
animationDuration: 450,
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
animationDuration: 400,
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params;
const point = filteredTrend.value[row.dataIndex ?? 0];
const point = trend.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.monthLabel}<br/><strong>${props.formatAmount(point.amount)}</strong>`;
return `${point.monthLabel}<br/>${props.formatAmount(point.amount)}`;
},
},
xAxis: {
type: 'category',
data: filteredTrend.value.map((p) => p.monthLabel),
data: trend.value.map((p) => p.monthLabel),
boundaryGap: false,
axisLine: { show: false },
axisTick: { show: false },
@@ -75,19 +43,18 @@ const option = computed<UiChartOption>(() => ({
type: 'value',
min: 0,
...(hasTrend.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
splitLine: { lineStyle: { color: '#eef2f7' } },
axisLabel: { show: false },
},
series: [
{
type: 'line',
name: 'فروش',
data: filteredTrend.value.map((p) => p.amount),
smooth: 0.4,
data: trend.value.map((p) => p.amount),
smooth: 0.35,
symbol: 'circle',
symbolSize: period.value === 1 ? 10 : 8,
itemStyle: { color: '#10b981', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#10b981', width: 3 },
symbolSize: 8,
itemStyle: { color: '#14b8a6', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#14b8a6', width: 3 },
areaStyle: {
color: {
type: 'linear',
@@ -96,8 +63,8 @@ const option = computed<UiChartOption>(() => ({
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(16, 185, 129, 0.28)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' },
{ offset: 0, color: 'rgba(20, 184, 166, 0.28)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0)' },
],
},
},
@@ -107,22 +74,56 @@ const option = computed<UiChartOption>(() => ({
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 class="font-bold text-slate-800 text-base">روند فروش</h3>
<span
v-if="hasTrend && periodChange != null"
class="mt-1 inline-flex text-xs font-bold px-2 py-0.5 rounded-md"
:class="periodChange < 0 ? 'text-rose-600 bg-rose-50' : 'text-emerald-600 bg-emerald-50'"
>
{{ formatChange(periodChange) }} {{ changeHint }}
</span>
</div>
<a-segmented v-model:value="period" :options="periodOptions" />
</div>
<div class="h-56">
<UiChart :option="option" height="224px" />
</div>
</section>
<article class="panel">
<header class="panel-head">
<h2>روند فروش</h2>
<span v-if="hasTrend && change != null" class="mini-badge" :class="{ down: change < 0 }">
{{ formatChange(change) }} نسبت به ماه قبل
</span>
</header>
<UiChart :option="option" height="210px" />
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: var(--pad-modal);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid var(--line);
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
color: #0f172a;
}
.mini-badge {
font-size: 0.7rem;
font-weight: 700;
color: #16a34a;
background: #dcfce7;
border-radius: var(--radius);
padding: 0.18rem 0.5rem;
white-space: nowrap;
}
.mini-badge.down {
color: #dc2626;
background: #fee2e2;
}
</style>
@@ -1,20 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSourceDto } from '@mento/shared';
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
sources: HomeDashboardSourceDto[];
}>();
const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8'];
const rows = computed(() => props.sources);
const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: CHART_PALETTE,
color: colors,
tooltip: {
show: total.value > 0,
trigger: 'item',
@@ -27,51 +28,147 @@ const option = computed<UiChartOption>(() => ({
series: [
{
type: 'pie',
radius: ['55%', '78%'],
radius: ['58%', '82%'],
center: ['50%', '50%'],
silent: total.value === 0,
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
labelLine: { show: false },
data:
total.value > 0
? rows.value.map((s) => ({ name: s.label, value: s.count }))
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
: [{ name: '', value: 1, itemStyle: { color: '#e2e8f0' } }],
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">منابع ورود مشتریان</h3>
<span v-if="total" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
</div>
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-full sm:w-[48%] sm:max-w-[220px] shrink-0">
<div class="relative w-full aspect-square">
<UiChart :option="option" height="100%" />
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
<span class="text-xs text-slate-400">مشتری</span>
</div>
</div>
</div>
<div class="w-full sm:flex-1 min-w-0">
<a-empty v-if="!total" description="منبعی ثبت نشده" />
<div v-else class="divide-y divide-slate-100">
<div v-for="(item, index) in rows" :key="item.label" class="flex items-center gap-2 py-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
></span>
<span class="flex-1 text-sm text-slate-700 truncate">{{ item.label }}</span>
<span class="text-sm font-bold text-slate-800">{{ toFaDigits(item.percent) }}٪</span>
</div>
<article class="panel">
<header class="panel-head">
<h2>منابع مشتریان</h2>
</header>
<div class="donut-wrap">
<div class="donut-chart-box">
<UiChart :option="option" height="132px" />
<div class="donut-center">
<strong>{{ toFaDigits(total) }}</strong>
<span>مشتری</span>
</div>
</div>
<p v-if="!total" class="legend-empty">منبعی ثبت نشده</p>
<ul v-else class="donut-legend">
<li v-for="(row, i) in rows" :key="row.label">
<span class="dot" :style="{ background: colors[i % colors.length] }" />
<span class="lbl">{{ row.label }}</span>
<em>{{ toFaDigits(row.percent) }}٪</em>
</li>
</ul>
</div>
</section>
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: var(--pad-modal);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid var(--line);
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-head {
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
color: #0f172a;
}
.donut-wrap {
display: grid;
grid-template-columns: 132px 1fr;
gap: 0.85rem;
align-items: center;
}
.donut-chart-box {
position: relative;
width: 132px;
height: 132px;
}
.donut-center {
position: absolute;
inset: 0;
display: grid;
place-content: center;
text-align: center;
gap: 0.05rem;
pointer-events: none;
}
.donut-center strong {
font-size: 1.15rem;
color: #0f172a;
}
.donut-center span {
font-size: 0.7rem;
color: #94a3b8;
}
.donut-legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.48rem;
font-size: 0.8rem;
}
.donut-legend li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.lbl {
flex: 1;
color: #475569;
}
.legend-empty {
margin: 0;
color: #94a3b8;
font-size: 0.8rem;
}
em {
font-style: normal;
color: #94a3b8;
font-weight: 700;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
}
@media (max-width: 500px) {
.donut-wrap {
grid-template-columns: 1fr;
justify-items: center;
}
}
</style>
@@ -1,15 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { TrophyOutlined } from '@/icons/ant-bridge';
import {
Avatar as AAvatar,
Flex as AFlex,
Progress as AProgress,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardTopCustomerDto } from '@mento/shared';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
@@ -18,61 +9,25 @@ const props = defineProps<{
customers: HomeDashboardTopCustomerDto[];
}>();
const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
const rows = computed(() => props.customers.slice(0, 3));
const rows = computed(() => props.customers.slice(0, 5));
const maxDeals = computed(() => Math.max(...rows.value.map((c) => c.dealCount), 1));
function rankStyle(rank: number) {
if (rank === 1) {
return {
background: token.value.colorWarningBg,
color: token.value.colorWarning,
border: `1px solid ${token.value.colorWarningBorder}`,
};
}
if (rank === 2) {
return {
background: token.value.colorFillSecondary,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorder}`,
};
}
if (rank === 3) {
return {
background: token.value.colorPrimaryBg,
color: token.value.colorPrimary,
border: `1px solid ${token.value.colorPrimaryBorder}`,
};
}
return {
background: token.value.colorFillAlter,
color: token.value.colorTextSecondary,
border: `1px solid ${token.value.colorBorderSecondary}`,
};
}
function progressPercent(count: number) {
return Math.round((count / maxDeals.value) * 100);
function trophy(rank: number) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return `#${toFaDigits(rank)}`;
}
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<TrophyOutlined class="text-amber-500" />
<h3 class="font-bold text-slate-800 text-base">مشتریان برتر</h3>
<article class="panel">
<header class="panel-head">
<div>
<h2>مشتریان برتر</h2>
<p class="hint">بر اساس معاملات برندهٔ ۵ ماه اخیر</p>
</div>
<RouterLink :to="{ name: 'contacts' }" class="text-sm font-bold text-emerald-600">
مشاهده همه
</RouterLink>
</div>
<a-typography-text type="secondary" style="display: block; margin-bottom: 12px">
بر اساس معاملات برندهٔ اخیر
</a-typography-text>
<RouterLink :to="{ name: 'contacts' }">مشاهده همه</RouterLink>
</header>
<HomePanelEmpty
v-if="!rows.length"
@@ -81,51 +36,99 @@ function progressPercent(count: number) {
action-label="مشتری جدید"
:action-to="{ name: 'person-new' }"
/>
<a-flex v-else vertical :gap="token.marginSM">
<a-flex
v-for="item in rows"
:key="item.id"
align="center"
:gap="token.marginSM"
class="rounded-xl border"
:class="item.rank === 1 ? 'bg-amber-50/50 border-amber-200/80' : 'bg-slate-50 border-slate-200/70'"
:style="{ padding: `${token.paddingSM}px`, minWidth: 0 }"
>
<a-flex
align="center"
justify="center"
:style="{
width: '36px',
height: '36px',
borderRadius: '50%',
fontWeight: 700,
flexShrink: 0,
...rankStyle(item.rank),
}"
>
{{ toFaDigits(item.rank) }}
</a-flex>
<a-avatar :style="{ background: token.colorPrimary, flexShrink: 0 }">
{{ item.name.slice(0, 1) }}
</a-avatar>
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
<a-flex align="center" justify="space-between" :gap="token.marginXS">
<a-typography-text strong ellipsis>{{ item.name }}</a-typography-text>
<a-tag v-if="item.rank === 1" color="gold" style="flex-shrink: 0">رتبه یک</a-tag>
</a-flex>
<a-flex align="center" :gap="token.marginSM">
<a-progress
:percent="progressPercent(item.dealCount)"
:show-info="false"
:stroke-color="item.rank === 1 ? token.colorWarning : token.colorPrimary"
style="flex: 1; margin: 0; line-height: 1"
/>
<a-typography-text type="secondary" style="flex-shrink: 0; white-space: nowrap">
{{ toFaDigits(item.dealCount) }} معامله
</a-typography-text>
</a-flex>
</a-flex>
</a-flex>
</a-flex>
</section>
<ul v-else class="top-list">
<li v-for="row in rows" :key="row.id">
<span class="cust-avatar">{{ row.name.slice(0, 1) }}</span>
<div>
<strong>{{ row.name }}</strong>
<span>{{ toFaDigits(row.dealCount) }} معامله</span>
</div>
<span class="rank">{{ trophy(row.rank) }}</span>
</li>
</ul>
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.85rem;
gap: 0.5rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
}
.hint {
margin: 0.15rem 0 0;
color: #94a3b8;
font-size: 0.7rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
font-weight: 600;
}
.top-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.top-list li {
display: flex;
align-items: center;
gap: 0.65rem;
}
.cust-avatar {
width: 38px;
height: 38px;
border-radius: var(--radius);
background: linear-gradient(145deg, #ccfbf1, #99f6e4);
color: #0f766e;
display: grid;
place-items: center;
font-weight: 600;
flex-shrink: 0;
}
.top-list div {
flex: 1;
min-width: 0;
display: grid;
gap: 0.1rem;
}
.top-list strong {
font-size: 0.84rem;
}
.top-list span {
color: #94a3b8;
font-size: 0.72rem;
}
.rank {
font-size: 1.15rem;
}
</style>
@@ -1,30 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
import {
Alert as AAlert,
Card as ACard,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
ListItem as AListItem,
Segmented as ASegmented,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import { UserOutlined } from '@/icons/ant-bridge';
import UserAvatar from '@/components/UserAvatar.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification';
import crownImg from '@/assets/club/crown.png';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [
{ value: 'today', label: 'امروز' },
{ value: 'month', label: 'این ماه' },
@@ -58,342 +40,316 @@ onMounted(() => {
});
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
const allItems = computed(() => board.value?.items ?? []);
interface PodiumSlot {
rank: number;
entry: GamificationLeaderEntryDto | null;
stepHeight: number;
avatarSize: number;
label: string;
gradient: string;
numberColor: string;
ringColor: string;
borderColor: string;
}
/** چیدمان ۳ پله سکوی قهرمانی به ترتیب ورزشی از راست به چپ: دوم (نقره)، اول (طلا - مرکز)، سوم (برنز) */
const podiumSlots = computed<PodiumSlot[]>(() => {
const items = allItems.value;
if (!items.length) return [];
const first = items[0] ?? null;
const second = items[1] ?? null;
const third = items[2] ?? null;
return [
{
rank: 2,
entry: second,
stepHeight: 74,
avatarSize: 48,
label: 'نقره',
gradient: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 45%, #94a3b8 100%)',
numberColor: '#334155',
ringColor: '#94a3b8',
borderColor: '#cbd5e1',
},
{
rank: 1,
entry: first,
stepHeight: 110,
avatarSize: 58,
label: 'طلا',
gradient: 'linear-gradient(180deg, #fef08a 0%, #f59e0b 55%, #d97706 100%)',
numberColor: '#78350f',
ringColor: '#f59e0b',
borderColor: '#f59e0b',
},
{
rank: 3,
entry: third,
stepHeight: 52,
avatarSize: 44,
label: 'برنز',
gradient: 'linear-gradient(180deg, #e8b896 0%, #b87333 50%, #7c4a1e 100%)',
numberColor: '#5c3317',
ringColor: '#b87333',
borderColor: '#cd7f32',
},
];
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
/** ترتیب نمایش از راست: سوم، اول، دوم */
const podium = computed(() => {
const [a, b, c] = top3.value;
return [c, a, b].filter((x): x is GamificationLeaderEntryDto => !!x);
});
const rest = computed(() => {
const items = allItems.value;
return items.length > 3 ? items.slice(3) : [];
const items = board.value?.items ?? [];
return top3.value.length ? items.slice(top3.value.length) : items;
});
function chgClass(change: number | null) {
return change && change > 0 ? 'up' : change && change < 0 ? 'down' : '';
}
const meOutside = computed(() => {
const b = board.value;
if (!b?.me) return null;
const inTop3 = allItems.value.slice(0, 3).some((i) => i.userId === b.me!.userId);
const inRest = rest.value.some((i) => i.userId === b.me!.userId);
return inTop3 || inRest ? null : b.me;
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
});
type Row = { key: string; sep: boolean; entry: GamificationLeaderEntryDto | null };
const rows = computed<Row[]>(() => {
const list: Row[] = rest.value.map((e) => ({ key: e.userId, sep: false, entry: e }));
if (meOutside.value) {
list.push({ key: '__sep', sep: true, entry: null });
list.push({ key: `__me-${meOutside.value.userId}`, sep: false, entry: meOutside.value });
}
return list;
});
function changeType(change: number | null): 'success' | 'danger' | undefined {
return change && change > 0 ? 'success' : change && change < 0 ? 'danger' : undefined;
}
</script>
<template>
<a-card title="جدول قهرمانان و رتبه‌بندی">
<a-flex vertical :gap="16">
<a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
<section class="board">
<div class="periods" role="tablist" aria-label="بازهٔ جدول امتیازات">
<button
v-for="p in PERIODS"
:key="p.value"
type="button"
role="tab"
:aria-selected="period === p.value"
:class="{ on: period === p.value }"
@click="period = p.value"
>
{{ p.label }}
</button>
</div>
<a-alert v-if="error" type="error" show-icon :message="error" />
<p v-if="error" class="err">{{ error }}</p>
<a-spin :spinning="loading">
<a-flex vertical :gap="16">
<!-- بخش سکوی قهرمانی ۳ پله با ارتفاع‌های نامساوی (Podium) -->
<div
v-if="podiumSlots.length"
:style="{
position: 'relative',
borderRadius: `${token.borderRadiusLG}px`,
background: 'radial-gradient(ellipse at top, rgba(16, 185, 129, 0.06), transparent 70%), linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%)',
border: `1px solid ${token.colorBorderSecondary}`,
padding: '24px 12px 0 12px',
overflow: 'hidden',
}"
>
<!-- ردیف ۳ پله قهرمانی هم‌تراز در کف سکو -->
<a-flex justify="center" align="flex-end" :gap="12" :style="{ minHeight: '220px', width: '100%' }">
<div
v-for="slot in podiumSlots"
:key="slot.rank"
:style="{
flex: 1,
maxWidth: '140px',
minWidth: '85px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
}"
>
<!-- اطلاعات کاربر بالای پله (تصویر، نام، امتیاز) -->
<a-flex
vertical
align="center"
:gap="4"
:style="{
width: '100%',
marginBottom: '8px',
textAlign: 'center',
minWidth: 0,
}"
>
<!-- تاج درخشان نفر اول -->
<div
v-if="slot.rank === 1"
:style="{
height: '26px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}"
>
<img
:src="crownImg"
alt="نفر اول"
:style="{ width: '28px', height: '28px', objectFit: 'contain' }"
/>
</div>
<div v-else :style="{ height: '26px' }" />
<div v-if="podium.length" class="top3" :class="{ loading }">
<div
v-for="e in podium"
:key="e.userId"
class="t-card"
:class="[`r${e.rank}`, { me: e.isMe }]"
>
<img v-if="e.rank === 1" :src="crownImg" alt="" class="t-crown" />
<span class="t-ava">
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="e.rank === 1 ? 50 : 46" />
</span>
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
<small>{{ faNum(e.score) }} امتیاز</small>
</div>
</div>
<!-- عکس / آواتار کاربر با حلقه متالیک متناسب با رتبه -->
<template v-if="slot.entry">
<span
:style="{
display: 'inline-flex',
padding: '2px',
borderRadius: '999px',
background: '#ffffff',
boxShadow: `0 0 0 3px ${slot.ringColor}, 0 4px 10px rgba(0, 0, 0, 0.1)`,
}"
>
<UserAvatar
:user-id="slot.entry.userId"
:has-avatar="slot.entry.hasAvatar"
:name="slot.entry.fullName"
:size="slot.avatarSize"
/>
</span>
<a-flex align="center" :gap="4" :style="{ maxWidth: '100%' }">
<a-typography-text
strong
:ellipsis="true"
:style="{
fontSize: slot.rank === 1 ? '13px' : '12px',
color: slot.entry.isMe ? token.colorPrimary : token.colorTextHeading,
maxWidth: '100%',
}"
>
{{ slot.entry.isMe ? 'شما' : slot.entry.fullName }}
</a-typography-text>
</a-flex>
<a-tag
v-if="slot.entry.isMe"
color="success"
:bordered="false"
:style="{ margin: 0, fontSize: '10px', lineHeight: '18px', padding: '0 6px' }"
>
شما
</a-tag>
<a-typography-text
:style="{
fontSize: '12px',
fontWeight: 'bold',
color: slot.rank === 1 ? '#d97706' : token.colorTextSecondary,
}"
>
{{ faNum(slot.entry.score) }} امتیاز
</a-typography-text>
</template>
<!-- حالت خالی اگر در تیم جایگاه پر نشده باشد -->
<template v-else>
<span
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: `${slot.avatarSize}px`,
height: `${slot.avatarSize}px`,
borderRadius: '999px',
background: 'rgba(0, 0, 0, 0.04)',
border: '2px dashed #cbd5e1',
color: '#94a3b8',
}"
>
<UserOutlined :style="{ fontSize: '20px' }" />
</span>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
خالی
</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: '11px' }">
—
</a-typography-text>
</template>
</a-flex>
<!-- پله سکوی قهرمانی با ارتفاع اختصاصی و جلوه ۳ بعدی -->
<div
:style="{
width: '100%',
height: `${slot.stepHeight}px`,
borderRadius: '12px 12px 0 0',
background: slot.gradient,
border: `1px solid ${slot.borderColor}`,
borderBottom: 'none',
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.06), inset 0 1px 2px rgba(255, 255, 255, 0.7)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}"
>
<span
:style="{
fontSize: slot.rank === 1 ? '28px' : '22px',
fontWeight: 900,
color: slot.numberColor,
lineHeight: 1,
textShadow: '0 1px 1px rgba(255, 255, 255, 0.8)',
}"
>
{{ toFaDigits(slot.rank) }}
</span>
<span
:style="{
fontSize: '11px',
fontWeight: 700,
color: slot.numberColor,
opacity: 0.85,
marginTop: '2px',
}"
>
{{ slot.label }}
</span>
</div>
</div>
</a-flex>
<!-- خط پایه و زمین زیر پله‌های سکو -->
<div
:style="{
width: 'calc(100% + 24px)',
marginInline: '-12px',
height: '10px',
background: '#e2e8f0',
borderTop: '2px solid #cbd5e1',
}"
/>
</div>
<!-- لیست نفرات چهارم به بعد در جدول -->
<a-list v-if="rows.length" :data-source="rows" :split="true">
<template #renderItem="{ item }">
<a-divider v-if="item.sep" :style="{ margin: 0 }">⋯</a-divider>
<a-list-item
v-else-if="item.entry"
:style="item.entry.isMe ? { background: token.colorWarningBg, borderRadius: `${token.borderRadius}px` } : undefined"
>
<a-flex align="center" :gap="12" :style="{ width: '100%' }">
<a-typography-text type="secondary" :style="{ width: '28px', textAlign: 'center', fontWeight: 'bold' }">
{{ toFaDigits(item.entry.rank) }}
</a-typography-text>
<UserAvatar
:user-id="item.entry.userId"
:has-avatar="item.entry.hasAvatar"
:name="item.entry.fullName"
:size="32"
/>
<a-typography-text
:strong="item.entry.isMe"
:ellipsis="true"
:style="{ flex: 1, minWidth: 0 }"
>
{{ item.entry.isMe ? 'شما' : item.entry.fullName }}
</a-typography-text>
<a-typography-text :strong="item.entry.isMe">
{{ faNum(item.entry.score) }} امتیاز
</a-typography-text>
<a-typography-text
:type="changeType(item.entry.change)"
strong
:style="{ minWidth: '32px', textAlign: 'end' }"
>
<template v-if="item.entry.change">
{{ item.entry.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(item.entry.change)) }}
</template>
</a-typography-text>
</a-flex>
</a-list-item>
<ol class="rows" :class="{ loading }">
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
<span class="rank">{{ toFaDigits(e.rank) }}</span>
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="28" />
<b class="who">{{ e.isMe ? 'شما' : e.fullName }}</b>
<span class="score">{{ faNum(e.score) }}</span>
<span class="chg" :class="chgClass(e.change)">
<template v-if="e.change">{{ e.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(e.change)) }}</template>
</span>
</li>
<template v-if="meOutside">
<li class="sep" aria-hidden="true">⋯</li>
<li class="me">
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="28" />
<b class="who">شما</b>
<span class="score">{{ faNum(meOutside.score) }}</span>
<span class="chg" :class="chgClass(meOutside.change)">
<template v-if="meOutside.change">
{{ meOutside.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(meOutside.change)) }}
</template>
</a-list>
<a-empty
v-if="board && !board.items.length"
description="هنوز کسی در این بازه امتیاز نگرفته است."
/>
</a-flex>
</a-spin>
</a-flex>
</a-card>
</span>
</li>
</template>
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
</ol>
</section>
</template>
<style scoped>
.board {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.75rem 0.9rem 0.5rem;
box-shadow: var(--shadow-xs);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
min-width: 0;
}
.periods {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 3px;
border-radius: 12px;
background: #f8fafc;
}
.periods button {
border: 0;
background: none;
border-radius: 10px;
padding: 0.5rem 0.6rem;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: #64748b;
cursor: pointer;
white-space: nowrap;
}
.periods button.on {
background: #e0e7ff;
color: #1e1b4b;
}
.err {
margin: 0;
color: var(--danger);
font-size: var(--fs-sm);
}
.top3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
align-items: end;
padding-top: 0.8rem;
}
.t-card {
--bg: #e0e7ff;
--ring: #a5b4fc;
position: relative;
display: grid;
justify-items: center;
gap: 4px;
padding: 0 0.4rem 0.7rem;
border-radius: 14px;
background: var(--bg);
text-align: center;
min-width: 0;
margin-top: 1.6rem;
}
.t-card.r1 {
--bg: #fef3c7;
--ring: #f59e0b;
padding-bottom: 1rem;
margin-top: 0;
}
.t-card.r3 {
--bg: #ffedd5;
--ring: #fb923c;
}
.t-card.me {
box-shadow: 0 0 0 2px var(--accent);
}
.t-crown {
position: absolute;
top: -1.55rem;
width: 30px;
height: 30px;
object-fit: contain;
z-index: 1;
}
.t-ava {
margin-top: -1.4rem;
padding: 3px;
border-radius: 999px;
background: #fff;
box-shadow: 0 0 0 3px var(--ring);
display: grid;
}
.t-card.r1 .t-ava {
margin-top: -0.4rem;
}
.t-card strong {
margin-top: 0.3rem;
font-size: 0.92rem;
font-weight: 800;
color: #0f172a;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-card small {
font-size: 0.76rem;
color: #475569;
}
.t-card.r1 small {
color: #b45309;
font-weight: 700;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
.rows li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.7rem;
border-bottom: 1px solid #f1f5f9;
}
.rows li:last-child {
border-bottom: 0;
}
.rows li.me {
background: #fef9e7;
border-radius: 10px;
border-bottom-color: transparent;
}
.rows li.me .score,
.rows li.me .who {
font-weight: 800;
}
.rows li.sep {
justify-content: center;
color: var(--muted-light);
padding: 0;
border: 0;
}
.chg {
min-width: 1.8rem;
font-size: 0.74rem;
font-weight: 800;
flex: 0 0 auto;
}
.chg.up {
color: #16a34a;
}
.chg.down {
color: #ef4444;
}
.score {
min-width: 2.8rem;
font-size: 0.84rem;
font-weight: 600;
color: #0f172a;
flex: 0 0 auto;
}
.who {
flex: 1;
min-width: 0;
text-align: start;
font-size: 0.84rem;
font-weight: 600;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank {
width: 1.6rem;
text-align: center;
font-size: 0.82rem;
color: #64748b;
flex: 0 0 auto;
}
.loading {
opacity: 0.55;
}
.empty {
justify-content: center;
color: var(--muted);
font-size: var(--fs-sm);
border: 0 !important;
}
@media (max-width: 520px) {
.top3 {
gap: 6px;
}
.t-card strong {
font-size: 0.8rem;
}
}
</style>
@@ -1,56 +0,0 @@
import type { Component } from 'vue';
import {
AimOutlined,
BankOutlined,
CalendarOutlined,
CheckCircleOutlined,
ContainerOutlined,
CrownOutlined,
DollarOutlined,
FileTextOutlined,
FilterOutlined,
FireOutlined,
GiftOutlined,
MessageOutlined,
PhoneOutlined,
RiseOutlined,
SafetyCertificateOutlined,
ShoppingOutlined,
SlidersOutlined,
StarOutlined,
SyncOutlined,
TeamOutlined,
TrophyOutlined,
UserAddOutlined,
} from '@/icons/ant-bridge';
/** نگاشت نام آیکون‌های باشگاه (txIcon / پاداش / نشان) به آیکون‌های Ant Design */
const CLUB_ICONS: Record<string, Component> = {
trophy: TrophyOutlined,
'user-plus': UserAddOutlined,
funnel: FilterOutlined,
briefcase: ContainerOutlined,
phone: PhoneOutlined,
calendar: CalendarOutlined,
'calendar-sun': CalendarOutlined,
document: FileTextOutlined,
check: CheckCircleOutlined,
refresh: SyncOutlined,
dollar: DollarOutlined,
chat: MessageOutlined,
star: StarOutlined,
medal: SafetyCertificateOutlined,
gift: GiftOutlined,
sliders: SlidersOutlined,
building: BankOutlined,
trend: RiseOutlined,
crown: CrownOutlined,
bag: ShoppingOutlined,
target: AimOutlined,
fire: FireOutlined,
users: TeamOutlined,
};
export function clubIcon(name: string | null | undefined): Component {
return (name && CLUB_ICONS[name]) || StarOutlined;
}
+5 -5
View File
@@ -102,13 +102,13 @@ onUnmounted(() => {
<style scoped>
.hub {
width: min(28rem, calc(100vw - var(--hub-offset, 16rem) - 1.5rem));
max-height: calc(100dvh - 16px - var(--control-h) - 28px);
max-height: calc(100dvh - 56px);
overflow: hidden;
padding: 0.9rem 0.9rem 0.8rem;
display: grid;
align-content: start;
gap: 0.7rem;
border-radius: 16px;
border-radius: 28px;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
@@ -204,7 +204,7 @@ onUnmounted(() => {
gap: 0.28rem;
padding: 0.65rem 0.7rem 0.55rem;
border: 0;
border-radius: 12px;
border-radius: 16px;
background: #f7f8fa;
cursor: pointer;
text-align: start;
@@ -267,7 +267,7 @@ onUnmounted(() => {
min-height: 2.4rem;
padding: 0.45rem 0.7rem;
border: 0;
border-radius: 10px;
border-radius: 999px;
background: #ecfdf8;
color: #0f766e;
font: inherit;
@@ -316,7 +316,7 @@ onUnmounted(() => {
max-height: min(86dvh, calc(100dvh - 3.5rem));
overflow: auto;
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 16px 16px 0 0;
border-radius: 24px 24px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
}
@@ -1,21 +1,9 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import {
Button as AButton,
Flex as AFlex,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import {
CalendarOutlined,
PlusOutlined,
UserAddOutlined,
} from '@/icons/ant-bridge';
import UiIcon from '@/components/UiIcon.vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const props = withDefaults(
withDefaults(
defineProps<{
title?: string;
description?: string;
@@ -32,24 +20,15 @@ const props = withDefaults(
secondaryIcon: 'calendar-plus',
},
);
const router = useRouter();
function go(to: RouteLocationRaw) {
void router.push(to);
}
const secondaryIconComp = () => {
if (props.secondaryIcon === 'user-plus') return UserAddOutlined;
return CalendarOutlined;
};
</script>
<template>
<a-flex vertical align="center" :gap="16" :style="{ width: '100%', textAlign: 'center' }">
<div aria-hidden="true" :style="{ width: '168px' }">
<span aria-hidden="true">✦</span>
<svg viewBox="0 0 168 132" fill="none" :style="{ width: '168px', height: 'auto', display: 'block' }">
<div class="empty-state">
<div class="art" aria-hidden="true">
<span class="spark s1">✦</span>
<span class="spark s2">✦</span>
<span class="spark s3">✦</span>
<svg viewBox="0 0 168 132" fill="none">
<path
d="M26 44c0-7.2 5.8-13 13-13h30l9 9h51c7.2 0 13 5.8 13 13v54c0 7.2-5.8 13-13 13H39c-7.2 0-13-5.8-13-13V44Z"
fill="#D9E6E8"
@@ -73,24 +52,147 @@ const secondaryIconComp = () => {
</svg>
</div>
<a-typography-title :level="4" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">{{ description }}</a-typography-text>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<a-space direction="vertical" style="width: min(100%, 304px)">
<a-button type="primary" block @click="go(primaryTo)">
<template #icon><PlusOutlined /></template>
<div class="actions">
<RouterLink class="btn-primary" :to="primaryTo">
<span class="plus">+</span>
{{ primaryLabel }}
</a-button>
<a-button
v-if="secondaryLabel && secondaryTo"
block
@click="go(secondaryTo)"
>
<template #icon>
<component :is="secondaryIconComp()" />
</template>
</RouterLink>
<RouterLink v-if="secondaryLabel && secondaryTo" class="btn-secondary" :to="secondaryTo">
<UiIcon :name="secondaryIcon" :size="18" />
{{ secondaryLabel }}
</a-button>
</a-space>
</a-flex>
</RouterLink>
</div>
</div>
</template>
<style scoped>
.empty-state {
display: grid;
justify-items: center;
align-content: center;
text-align: center;
width: 100%;
padding: 1.4rem 0.8rem 0.6rem;
}
.art {
position: relative;
width: 168px;
margin-bottom: 1.05rem;
}
.art svg {
width: 168px;
height: auto;
display: block;
}
.spark {
position: absolute;
color: #5eead4;
font-size: 0.68rem;
line-height: 1;
}
.s1 {
top: 6px;
inset-inline-end: 8px;
}
.s2 {
top: 44px;
inset-inline-start: 2px;
font-size: 0.52rem;
color: #99f6e4;
}
.s3 {
bottom: 20px;
inset-inline-end: -2px;
font-size: 0.46rem;
color: #99f6e4;
}
h3 {
margin: 0;
font-size: 1.08rem;
font-weight: 600;
color: #1e2937;
}
p {
margin: 0.5rem 0 1.25rem;
max-width: 21.5rem;
color: #94a3b8;
font-size: 0.84rem;
line-height: 1.85;
}
.actions {
display: grid;
gap: 0.7rem;
width: min(100%, 304px);
}
.btn-primary,
.btn-secondary {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
font-weight: 700;
font-size: 0.9rem;
}
.btn-primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.26);
}
.plus {
font-size: 1.2rem;
line-height: 1;
font-weight: 600;
}
.btn-secondary {
background: #fff;
color: #0f766e;
border: 1px solid #e2e8f0;
}
@media (max-width: 599px) {
.empty-state {
padding: 1.2rem 0.2rem 0.4rem;
}
.art,
.art svg {
width: 152px;
}
h3 {
font-size: 1.02rem;
}
p {
font-size: 0.8rem;
margin-bottom: 1.15rem;
}
.btn-primary,
.btn-secondary {
min-height: var(--control-h);
height: var(--control-h);
font-size: 0.86rem;
border-radius: var(--radius);
}
}
</style>
@@ -1,13 +1,8 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import {
Button as AButton,
Dropdown as ADropdown,
Menu as AMenu,
MenuItem as AMenuItem,
} from 'ant-design-vue';
import { MoreOutlined } from '@/icons/ant-bridge';
import UiIcon from '@/components/UiIcon.vue';
export type ProfileMoreItem = {
key: string;
@@ -20,40 +15,207 @@ const open = defineModel<boolean>({ default: false });
defineProps<{ items: ProfileMoreItem[] }>();
const emit = defineEmits<{ select: [key: string] }>();
function onSelect(key: string) {
emit('select', key);
const triggerRef = ref<HTMLButtonElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 220;
const height = panel?.offsetHeight || 0;
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
let nextLeft = rtl ? rect.right - width : rect.left;
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
watch(open, async (value) => {
if (!value) {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
return;
}
await nextTick();
place();
await nextTick();
place();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false;
}
function onLinkClick() {
open.value = false;
function scheduleClose() {
window.setTimeout(() => {
close();
}, 0);
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
close();
});
}
</script>
<template>
<a-dropdown v-model:open="open" :trigger="['click']" placement="bottomLeft">
<a-button aria-label="منوی بیشتر" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click.stop>
<a-menu-item
v-for="item in items"
:key="item.key"
:danger="item.danger"
@click="item.to ? onLinkClick() : onSelect(item.key)"
<div class="wrap" @click.stop>
<button
ref="triggerRef"
class="icon-btn"
type="button"
aria-label="منوی بیشتر"
:aria-expanded="open"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
<Teleport to="body">
<div v-if="open" class="layer">
<button class="backdrop" type="button" aria-label="بستن منو" @click="close" />
<ul
ref="panelRef"
class="panel"
role="menu"
:style="{ top: `${top}px`, left: `${left}px` }"
@click.stop
>
<RouterLink
v-if="item.to"
:to="item.to"
:style="{ color: 'inherit', display: 'block' }"
@click="onLinkClick"
>
{{ item.label }}
</RouterLink>
<span v-else>{{ item.label }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<li v-for="item in items" :key="item.key" role="none">
<RouterLink
v-if="item.to"
class="item"
:class="{ danger: item.danger }"
role="menuitem"
:to="item.to"
@click.stop="scheduleClose"
>
{{ item.label }}
</RouterLink>
<button
v-else
class="item"
:class="{ danger: item.danger }"
type="button"
role="menuitem"
@click.stop.prevent="onSelect(item.key)"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</Teleport>
</div>
</template>
<style scoped>
.wrap {
position: relative;
flex-shrink: 0;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: #fff;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #334155;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
cursor: pointer;
}
.layer {
position: fixed;
inset: 0;
z-index: 80;
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: default;
}
.panel {
position: absolute;
z-index: 1;
margin: 0;
padding: 0.4rem;
list-style: none;
width: 220px;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
gap: 2px;
}
.panel > li {
display: block;
width: 100%;
}
.item {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: var(--radius);
background: transparent;
color: #0f172a;
font: inherit;
font-weight: 600;
font-size: 0.9rem;
line-height: 1.6;
text-align: start;
text-decoration: none;
white-space: nowrap;
padding: 0.7rem 0.85rem;
cursor: pointer;
}
.item:hover {
background: #f0fdfa;
}
.item.danger {
color: #b91c1c;
}
.item.danger:hover {
background: #fef2f2;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PlusOutlined } from '@/icons/ant-bridge';
import { PlusOutlined } from '@ant-design/icons-vue';
import { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
@@ -6,7 +6,7 @@ import {
DeleteOutlined,
FlagFilled,
MoreOutlined,
} from '@/icons/ant-bridge';
} from '@ant-design/icons-vue';
import {
Avatar as AAvatar,
Button as AButton,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { PlusOutlined } from '@/icons/ant-bridge';
import { PlusOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Card as ACard,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Dropdown as ADropdown,
@@ -5,7 +5,7 @@ import {
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@/icons/ant-bridge';
} from '@ant-design/icons-vue';
import {
Avatar as AAvatar,
Button as AButton,
@@ -9,31 +9,8 @@ import type {
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Steps as ASteps,
Step as AStep,
Typography,
} from 'ant-design-vue';
import {
AppstoreOutlined,
AimOutlined,
CheckCircleOutlined,
} from '@/icons/ant-bridge';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
import { UiButton, UiField, UiSelect } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
type Step = 'target' | 'map' | 'review';
@@ -54,23 +31,10 @@ const stageMap = ref<Record<string, string>>({});
const busy = ref(false);
const error = ref('');
const stepIndex = computed(() => {
if (step.value === 'target') return 0;
if (step.value === 'map') return 1;
return 2;
});
const selectedTarget = computed(
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null,
);
const targetOptions = computed(() =>
(preview.value?.targets ?? []).map((target) => ({
value: target.id,
label: target.name,
})),
);
const canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
const canContinueMap = computed(() => {
const current = preview.value;
@@ -107,13 +71,6 @@ function compatibleTargets(src: PipelineMergePreviewStageDto) {
});
}
function compatibleOptions(src: PipelineMergePreviewStageDto) {
return compatibleTargets(src).map((stage) => ({
value: stage.id,
label: stage.name,
}));
}
function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
if (stage.isWon) return 'برد';
if (stage.isLost) return 'باخت';
@@ -214,181 +171,298 @@ watch(targetId, () => {
</script>
<template>
<a-flex vertical :gap="16">
<a-steps :current="stepIndex">
<a-step title="مقصد" />
<a-step title="نگاشت" />
<a-step title="بازبینی" />
</a-steps>
<div class="merge-wizard">
<ol class="steps" aria-label="مراحل ادغام کاریز">
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li>
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li>
<li :class="{ active: step === 'review' }">۳. بازبینی</li>
</ol>
<a-spin :spinning="busy && !preview">
<template v-if="preview">
<a-flex v-if="step === 'target'" vertical :gap="16">
<a-flex :gap="8" align="flex-start">
<AppstoreOutlined />
<a-flex vertical :gap="4">
<a-typography-title :level="5" :style="{ margin: 0 }">
کاریز مقصد را انتخاب کنید
</a-typography-title>
<a-typography-text type="secondary">
معاملات و فرم‌های سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل می‌شوند.
</a-typography-text>
</a-flex>
</a-flex>
<p v-if="busy && !preview" class="muted">در حال آماده‌سازی پیش‌نمایش…</p>
<a-form layout="vertical">
<a-form-item label="کاریز مقصد" required>
<a-select
v-model:value="targetId"
:options="targetOptions"
placeholder="انتخاب کنید"
style="width: 100%"
/>
</a-form-item>
</a-form>
<template v-else-if="preview">
<section v-if="step === 'target'" class="step-panel">
<div class="intro">
<UiIcon name="layers" :size="20" />
<div>
<h3>کاریز مقصد را انتخاب کنید</h3>
<p>
معاملات و فرم‌های سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل می‌شوند.
</p>
</div>
</div>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :md="8">
<a-card :bordered="true">
<a-typography-text strong>{{ toFaDigits(preview.dealCount) }}</a-typography-text>
<br />
<a-typography-text type="secondary">معامله منتقل می‌شود</a-typography-text>
</a-card>
</a-col>
<a-col :xs="24" :md="8">
<a-card :bordered="true">
<a-typography-text strong>{{ toFaDigits(preview.leadForms.length) }}</a-typography-text>
<br />
<a-typography-text type="secondary">فرم سرنخ به مقصد وصل می‌شود</a-typography-text>
</a-card>
</a-col>
<a-col v-if="preview.source.isDefault" :xs="24" :md="8">
<a-alert
type="warning"
show-icon
message="هشدار"
description="کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود."
/>
</a-col>
</a-row>
<UiField label="کاریز مقصد" required>
<UiSelect v-model="targetId" required>
<option value="" disabled>انتخاب کنید</option>
<option v-for="target in preview.targets" :key="target.id" :value="target.id">
{{ target.name }}
</option>
</UiSelect>
</UiField>
<a-alert
v-if="selectedTarget?.isDefault"
type="info"
show-icon
message="مقصد انتخاب‌شده کاریز پیش‌فرض است."
/>
</a-flex>
<div class="summary-grid">
<article>
<strong>{{ toFaDigits(preview.dealCount) }}</strong>
<span>معامله منتقل می‌شود</span>
</article>
<article>
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong>
<span>فرم سرنخ به مقصد وصل می‌شود</span>
</article>
<article v-if="preview.source.isDefault" class="warn">
<strong>هشدار</strong>
<span>کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود.</span>
</article>
</div>
<a-flex v-else-if="step === 'map'" vertical :gap="16">
<a-flex :gap="8" align="flex-start">
<AimOutlined />
<a-flex vertical :gap="4">
<a-typography-title :level="5" :style="{ margin: 0 }">
مرحله‌های مبدا را نگاشت کنید
</a-typography-title>
<a-typography-text type="secondary">
هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.
</a-typography-text>
</a-flex>
</a-flex>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p>
</section>
<a-form layout="vertical">
<a-row
v-for="sourceStage in preview.stages"
:key="sourceStage.id"
:gutter="[16, 0]"
align="middle"
>
<a-col :xs="24" :md="12">
<a-form-item :style="{ marginBottom: '12px' }">
<a-typography-text strong>{{ sourceStage.name }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
</a-typography-text>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="مرحله مقصد"
required
:validate-status="stageMap[sourceStage.id] ? undefined : 'error'"
:style="{ marginBottom: '12px' }"
>
<a-select
v-model:value="stageMap[sourceStage.id]"
:options="compatibleOptions(sourceStage)"
placeholder="مرحله مقصد"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-flex>
<section v-else-if="step === 'map'" class="step-panel">
<div class="intro">
<UiIcon name="target" :size="20" />
<div>
<h3>مرحله‌های مبدا را نگاشت کنید</h3>
<p>هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.</p>
</div>
</div>
<a-flex v-else vertical :gap="16">
<a-flex :gap="8" align="flex-start">
<CheckCircleOutlined />
<a-flex vertical :gap="4">
<a-typography-title :level="5" :style="{ margin: 0 }">بازبینی نهایی</a-typography-title>
<a-typography-paragraph type="secondary" :style="{ margin: 0 }">
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
کاریز «{{ preview.source.name }}» حذف می‌شود.
</a-typography-paragraph>
</a-flex>
</a-flex>
<div class="map-list">
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row">
<div class="stage-info">
<strong>{{ sourceStage.name }}</strong>
<span>
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
</span>
</div>
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]">
<option value="" disabled>مرحله مقصد</option>
<option
v-for="targetStage in compatibleTargets(sourceStage)"
:key="targetStage.id"
:value="targetStage.id"
>
{{ targetStage.name }}
</option>
</UiSelect>
</article>
</div>
</section>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="مبدا">{{ preview.source.name }}</a-descriptions-item>
<a-descriptions-item label="مقصد">{{ selectedTarget?.name }}</a-descriptions-item>
<a-descriptions-item label="روش فرم‌ها">
همه فرم‌های سرنخ به مقصد وصل می‌شوند.
</a-descriptions-item>
</a-descriptions>
</a-flex>
</template>
<section v-else class="step-panel">
<div class="intro">
<UiIcon name="check" :size="20" />
<div>
<h3>بازبینی نهایی</h3>
<p>
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
کاریز «{{ preview.source.name }}» حذف می‌شود.
</p>
</div>
</div>
<a-typography-text v-else-if="!busy && !error" type="secondary">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</a-typography-text>
</a-spin>
<dl class="review-list">
<div>
<dt>مبدا</dt>
<dd>{{ preview.source.name }}</dd>
</div>
<div>
<dt>مقصد</dt>
<dd>{{ selectedTarget?.name }}</dd>
</div>
<div>
<dt>روش فرم‌ها</dt>
<dd>همه فرم‌های سرنخ به مقصد وصل می‌شوند.</dd>
</div>
</dl>
</section>
</template>
<a-alert v-if="error" type="error" show-icon :message="error" />
<p v-else-if="!busy && !error" class="muted">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</p>
<a-space wrap>
<a-button :disabled="busy" @click="emit('close')">بستن</a-button>
<a-button v-if="step === 'map'" :disabled="busy" @click="step = 'target'">قبلی</a-button>
<a-button v-else-if="step === 'review'" :disabled="busy" @click="step = 'map'">قبلی</a-button>
<a-button
v-if="step === 'target'"
type="primary"
:disabled="!preview || !canContinueTarget"
@click="nextFromTarget"
>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton>
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'">
قبلی
</UiButton>
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'">
قبلی
</UiButton>
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
ادامه
</a-button>
<a-button
v-else-if="step === 'map'"
type="primary"
:disabled="!canContinueMap"
@click="nextFromMap"
>
</UiButton>
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap">
بازبینی
</a-button>
<a-button
v-else
type="primary"
danger
:loading="busy"
:disabled="!canContinueMap"
@click="submitMerge"
>
</UiButton>
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge">
ادغام و حذف مبدا
</a-button>
</a-space>
</a-flex>
</UiButton>
</div>
</div>
</template>
<style scoped>
.merge-wizard {
display: grid;
gap: 1rem;
}
.steps {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0;
margin: 0;
list-style: none;
}
.steps li {
padding: 0.3rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 0.85rem;
}
.steps li.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.steps li.done {
color: var(--ink);
}
.step-panel {
display: grid;
gap: 1rem;
}
.intro {
display: flex;
gap: 0.65rem;
align-items: flex-start;
}
.intro h3,
.intro p {
margin: 0;
}
.intro h3 {
font-size: 1rem;
}
.intro p {
margin-top: 0.25rem;
color: var(--muted);
line-height: 1.8;
}
.summary-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.summary-grid article {
display: grid;
gap: 0.2rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.summary-grid strong {
color: var(--ink);
}
.summary-grid span,
.muted,
.note {
color: var(--muted);
font-size: 0.9rem;
}
.summary-grid .warn {
border-color: var(--danger);
background: var(--danger-soft);
}
.map-list {
display: grid;
gap: 0.65rem;
}
.map-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 18rem);
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.stage-info {
display: grid;
gap: 0.25rem;
}
.stage-info span {
color: var(--muted);
font-size: 0.85rem;
}
.review-list {
display: grid;
gap: 0.65rem;
margin: 0;
}
.review-list div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--line);
}
.review-list dt,
.review-list dd {
margin: 0;
}
.review-list dt {
color: var(--muted);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-start;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
.error {
color: #b91c1c;
margin: 0;
}
@media (max-width: 720px) {
.map-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import {
AppstoreOutlined,
FundOutlined,
} from '@/icons/ant-bridge';
} from '@ant-design/icons-vue';
import {
Flex as AFlex,
InputNumber as AInputNumber,
@@ -11,20 +11,6 @@ import {
weekdayIndex,
} from '@/lib/routines';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import {
Card as ACard,
Col as ACol,
Flex as AFlex,
Progress as AProgress,
Row as ARow,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = defineProps<{
score: RoutineScoreDto | null;
@@ -43,98 +29,216 @@ const rows = computed(() => {
});
const strip = computed(() => props.days ?? []);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
const STATE_STYLES: Record<string, { bg: string; fg: string }> = {
complete: { bg: 'var(--ant-color-success-bg)', fg: 'var(--ant-color-success)' },
partial: { bg: 'var(--ant-color-warning-bg)', fg: 'var(--ant-color-warning)' },
missed: { bg: 'var(--ant-color-error-bg)', fg: 'var(--ant-color-error)' },
empty: { bg: 'var(--ant-color-fill-secondary)', fg: 'var(--ant-color-text-quaternary)' },
};
function stateStyle(state: string) {
return STATE_STYLES[state] ?? STATE_STYLES.empty;
}
</script>
<template>
<a-card :bordered="false">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-title :level="5" :style="{ margin: 0 }">امتیاز روزانه</a-typography-title>
<a-typography-text type="secondary" title="روزهای متوالی با انجام همه کارهای اجباری">
<a-typography-text strong :style="{ color: 'var(--ant-color-primary)' }">
{{ toFaDigits(score?.streak ?? 0) }}
</a-typography-text>
روز پیاپی کامل
</a-typography-text>
</a-flex>
<section class="score-card">
<header>
<h3>امتیاز روزانه</h3>
<span class="streak" :title="'روزهای متوالی با انجام همه کارهای اجباری'">
<strong>{{ toFaDigits(score?.streak ?? 0) }}</strong>
روز پیاپی کامل
</span>
</header>
<a-row :gutter="[16, 16]">
<a-col v-for="r in rows" :key="r.label" :span="12">
<a-flex justify="space-between" align="baseline" :gap="8">
<a-typography-text type="secondary">{{ r.label }}</a-typography-text>
<a-typography-text strong>{{ formatPercent(r.value) }}</a-typography-text>
</a-flex>
<a-progress
:percent="r.value ?? 0"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(r.value)]"
/>
</a-col>
</a-row>
<div class="scores">
<div v-for="r in rows" :key="r.label" class="score" :class="`tone-${percentTone(r.value)}`">
<span>{{ r.label }}</span>
<strong>{{ formatPercent(r.value) }}</strong>
<i class="bar"><b :style="{ width: `${r.value ?? 0}%` }" /></i>
</div>
</div>
<template v-if="strip.length">
<a-flex :gap="4" role="list" aria-label="وضعیت روزهای اخیر">
<a-flex
v-for="d in strip"
:key="d.date"
vertical
align="center"
:gap="2"
role="listitem"
:style="{ flex: 1, minWidth: 0 }"
>
<a-tooltip
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
>
<a-flex
justify="center"
align="center"
:style="{
width: '26px',
height: '26px',
flexShrink: 0,
borderRadius: token.borderRadiusSM,
background: stateStyle(d.state).bg,
color: stateStyle(d.state).fg,
}"
>
<a-typography-text
strong
:style="{ color: stateStyle(d.state).fg, lineHeight: 1 }"
>
{{ DAY_STATE_SYMBOLS[d.state] }}
</a-typography-text>
</a-flex>
</a-tooltip>
<a-typography-text type="secondary">
{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}
</a-typography-text>
</a-flex>
</a-flex>
<a-flex wrap="wrap" :gap="8">
<a-tag color="success" :bordered="false">✓ کامل</a-tag>
<a-tag color="warning" :bordered="false">△ ناقص</a-tag>
<a-tag color="error" :bordered="false">× از دست رفته</a-tag>
</a-flex>
</template>
</a-flex>
</a-card>
<div v-if="strip.length" class="strip-wrap">
<div class="strip" role="list" aria-label="وضعیت روزهای اخیر">
<span
v-for="d in strip"
:key="d.date"
role="listitem"
class="cell"
:class="`s-${d.state}`"
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
>
<em>{{ DAY_STATE_SYMBOLS[d.state] }}</em>
<small>{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}</small>
</span>
</div>
<div class="legend">
<span><i class="s-complete">✓</i> کامل</span>
<span><i class="s-partial">△</i> ناقص</span>
<span><i class="s-missed">×</i> از دست رفته</span>
</div>
</div>
</section>
</template>
<style scoped>
.score-card {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.streak {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-size: var(--fs-xs);
color: var(--muted);
}
.streak strong {
font-size: var(--fs-lg);
color: var(--accent-deep);
}
.scores {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.score {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 4px 8px;
}
.score span {
font-size: var(--fs-xs);
color: var(--muted);
}
.score strong {
font-size: var(--fs-md);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--line-strong);
transition: width 0.4s ease;
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.strip-wrap {
display: grid;
gap: var(--sp-2);
}
.strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 4px;
}
.cell {
display: grid;
justify-items: center;
gap: 2px;
}
.cell em {
width: 100%;
aspect-ratio: 1;
max-width: 26px;
border-radius: 6px;
display: grid;
place-items: center;
font-style: normal;
font-size: 0.75rem;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted-light);
}
.cell small {
font-size: 0.62rem;
color: var(--muted-light);
}
.s-complete,
.cell.s-complete em {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial,
.cell.s-partial em {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed,
.cell.s-missed em {
background: var(--danger-soft);
color: var(--danger);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.legend i {
width: 16px;
height: 16px;
border-radius: 4px;
display: inline-grid;
place-items: center;
font-style: normal;
font-size: 0.65rem;
font-weight: var(--fw-bold);
}
</style>
@@ -10,7 +10,7 @@ import {
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CloseOutlined } from '@/icons/ant-bridge';
import { CloseOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
+129 -80
View File
@@ -1,32 +1,14 @@
<script setup lang="ts">
import { computed, type Component } from 'vue';
import {
Avatar as AAvatar,
Card as ACard,
Flex as AFlex,
Statistic as AStatistic,
Typography,
} from 'ant-design-vue';
import {
AimOutlined,
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
FlagOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@/icons/ant-bridge';
import UiIcon from '@/components/UiIcon.vue';
const { Text: ATypographyText } = Typography;
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
const props = withDefaults(
withDefaults(
defineProps<{
label: string;
value: string;
hint?: string;
icon?: string;
icon?: IconName;
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
/** تغییر نسبت به دوره قبل (درصد یا عدد)؛ مثبت = بهتر */
delta?: number | null;
@@ -35,68 +17,135 @@ const props = withDefaults(
{ hint: '', icon: undefined, tone: 'neutral', delta: null, deltaSuffix: '' },
);
const ICONS: Record<string, Component> = {
check: CheckOutlined,
clock: ClockCircleOutlined,
warning: WarningOutlined,
target: AimOutlined,
'x-circle': CloseCircleOutlined,
calendar: CalendarOutlined,
flag: FlagOutlined,
users: TeamOutlined,
refresh: SyncOutlined,
};
const TONES: Record<string, { bg: string; fg: string }> = {
accent: { bg: 'var(--ant-color-primary-bg)', fg: 'var(--ant-color-primary)' },
success: { bg: 'var(--ant-color-success-bg)', fg: 'var(--ant-color-success)' },
warning: { bg: 'var(--ant-color-warning-bg)', fg: 'var(--ant-color-warning)' },
danger: { bg: 'var(--ant-color-error-bg)', fg: 'var(--ant-color-error)' },
info: { bg: 'var(--ant-color-info-bg)', fg: 'var(--ant-color-info)' },
neutral: { bg: 'var(--ant-color-fill-secondary)', fg: 'var(--ant-color-text-secondary)' },
};
const iconComp = computed(() => (props.icon ? (ICONS[props.icon] ?? AimOutlined) : null));
const toneStyle = computed(() => TONES[props.tone] ?? TONES.neutral);
function fa(n: number) {
return Math.abs(n).toLocaleString('fa-IR');
}
</script>
<template>
<a-card :bordered="false" :style="{ height: '100%' }">
<a-flex vertical :gap="6">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">{{ label }}</a-typography-text>
<a-avatar
v-if="iconComp"
:style="{ background: toneStyle.bg, color: toneStyle.fg, flexShrink: 0 }"
>
<template #icon><component :is="iconComp" /></template>
</a-avatar>
</a-flex>
<a-statistic
:value="value"
:value-style="
tone === 'danger'
? { color: 'var(--ant-color-error)', fontWeight: 700 }
: { fontWeight: 700 }
"
/>
<a-flex v-if="hint || delta != null" align="center" wrap="wrap" :gap="6">
<a-typography-text
v-if="delta != null && delta !== 0"
:type="delta > 0 ? 'success' : 'danger'"
strong
>
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
</a-typography-text>
<a-typography-text v-else-if="delta === 0" type="secondary">
بدون تغییر
</a-typography-text>
<a-typography-text v-if="hint" type="secondary">{{ hint }}</a-typography-text>
</a-flex>
</a-flex>
</a-card>
<div class="kpi" :class="`tone-${tone}`">
<div class="kpi-top">
<span class="kpi-label">{{ label }}</span>
<span v-if="icon" class="kpi-ico"><UiIcon :name="icon" :size="16" /></span>
</div>
<strong class="kpi-value">{{ value }}</strong>
<div v-if="hint || delta != null" class="kpi-foot">
<span v-if="delta != null && delta !== 0" class="delta" :class="delta > 0 ? 'up' : 'down'">
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
</span>
<span v-else-if="delta === 0" class="delta flat">بدون تغییر</span>
<span v-if="hint" class="kpi-hint">{{ hint }}</span>
</div>
</div>
</template>
<style scoped>
.kpi {
display: grid;
gap: 6px;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
min-width: 0;
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.kpi-label {
font-size: var(--fs-sm);
color: var(--muted);
font-weight: var(--fw-medium);
}
.kpi-ico {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.tone-accent .kpi-ico {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.tone-success .kpi-ico {
background: var(--success-soft);
color: var(--success-deep);
}
.tone-warning .kpi-ico {
background: var(--warning-soft);
color: var(--warning-deep);
}
.tone-danger .kpi-ico {
background: var(--danger-soft);
color: var(--danger);
}
.tone-info .kpi-ico {
background: var(--info-soft);
color: var(--info-deep);
}
.kpi-value {
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.tone-danger .kpi-value {
color: var(--danger);
}
.kpi-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: var(--fs-xs);
}
.kpi-hint {
color: var(--muted);
}
.delta {
font-weight: var(--fw-semibold);
}
.delta.up {
color: var(--success-deep);
}
.delta.down {
color: var(--danger);
}
.delta.flat {
color: var(--muted);
}
@media (max-width: 900px) {
.kpi {
padding: var(--sp-3);
}
.kpi-value {
font-size: var(--fs-xl);
}
}
</style>
@@ -7,11 +7,11 @@ import {
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CheckOutlined, PlusOutlined } from '@/icons/ant-bridge';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
@@ -132,8 +132,7 @@ function onCreated(t: RoutineTaskDto) {
:style="{ marginTop: '12px' }"
/>
<CrmSkeleton :loading="loading && !items.length">
<CrmSkeletonList :rows="4">
<a-spin :spinning="loading && !items.length">
<a-empty
v-if="!loading && !items.length"
:description="emptyTitle"
@@ -186,8 +185,7 @@ function onCreated(t: RoutineTaskDto) {
/>
</template>
</a-flex>
</CrmSkeletonList>
</CrmSkeleton>
</a-spin>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
@@ -4,15 +4,6 @@ import type { RoutineCalendarDayDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import { dateToJalali, toFaDigits } from '@/lib/jalali';
import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines';
import {
Button as AButton,
Flex as AFlex,
Typography,
theme,
} from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = withDefaults(
defineProps<{
@@ -56,39 +47,6 @@ function heatLevel(d?: RoutineCalendarDayDto): number {
return 1;
}
const STATE_BG: Record<string, string> = {
complete: 'var(--ant-color-success-bg)',
partial: 'var(--ant-color-warning-bg)',
missed: 'var(--ant-color-error-bg)',
empty: 'var(--ant-color-bg-container)',
future: 'var(--ant-color-fill-quaternary)',
};
const STATE_DOT: Record<string, string> = {
complete: 'var(--ant-color-success)',
partial: 'var(--ant-color-warning)',
missed: 'var(--ant-color-error)',
empty: 'var(--ant-color-text-quaternary)',
future: 'var(--ant-color-text-quaternary)',
};
const HEAT_BG: Record<number, string> = {
0: 'var(--ant-color-fill-quaternary)',
1: 'var(--ant-color-info-bg)',
2: 'var(--ant-color-primary-bg)',
3: 'var(--ant-color-success-bg)',
4: 'var(--ant-color-success)',
};
function cellKey(c: (typeof cells.value)[number]) {
return c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty');
}
function cellBackground(c: (typeof cells.value)[number]) {
if (props.mode === 'heat') return HEAT_BG[heatLevel(c.day)];
return STATE_BG[cellKey(c)] ?? STATE_BG.empty;
}
function title(c: (typeof cells.value)[number]) {
const d = c.day;
if (!d || !d.total) return 'بدون کار';
@@ -97,78 +55,211 @@ function title(c: (typeof cells.value)[number]) {
</script>
<template>
<a-flex vertical :gap="6">
<a-flex :gap="6">
<a-typography-text
v-for="w in ROUTINE_WEEKDAY_LABELS"
:key="w"
type="secondary"
strong
:style="{ flex: '1 1 0', minWidth: 0, textAlign: 'center' }"
<div class="mgrid" :class="[mode, { compact }]">
<div v-for="w in ROUTINE_WEEKDAY_LABELS" :key="w" class="wd">{{ compact ? w.charAt(0) : w }}</div>
<template v-for="c in cells" :key="c.key">
<span v-if="c.blank" class="cell blank" />
<button
v-else
type="button"
class="cell"
:class="[
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
{ sel: selected === c.key, today: c.key === today },
]"
:title="title(c)"
@click="emit('select', c.key)"
>
{{ compact ? w.charAt(0) : w }}
</a-typography-text>
</a-flex>
<a-flex wrap="wrap" :gap="6">
<template v-for="c in cells" :key="c.key">
<a-flex
v-if="c.blank"
:style="{ flex: '0 0 calc((100% - 36px) / 7)', minWidth: 0 }"
/>
<a-button
v-else
:title="title(c)"
:style="{
flex: '0 0 calc((100% - 36px) / 7)',
minWidth: 0,
height: compact ? '44px' : '74px',
padding: '8px',
background: cellBackground(c),
borderColor:
selected === c.key
? token.colorPrimary
: c.key === today
? token.colorPrimaryBorder
: undefined,
boxShadow: selected === c.key ? `0 0 0 1px ${token.colorPrimary}` : undefined,
}"
@click="emit('select', c.key)"
>
<a-flex
vertical
align="start"
justify="space-between"
:style="{ width: '100%', height: '100%' }"
>
<a-typography-text
strong
:style="c.key === today ? { color: 'var(--ant-color-primary)' } : undefined"
>
{{ toFaDigits(c.jd) }}
</a-typography-text>
<a-flex
v-if="!compact && c.day?.total"
justify="space-between"
align="center"
:style="{ width: '100%' }"
>
<a-typography-text type="secondary">
{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}
</a-typography-text>
<a-flex
v-if="mode === 'state'"
:style="{
width: '8px',
height: '8px',
borderRadius: token.borderRadiusLG,
background: STATE_DOT[cellKey(c)],
flexShrink: 0,
}"
/>
</a-flex>
</a-flex>
</a-button>
</template>
</a-flex>
</a-flex>
<span class="num">{{ toFaDigits(c.jd) }}</span>
<template v-if="!compact && c.day?.total">
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span>
<i v-if="mode === 'state'" class="dot" />
</template>
</button>
</template>
</div>
</template>
<style scoped>
.mgrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.wd {
text-align: center;
font-size: var(--fs-xs);
color: var(--muted);
font-weight: var(--fw-semibold);
padding-bottom: 2px;
}
.cell {
position: relative;
min-height: 74px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
cursor: pointer;
font: inherit;
text-align: right;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.cell.blank {
border: 0;
background: transparent;
cursor: default;
}
.cell:not(.blank):hover {
border-color: var(--line-strong);
}
.cell.sel {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.cell.today .num {
background: var(--accent);
color: #fff;
}
.num {
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 99px;
display: inline-grid;
place-items: center;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.frac {
font-size: var(--fs-xs);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
position: absolute;
top: 10px;
inset-inline-end: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-strong);
}
/* وضعیت روز */
.s-complete {
background: color-mix(in srgb, var(--success-soft) 45%, var(--surface));
}
.s-complete .dot {
background: var(--success);
}
.s-partial {
background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface));
}
.s-partial .dot {
background: var(--warning);
}
.s-missed {
background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface));
}
.s-missed .dot {
background: var(--danger);
}
.s-future {
background: var(--surface);
}
.s-empty {
background: var(--surface);
}
/* Heatmap */
.h-0 {
background: var(--surface-subtle);
}
.h-1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h-2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h-3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h-4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.h-3 .num,
.h-4 .num,
.h-3 .frac,
.h-4 .frac {
color: #fff;
}
.compact {
gap: 4px;
}
.compact .cell {
min-height: 0;
aspect-ratio: 1;
padding: 2px;
align-items: center;
justify-content: center;
border-color: transparent;
}
.compact .num {
font-size: 0.7rem;
min-width: 0;
height: auto;
}
@media (max-width: 700px) {
.mgrid {
gap: 3px;
}
.cell {
min-height: 54px;
padding: 4px;
align-items: center;
}
.frac {
font-size: 0.62rem;
}
.dot {
position: static;
width: 7px;
height: 7px;
}
}
</style>
@@ -19,7 +19,7 @@ import {
FlagOutlined,
MoreOutlined,
WarningOutlined,
} from '@/icons/ant-bridge';
} from '@ant-design/icons-vue';
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
@@ -1,26 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import type { RoutineMemberStatDto } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import { toFaDigits } from '@/lib/jalali';
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
import {
Empty as AEmpty,
Flex as AFlex,
Progress as AProgress,
Space as ASpace,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ members: RoutineMemberStatDto[] }>();
const emit = defineEmits<{ select: [userId: string] }>();
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
const sortKey = ref<SortKey>('percent');
const sortDir = ref<'asc' | 'desc'>('desc');
const columns: Array<{ key: SortKey; label: string }> = [
{ key: 'name', label: 'کاربر' },
{ key: 'total', label: 'کل کارها' },
{ key: 'completed', label: 'انجام شده' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'skipped', label: 'رد شده' },
{ key: 'percent', label: 'درصد انجام' },
{ key: 'streak', label: 'روز پیاپی کامل' },
{ key: 'avg', label: 'میانگین زمان انجام' },
];
function value(m: RoutineMemberStatDto, key: SortKey): number | string {
switch (key) {
case 'name':
@@ -34,101 +37,155 @@ function value(m: RoutineMemberStatDto, key: SortKey): number | string {
}
}
function sorterFor(key: SortKey) {
return (a: RoutineMemberStatDto, b: RoutineMemberStatDto) => {
const va = value(a, key);
const vb = value(b, key);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa');
return (va as number) - (vb as number);
};
}
const rows = computed(() => {
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...props.members].sort((a, b) => {
const va = value(a, sortKey.value);
const vb = value(b, sortKey.value);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir;
return ((va as number) - (vb as number)) * dir;
});
});
const columns = computed<TableColumnsType<RoutineMemberStatDto>>(() => [
{ title: 'کاربر', key: 'name', sorter: sorterFor('name'), defaultSortOrder: undefined },
{
title: 'کل کارها',
key: 'total',
sorter: sorterFor('total'),
customRender: ({ record }) => toFaDigits(record.total),
},
{
title: 'انجام شده',
key: 'completed',
sorter: sorterFor('completed'),
customRender: ({ record }) => toFaDigits(record.completed),
},
{
title: 'عقب‌افتاده',
key: 'overdue',
sorter: sorterFor('overdue'),
customRender: ({ record }) => toFaDigits(record.overdue),
},
{
title: 'رد شده',
key: 'skipped',
sorter: sorterFor('skipped'),
customRender: ({ record }) => toFaDigits(record.skipped),
},
{ title: 'درصد انجام', key: 'percent', sorter: sorterFor('percent'), defaultSortOrder: 'descend' },
{
title: 'روز پیاپی کامل',
key: 'streak',
sorter: sorterFor('streak'),
customRender: ({ record }) => toFaDigits(record.streak),
},
{
title: 'میانگین زمان انجام',
key: 'avg',
sorter: sorterFor('avg'),
customRender: ({ record }) => formatMinutes(record.avgCompletionMinutes),
},
]);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
function rowProps(record: RoutineMemberStatDto) {
return {
onClick: () => emit('select', record.user.id),
style: { cursor: 'pointer' },
};
function sortBy(key: SortKey) {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc';
}
}
</script>
<template>
<a-table
:columns="columns"
:data-source="props.members"
:pagination="false"
:row-key="(r: RoutineMemberStatDto) => r.user.id"
:custom-row="rowProps"
:scroll="{ x: 'max-content' }"
>
<template #emptyText>
<a-empty description="داده‌ای برای این بازه نیست." />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-space :size="8">
<UserAvatar :name="record.user.fullName" :user-id="record.user.id" :size="28" />
<a-typography-text strong>{{ record.user.fullName }}</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'percent'">
<a-flex align="center" :gap="8" :style="{ minWidth: '8rem' }">
<a-typography-text strong>{{ formatPercent(record.percent) }}</a-typography-text>
<a-progress
:percent="Math.max(0, Math.min(100, Math.round(record.percent ?? 0)))"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(record.percent)]"
:style="{ flex: 1 }"
/>
</a-flex>
</template>
</template>
</a-table>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th
v-for="c in columns"
:key="c.key"
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
>
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)">
{{ c.label }}
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)">
<td>
<span class="who">
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" />
{{ m.user.fullName }}
</span>
</td>
<td>{{ toFaDigits(m.total) }}</td>
<td class="ok">{{ toFaDigits(m.completed) }}</td>
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td>
<td>{{ toFaDigits(m.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(m.percent)}`">
<strong>{{ formatPercent(m.percent) }}</strong>
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
</span>
</td>
<td>{{ toFaDigits(m.streak) }}</td>
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty">داده‌ای برای این بازه نیست.</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.sort.on {
color: var(--accent-deep);
}
.arrow {
font-size: 0.6rem;
opacity: 0.7;
}
.clickable {
cursor: pointer;
}
.who {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-weight: var(--fw-medium);
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.nowrap {
white-space: nowrap;
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
</style>
@@ -1,19 +0,0 @@
<script setup lang="ts">
import { provide, toRef } from 'vue';
import { CRM_SKELETON_LOADING } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
}>(),
{ loading: false },
);
provide(CRM_SKELETON_LOADING, toRef(props, 'loading'));
</script>
<template>
<div class="crm-skeleton-root" :data-loading="loading ? '1' : '0'">
<slot />
</div>
</template>
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonAvatar = Skeleton.Avatar;
const props = withDefaults(
defineProps<{
loading?: boolean;
size?: number;
shape?: 'circle' | 'square';
}>(),
{ size: 40, shape: 'circle' },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const sizePx = computed(() => props.size ?? token.value.controlHeightLG ?? 40);
</script>
<template>
<a-skeleton-avatar
v-if="isLoading"
active
:size="sizePx"
:shape="shape"
/>
<slot v-else />
</template>
@@ -1,51 +0,0 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
width?: string | number;
height?: string | number;
round?: boolean;
block?: boolean;
}>(),
{
round: false,
block: true,
},
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const widthCss = computed(() =>
props.width == null ? '100%' : typeof props.width === 'number' ? `${props.width}px` : props.width,
);
const heightCss = computed(() =>
props.height == null
? `${token.value.controlHeight}px`
: typeof props.height === 'number'
? `${props.height}px`
: props.height,
);
</script>
<template>
<a-skeleton-button
v-if="isLoading"
active
:block="block"
:shape="round ? 'round' : 'default'"
:style="{
width: widthCss,
minWidth: widthCss,
height: heightCss,
borderRadius: round ? '999px' : `${token.borderRadius}px`,
}"
/>
<slot v-else />
</template>
@@ -1,30 +0,0 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Card as ACard, Skeleton as ASkeleton } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
/** Approximate body lines inside the card */
rows?: number;
bordered?: boolean;
/** Min height so layout does not jump */
minHeight?: number;
}>(),
{ rows: 4, bordered: false },
);
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-card
v-if="isLoading"
:bordered="bordered"
:style="minHeight ? { minHeight: `${minHeight}px` } : undefined"
>
<a-skeleton active :title="{ width: '40%' }" :paragraph="{ rows }" />
</a-card>
<slot v-else />
</template>
@@ -1,50 +0,0 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const ASkeletonInput = Skeleton.Input;
const props = withDefaults(
defineProps<{
loading?: boolean;
width?: string | number;
/** button | input | select — all use controlHeight 36 */
shape?: 'button' | 'input' | 'select' | 'default';
}>(),
{ shape: 'default', width: 120 },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const widthCss = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width,
);
const isField = computed(() => props.shape === 'input' || props.shape === 'select');
</script>
<template>
<a-skeleton-input
v-if="isLoading && isField"
active
:style="{
width: widthCss,
minWidth: widthCss,
height: `${token.controlHeight}px`,
}"
/>
<a-skeleton-button
v-else-if="isLoading"
active
:style="{
width: widthCss,
minWidth: widthCss,
height: `${token.controlHeight}px`,
borderRadius: `${token.borderRadius}px`,
}"
/>
<slot v-else />
</template>
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Card as ACard, Flex as AFlex, Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
bordered?: boolean;
}>(),
{ bordered: false },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-card v-if="isLoading" :bordered="bordered">
<a-flex vertical :gap="token.paddingXS">
<a-skeleton-button
active
:style="{
width: '45%',
minWidth: 0,
height: `${token.fontSizeSM}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
<a-skeleton-button
active
:style="{
width: '60%',
minWidth: 0,
height: '28px',
borderRadius: `${token.borderRadius}px`,
}"
/>
</a-flex>
</a-card>
<slot v-else />
</template>
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Flex as AFlex, Skeleton as ASkeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
rows?: number;
avatar?: boolean;
}>(),
{ rows: 6, avatar: true },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-flex v-if="isLoading" vertical :gap="token.paddingSM">
<a-skeleton
v-for="i in rows"
:key="i"
active
:avatar="avatar ? { size: token.controlHeight, shape: 'circle' } : false"
:title="{ width: '40%' }"
:paragraph="{ rows: 1, width: ['70%'] }"
/>
</a-flex>
<slot v-else />
</template>
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Skeleton as ASkeleton } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
rows?: number;
width?: string | number | Array<string | number>;
}>(),
{ rows: 3 },
);
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-skeleton
v-if="isLoading"
active
:title="false"
:paragraph="{ rows, width }"
/>
<slot v-else />
</template>
@@ -1,115 +0,0 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Card as ACard, Flex as AFlex, Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
rows?: number;
columns?: number;
/** Wrap bones in a card (matches most list pages) */
card?: boolean;
bordered?: boolean;
}>(),
{ rows: 8, columns: 5, card: true, bordered: false },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const colWidths = computed(() => {
const n = Math.max(1, props.columns);
return Array.from({ length: n }, (_, i) => {
if (i === 0) return '22%';
if (i === n - 1) return '12%';
return `${Math.floor(66 / Math.max(1, n - 2))}%`;
});
});
const rowHeight = computed(() => token.value.controlHeight + (token.value.paddingXS ?? 8));
const headerH = computed(() => Math.round((token.value.fontSizeSM ?? 12) + 4));
const cellH = computed(() => token.value.fontSize ?? 14);
</script>
<template>
<a-card v-if="isLoading && card" :bordered="bordered">
<a-flex vertical :gap="token.paddingXS">
<a-flex :gap="token.paddingSM" :style="{ marginBottom: `${token.marginXS}px` }">
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`h-${i}`"
active
:style="{
width: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${headerH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
<a-flex
v-for="r in rows"
:key="`r-${r}`"
:gap="token.paddingSM"
align="center"
:style="{ minHeight: `${rowHeight}px` }"
>
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`c-${r}-${i}`"
active
:style="{
width: i === 0 ? '85%' : '70%',
maxWidth: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${cellH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
</a-flex>
</a-card>
<a-flex v-else-if="isLoading" vertical :gap="token.paddingXS">
<a-flex :gap="token.paddingSM" :style="{ marginBottom: `${token.marginXS}px` }">
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`h-${i}`"
active
:style="{
width: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${headerH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
<a-flex
v-for="r in rows"
:key="`r-${r}`"
:gap="token.paddingSM"
align="center"
:style="{ minHeight: `${rowHeight}px` }"
>
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`c-${r}-${i}`"
active
:style="{
width: i === 0 ? '85%' : '70%',
maxWidth: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${cellH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
</a-flex>
<slot v-else />
</template>
@@ -1,55 +0,0 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
width?: string | number;
/** Typography title level approximation: 1≈38, 2≈30, 3≈24, 4≈20, 5≈16 */
level?: 1 | 2 | 3 | 4 | 5;
}>(),
{ level: 3, width: 180 },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const height = computed(() => {
switch (props.level) {
case 1:
return 38;
case 2:
return 30;
case 3:
return 24;
case 4:
return 20;
default:
return 16;
}
});
const widthCss = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width,
);
</script>
<template>
<a-skeleton-button
v-if="isLoading"
active
:style="{
width: widthCss,
minWidth: widthCss,
height: `${height}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
<div v-else :style="{ minHeight: `${height}px` }">
<slot />
</div>
</template>
@@ -1,13 +0,0 @@
import { computed, inject, type ComputedRef, type InjectionKey, type Ref } from 'vue';
export const CRM_SKELETON_LOADING: InjectionKey<Ref<boolean> | ComputedRef<boolean>> =
Symbol('crmSkeletonLoading');
/** Resolve whether a skeleton block should show bones (local override wins). */
export function useCrmSkeletonLoading(local?: Ref<boolean | undefined> | ComputedRef<boolean | undefined>) {
const injected = inject(CRM_SKELETON_LOADING, null);
return computed(() => {
if (local?.value !== undefined) return !!local.value;
return !!injected?.value;
});
}
-11
View File
@@ -1,11 +0,0 @@
export { default as CrmSkeleton } from './CrmSkeleton.vue';
export { default as CrmSkeletonBone } from './CrmSkeletonBone.vue';
export { default as CrmSkeletonTitle } from './CrmSkeletonTitle.vue';
export { default as CrmSkeletonControl } from './CrmSkeletonControl.vue';
export { default as CrmSkeletonParagraph } from './CrmSkeletonParagraph.vue';
export { default as CrmSkeletonAvatar } from './CrmSkeletonAvatar.vue';
export { default as CrmSkeletonCard } from './CrmSkeletonCard.vue';
export { default as CrmSkeletonTable } from './CrmSkeletonTable.vue';
export { default as CrmSkeletonKpi } from './CrmSkeletonKpi.vue';
export { default as CrmSkeletonList } from './CrmSkeletonList.vue';
export { CRM_SKELETON_LOADING, useCrmSkeletonLoading } from './context';
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Menu as AMenu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue';
import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
@@ -13,48 +12,56 @@ const auth = useAuthStore();
const links = computed(() => {
const role = auth.user?.role;
const permissions = auth.user?.permissions;
const all: Array<{ to: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ to: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ to: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ to: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
});
function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
return route.path === path || route.path.startsWith(`${path}/`);
}
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label })));
const selectedKeys = computed(() => {
const match = links.value.find((link) => on(link.to, link.exact));
return match ? [match.to] : [];
const active = computed(() => {
const path = route.path;
for (const link of links.value) {
if (link.exact) {
if (path === link.value) return link.value;
} else if (path === link.value || path.startsWith(`${link.value}/`)) {
return link.value;
}
}
return '/finance';
});
const items = computed<MenuProps['items']>(() =>
links.value.map((link) => ({
key: link.to,
label: link.label,
})),
);
const onClick: MenuProps['onClick'] = ({ key }) => {
void router.push(String(key));
};
function onChange(value: string | number) {
const next = String(value);
if (next && next !== route.path) void router.push(next);
}
</script>
<template>
<a-menu
mode="horizontal"
:selected-keys="selectedKeys"
:items="items"
@click="onClick"
/>
<div
:style="{
width: '100%',
overflowX: 'auto',
flexShrink: 0,
minHeight: '36px',
}"
>
<a-segmented
:value="active"
:options="options"
dir="rtl"
:style="{ minWidth: 'max-content' }"
@change="onChange"
/>
</div>
</template>
+10 -1
View File
@@ -23,7 +23,9 @@ withDefaults(
<div class="titles">
<h1 v-if="title" class="title">{{ title }}</h1>
<slot v-else name="title" />
<slot name="lead" />
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
<slot v-else name="lead" />
</div>
</div>
@@ -85,6 +87,13 @@ withDefaults(
line-height: var(--lh-tight);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: var(--lh-normal);
}
.actions {
display: flex;
align-items: center;
-163
View File
@@ -1,163 +0,0 @@
/**
* جایگزین drop-in برای `@ant-design/icons-vue`.
* همان نام‌های *Outlined/*Filled را اکسپورت می‌کند ولی از پک vuesax می‌کشد.
*/
import { defineComponent, h } from 'vue';
import VsIcon from '@/components/VsIcon.vue';
import { ANT_ICON_TO_VUESAX } from './name-map';
function makeAntIcon(exportName: string) {
const vuesax = ANT_ICON_TO_VUESAX[exportName] ?? 'info-circle';
return defineComponent({
name: exportName,
inheritAttrs: false,
props: {
spin: { type: Boolean, default: false },
rotate: { type: Number, default: undefined },
twoToneColor: { type: String, default: undefined },
},
setup(props, { attrs }) {
return () => {
const style: Record<string, string> = {};
const attrStyle = attrs.style;
if (attrStyle && typeof attrStyle === 'object' && !Array.isArray(attrStyle)) {
Object.assign(style, attrStyle as Record<string, string>);
}
if (props.rotate != null) {
style.transform = `rotate(${props.rotate}deg)`;
}
return h(VsIcon, {
name: vuesax,
map: 'raw',
spin: props.spin,
class: ['anticon', attrs.class],
style: Object.keys(style).length ? style : undefined,
// مثل آیکون Ant با font-size والد اسکیل می‌شود
size: '1em',
});
};
},
});
}
function exportAll() {
const out: Record<string, ReturnType<typeof makeAntIcon>> = {};
for (const name of Object.keys(ANT_ICON_TO_VUESAX)) {
out[name] = makeAntIcon(name);
}
return out;
}
const icons = exportAll();
export const AccountBookOutlined = icons.AccountBookOutlined;
export const AimOutlined = icons.AimOutlined;
export const AlertOutlined = icons.AlertOutlined;
export const AppstoreOutlined = icons.AppstoreOutlined;
export const ArrowDownOutlined = icons.ArrowDownOutlined;
export const ArrowLeftOutlined = icons.ArrowLeftOutlined;
export const ArrowRightOutlined = icons.ArrowRightOutlined;
export const ArrowUpOutlined = icons.ArrowUpOutlined;
export const AudioOutlined = icons.AudioOutlined;
export const BankOutlined = icons.BankOutlined;
export const BarChartOutlined = icons.BarChartOutlined;
export const BellOutlined = icons.BellOutlined;
export const BoldOutlined = icons.BoldOutlined;
export const BoxPlotOutlined = icons.BoxPlotOutlined;
export const BulbOutlined = icons.BulbOutlined;
export const CalendarOutlined = icons.CalendarOutlined;
export const CameraOutlined = icons.CameraOutlined;
export const CarOutlined = icons.CarOutlined;
export const CheckCircleOutlined = icons.CheckCircleOutlined;
export const CheckOutlined = icons.CheckOutlined;
export const ClockCircleOutlined = icons.ClockCircleOutlined;
export const CloseCircleOutlined = icons.CloseCircleOutlined;
export const CloseOutlined = icons.CloseOutlined;
export const CommentOutlined = icons.CommentOutlined;
export const ContainerOutlined = icons.ContainerOutlined;
export const ControlOutlined = icons.ControlOutlined;
export const CopyOutlined = icons.CopyOutlined;
export const CreditCardOutlined = icons.CreditCardOutlined;
export const CrownOutlined = icons.CrownOutlined;
export const CustomerServiceOutlined = icons.CustomerServiceOutlined;
export const DeleteOutlined = icons.DeleteOutlined;
export const DollarOutlined = icons.DollarOutlined;
export const DownOutlined = icons.DownOutlined;
export const DownloadOutlined = icons.DownloadOutlined;
export const DragOutlined = icons.DragOutlined;
export const EditOutlined = icons.EditOutlined;
export const EnvironmentOutlined = icons.EnvironmentOutlined;
export const ExclamationCircleOutlined = icons.ExclamationCircleOutlined;
export const ExpandOutlined = icons.ExpandOutlined;
export const ExportOutlined = icons.ExportOutlined;
export const EyeInvisibleOutlined = icons.EyeInvisibleOutlined;
export const EyeOutlined = icons.EyeOutlined;
export const FallOutlined = icons.FallOutlined;
export const FileExcelOutlined = icons.FileExcelOutlined;
export const FileOutlined = icons.FileOutlined;
export const FileTextOutlined = icons.FileTextOutlined;
export const FilterOutlined = icons.FilterOutlined;
export const FireOutlined = icons.FireOutlined;
export const FlagFilled = icons.FlagFilled;
export const FlagOutlined = icons.FlagOutlined;
export const FullscreenExitOutlined = icons.FullscreenExitOutlined;
export const FullscreenOutlined = icons.FullscreenOutlined;
export const FundOutlined = icons.FundOutlined;
export const GiftOutlined = icons.GiftOutlined;
export const HistoryOutlined = icons.HistoryOutlined;
export const HolderOutlined = icons.HolderOutlined;
export const HomeOutlined = icons.HomeOutlined;
export const IdcardOutlined = icons.IdcardOutlined;
export const InfoCircleOutlined = icons.InfoCircleOutlined;
export const ItalicOutlined = icons.ItalicOutlined;
export const LeftOutlined = icons.LeftOutlined;
export const LinkOutlined = icons.LinkOutlined;
export const LockOutlined = icons.LockOutlined;
export const LogoutOutlined = icons.LogoutOutlined;
export const MailOutlined = icons.MailOutlined;
export const MessageOutlined = icons.MessageOutlined;
export const MinusOutlined = icons.MinusOutlined;
export const MoreOutlined = icons.MoreOutlined;
export const NotificationOutlined = icons.NotificationOutlined;
export const NumberOutlined = icons.NumberOutlined;
export const PaperClipOutlined = icons.PaperClipOutlined;
export const PauseCircleOutlined = icons.PauseCircleOutlined;
export const PhoneOutlined = icons.PhoneOutlined;
export const PictureOutlined = icons.PictureOutlined;
export const PlusOutlined = icons.PlusOutlined;
export const PrinterOutlined = icons.PrinterOutlined;
export const PushpinOutlined = icons.PushpinOutlined;
export const QuestionCircleOutlined = icons.QuestionCircleOutlined;
export const ReloadOutlined = icons.ReloadOutlined;
export const RightOutlined = icons.RightOutlined;
export const RiseOutlined = icons.RiseOutlined;
export const RobotOutlined = icons.RobotOutlined;
export const SafetyCertificateOutlined = icons.SafetyCertificateOutlined;
export const SaveOutlined = icons.SaveOutlined;
export const ScheduleOutlined = icons.ScheduleOutlined;
export const SearchOutlined = icons.SearchOutlined;
export const SelectOutlined = icons.SelectOutlined;
export const SendOutlined = icons.SendOutlined;
export const SettingOutlined = icons.SettingOutlined;
export const ShoppingOutlined = icons.ShoppingOutlined;
export const SlidersOutlined = icons.SlidersOutlined;
export const SmileOutlined = icons.SmileOutlined;
export const SolutionOutlined = icons.SolutionOutlined;
export const SoundOutlined = icons.SoundOutlined;
export const StarOutlined = icons.StarOutlined;
export const StopOutlined = icons.StopOutlined;
export const SyncOutlined = icons.SyncOutlined;
export const TagOutlined = icons.TagOutlined;
export const TeamOutlined = icons.TeamOutlined;
export const ThunderboltOutlined = icons.ThunderboltOutlined;
export const ToolOutlined = icons.ToolOutlined;
export const TrophyOutlined = icons.TrophyOutlined;
export const UnorderedListOutlined = icons.UnorderedListOutlined;
export const UpOutlined = icons.UpOutlined;
export const UploadOutlined = icons.UploadOutlined;
export const UserAddOutlined = icons.UserAddOutlined;
export const UserOutlined = icons.UserOutlined;
export const WalletOutlined = icons.WalletOutlined;
export const WarningOutlined = icons.WarningOutlined;
export const ZoomInOutlined = icons.ZoomInOutlined;
export const ZoomOutOutlined = icons.ZoomOutOutlined;
-6
View File
@@ -1,6 +0,0 @@
export { getVuesaxSvg, hasVuesax, VUESAX_NAMES, type VuesaxName } from './vuesax';
export {
ANT_ICON_TO_VUESAX,
UI_ICON_TO_VUESAX,
resolveVuesaxName,
} from './name-map';
-208
View File
@@ -1,208 +0,0 @@
/**
* نام‌های قدیمی پروژه → کلید کوتاه پک vuesax-linear.
* اگر آیکون دقیق نبود نزدیک‌ترین معادل انتخاب شده.
*/
/** نام‌های UiIcon → vuesax */
export const UI_ICON_TO_VUESAX: Record<string, string> = {
home: 'home',
users: 'profile-2user',
briefcase: 'briefcase',
check: 'tick-circle',
box: 'box',
chart: 'chart',
settings: 'setting-2',
search: 'search-normal',
sparkle: 'magic-star',
bell: 'notification',
calendar: 'calendar',
'calendar-plus': 'calendar-add',
mail: 'sms',
megaphone: 'volume-high',
send: 'send-2',
fire: 'flash',
warning: 'warning-2',
trend: 'trend-up',
phone: 'call',
trophy: 'cup',
chat: 'messages',
dots: 'more',
'dots-v': 'more',
document: 'document-text',
bag: 'bag',
clock: 'clock',
'user-plus': 'user-add',
plus: 'add',
chevron: 'arrow-down',
star: 'star',
pin: 'location',
filter: 'filter',
funnel: 'filter',
lightbulb: 'lamp-on',
building: 'buildings',
user: 'user',
flag: 'flag',
refresh: 'refresh',
x: 'close-circle',
'x-circle': 'close-circle',
mic: 'microphone',
trash: 'trash',
stop: 'stop',
'check-plain': 'tick-circle',
'calendar-sun': 'calendar-1',
'calendar-edit': 'calendar-edit',
minus: 'minus',
dollar: 'dollar-circle',
layers: 'layer',
target: 'other:target',
pencil: 'edit-2',
edit: 'edit',
grid: 'element-3',
list: 'row-vertical',
eye: 'eye',
'eye-off': 'eye-slash',
lock: 'lock',
truck: 'truck-tick',
grip: 'element-equal',
'apps-plus': 'additem',
crown: 'crown',
gift: 'gift',
medal: 'medal',
logout: 'logout',
pulse: 'activity',
sliders: 'setting-4',
camera: 'camera',
clip: 'paperclip',
download: 'document-download',
upload: 'document-upload',
'arrow-right': 'arrow-right',
'arrow-left': 'arrow-left',
'chevron-right': 'arrow-right-1',
'chevron-left': 'arrow-left-1',
'chevrons-right': 'arrow-right-2',
'chevrons-left': 'arrow-left-2',
};
/** کامپوننت‌های @ant-design/icons-vue → vuesax */
export const ANT_ICON_TO_VUESAX: Record<string, string> = {
AccountBookOutlined: 'book',
AimOutlined: 'other:target',
AlertOutlined: 'warning-2',
AppstoreOutlined: 'element-3',
ArrowDownOutlined: 'arrow-down',
ArrowLeftOutlined: 'arrow-left',
ArrowRightOutlined: 'arrow-right',
ArrowUpOutlined: 'arrow-up',
AudioOutlined: 'volume-high',
BankOutlined: 'bank',
BarChartOutlined: 'chart-2',
BellOutlined: 'notification',
BoldOutlined: 'text-bold',
BoxPlotOutlined: 'chart-21',
BulbOutlined: 'lamp-on',
CalendarOutlined: 'calendar',
CameraOutlined: 'camera',
CarOutlined: 'car',
CheckCircleOutlined: 'tick-circle',
CheckOutlined: 'tick-circle',
ClockCircleOutlined: 'clock',
CloseCircleOutlined: 'close-circle',
CloseOutlined: 'close-circle',
CommentOutlined: 'message-text',
ContainerOutlined: 'box',
ControlOutlined: 'setting-4',
CopyOutlined: 'copy',
CreditCardOutlined: 'card',
CrownOutlined: 'crown',
CustomerServiceOutlined: 'headphone',
DeleteOutlined: 'trash',
DollarOutlined: 'dollar-circle',
DownOutlined: 'arrow-down',
DownloadOutlined: 'document-download',
DragOutlined: 'element-equal',
EditOutlined: 'edit-2',
EnvironmentOutlined: 'location',
ExclamationCircleOutlined: 'warning-2',
ExpandOutlined: 'maximize-4',
ExportOutlined: 'export',
EyeInvisibleOutlined: 'eye-slash',
EyeOutlined: 'eye',
FallOutlined: 'trend-down',
FileExcelOutlined: 'document-text',
FileOutlined: 'document',
FileTextOutlined: 'document-text',
FilterOutlined: 'filter',
FireOutlined: 'flash',
FlagFilled: 'flag',
FlagOutlined: 'flag',
FullscreenExitOutlined: 'maximize-3',
FullscreenOutlined: 'maximize-4',
FundOutlined: 'chart',
GiftOutlined: 'gift',
HistoryOutlined: 'clock',
HolderOutlined: 'element-equal',
HomeOutlined: 'home',
IdcardOutlined: 'personalcard',
InfoCircleOutlined: 'info-circle',
ItalicOutlined: 'text-italic',
LeftOutlined: 'arrow-left-1',
LinkOutlined: 'link',
LockOutlined: 'lock',
LogoutOutlined: 'logout',
MailOutlined: 'sms',
MessageOutlined: 'message',
MinusOutlined: 'minus',
MoreOutlined: 'more',
NotificationOutlined: 'notification-bing',
NumberOutlined: 'hashtag',
PaperClipOutlined: 'paperclip',
PauseCircleOutlined: 'pause',
PhoneOutlined: 'call',
PictureOutlined: 'gallery',
PlusOutlined: 'add',
PrinterOutlined: 'printer',
PushpinOutlined: 'location',
QuestionCircleOutlined: 'message-question',
ReloadOutlined: 'refresh-2',
RightOutlined: 'arrow-right-1',
RiseOutlined: 'trend-up',
RobotOutlined: 'cpu',
SafetyCertificateOutlined: 'shield-tick',
SaveOutlined: 'save-2',
ScheduleOutlined: 'calendar-tick',
SearchOutlined: 'search-normal',
SelectOutlined: 'mouse-square',
SendOutlined: 'send-2',
SettingOutlined: 'setting-2',
ShoppingOutlined: 'shopping-bag',
SlidersOutlined: 'setting-4',
SmileOutlined: 'emoji-happy',
SolutionOutlined: 'clipboard-text',
SoundOutlined: 'volume-high',
StarOutlined: 'star',
StopOutlined: 'stop',
SyncOutlined: 'refresh',
TagOutlined: 'tag',
TeamOutlined: 'profile-2user',
ThunderboltOutlined: 'flash',
ToolOutlined: 'setting',
TrophyOutlined: 'cup',
UnorderedListOutlined: 'row-vertical',
UpOutlined: 'arrow-up',
UploadOutlined: 'document-upload',
UserAddOutlined: 'user-add',
UserOutlined: 'user',
WalletOutlined: 'wallet-2',
WarningOutlined: 'warning-2',
ZoomInOutlined: 'search-zoom-in',
ZoomOutOutlined: 'search-zoom-out',
};
export function resolveVuesaxName(
name: string,
source: 'ui' | 'ant' | 'raw' = 'raw',
): string {
if (source === 'ui') return UI_ICON_TO_VUESAX[name] ?? name;
if (source === 'ant') return ANT_ICON_TO_VUESAX[name] ?? name;
return name;
}
File diff suppressed because one or more lines are too long
-19
View File
@@ -1,19 +0,0 @@
import pack from './vuesax-linear.json';
export type VuesaxName = keyof typeof pack & string;
const icons = pack as Record<string, string>;
/** Resolve short or prefixed name → SVG markup (currentColor). */
export function getVuesaxSvg(name: string): string | null {
const key = name.startsWith('vuesax-linear:')
? name.slice('vuesax-linear:'.length)
: name;
return icons[key] ?? null;
}
export function hasVuesax(name: string): boolean {
return getVuesaxSvg(name) != null;
}
export const VUESAX_NAMES = Object.keys(icons) as VuesaxName[];
File diff suppressed because it is too large Load Diff
-1
View File
@@ -4,7 +4,6 @@ import { brand } from '@mento/shared';
import App from './App.vue';
import { router } from './router';
import { vTableCols } from './lib/tableColumns';
import './styles/fonts.css';
import './styles/main.css';
document.title = `${brand.nameFa} | ${brand.fullName}`;

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