9 Commits
Author SHA1 Message Date
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30
MahdiandCursor 41f310f3a2 merge: bring latest origin/main into dev (design local, logic main)
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:26:43 +03:30
MahdiandCursor 754cc43c98 fix(web): drop unused viewingSelf after subtitle removal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor a319085dca fix(web): clear vue-tsc errors blocking push
Type attendance rows, remove unused symbols, and harden list item typing after the Ant settings migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30
MahdiandCursor b11affbdfd fix(web): load IRANSans outside the Tailwind pipeline
Tailwind rewrites url() values that contain parentheses, so the FaNum font files never resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:32:30 +03:30
Mahdi 7aaa15fad2 merge: bring latest origin/main into dev (keep local UI)
Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
2026-11-03 16:55:42 +03:30
Mahdi 39c62834e7 fix: resolve Ant switch and unused board menu type errors blocking push 2026-11-03 16:53:49 +03:30
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30
211 changed files with 33305 additions and 42352 deletions
+11 -3
View File
@@ -41,11 +41,19 @@ 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**
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).
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).
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).
`<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.
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
@@ -0,0 +1,7 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,9 +38,13 @@ 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 | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` |
| 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) |
| 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
@@ -1,15 +0,0 @@
.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
@@ -1,131 +0,0 @@
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}"
-29
View File
@@ -1,29 +0,0 @@
# 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
@@ -1,15 +0,0 @@
# 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
@@ -1,16 +0,0 @@
# 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
@@ -0,0 +1,65 @@
---
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-*)` پاک‌سازی شده است؟
@@ -1,9 +0,0 @@
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,6 +0,0 @@
CREATE TABLE "DataFix" (
"key" TEXT NOT NULL,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
);
@@ -1,6 +0,0 @@
ALTER TABLE "AccountingConnection"
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "customerSyncLastSlot" TEXT,
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
ADD COLUMN "customerSyncLastSummary" TEXT,
ADD COLUMN "customerSyncLastError" TEXT;
-23
View File
@@ -295,7 +295,6 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
assignedActivities Activity[] @relation("ActivityAssignees")
@@ -525,7 +524,6 @@ model Company {
customFields Json @default("{}")
groupLinks CompanyGroupLink[]
people Person[]
financialDocuments FinancialDocument[]
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -1843,13 +1841,6 @@ model AccountingConnection {
lastHealthAt DateTime?
lastHealthOk Boolean?
lastError String?
/// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
customerSyncHours Int[] @default([])
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
customerSyncLastSlot String?
customerSyncLastRunAt DateTime?
customerSyncLastSummary String?
customerSyncLastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[]
@@ -2339,12 +2330,6 @@ model FinancialDocument {
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
userId String?
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
/// person | company | user | other
partyKind String?
partyName String?
accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
@@ -2365,8 +2350,6 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt])
@@index([organizationId, status])
@@index([personId])
@@index([companyId])
@@index([userId])
}
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -3786,9 +3769,3 @@ model AttendanceEvent {
@@index([organizationId, userId, at])
@@unique([organizationId, userId, kind, sessionStartedAt])
}
/// علامت یک‌بار اجرای اصلاح داده
model DataFix {
key String @id
doneAt DateTime @default(now())
}
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatClock,
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
return id;
};
const resolvePerson = async (row: Record<string, string>) => {
const mobile = storedPhoneNumber(row.personMobile);
const mobile = normalizePhone(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null;
@@ -478,13 +478,10 @@ export class ActivitiesExcelService {
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobileKeys.length) {
const mobile = normalizePhone(row.personMobile);
if (mobile) {
const byMobile = await this.prisma.person.findFirst({
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
select: { id: true },
});
if (byMobile) return byMobile.id;
+1 -2
View File
@@ -8,7 +8,6 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -22,7 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
CustomerGroupsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
-367
View File
@@ -1,367 +0,0 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
-33
View File
@@ -27,36 +27,3 @@ export function normalizePhone(value?: string | null): string | null {
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null;
}
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+7 -18
View File
@@ -28,12 +28,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service';
import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asAddresses,
asCustomFields,
@@ -404,10 +399,7 @@ export class PeopleService implements OnModuleInit {
input: { mobile?: string | null; email?: string | null; birthDate: Date },
) {
const or: Prisma.PersonWhereInput[] = [];
const mobileKeys = phoneIdentityKeys(input.mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
if (input.mobile) or.push({ mobile: input.mobile });
if (input.email) or.push({ email: input.email });
if (!or.length) return;
await this.prisma.person.updateMany({
@@ -449,8 +441,8 @@ export class PeopleService implements OnModuleInit {
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email),
phone: storedPhoneNumber(dto.phone),
mobile: storedPhoneNumber(dto.mobile),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
position: emptyToNull(dto.position),
website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram),
@@ -561,8 +553,8 @@ export class PeopleService implements OnModuleInit {
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
@@ -649,13 +641,10 @@ export class PeopleService implements OnModuleInit {
) {
const email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const mobileKeys = phoneIdentityKeys(mobile);
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
if (mobile) or.push({ mobile });
if (!or.length) return;
const clash = await this.prisma.person.findFirst({
@@ -1,85 +0,0 @@
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { mergeSameMobilePeople } from './merge-same-mobile';
const FIX_KEY = 'person-mobile-canonical-v3';
@Injectable()
export class SameMobileMergeService implements OnApplicationBootstrap {
private readonly logger = new Logger(SameMobileMergeService.name);
constructor(private readonly prisma: PrismaService) {}
onApplicationBootstrap() {
if (process.env.NODE_ENV === 'test') return;
void this.run();
}
private async run() {
try {
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
SELECT 1 AS ok
FROM information_schema.columns
WHERE table_schema = 'public'
AND lower(table_name) = 'person'
AND lower(column_name) = 'gender'
`;
if (!ready.length) {
this.logger.warn('یکسان‌سازی موبایل رد شد: ستون gender هنوز نیست');
return;
}
await this.prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS "DataFix" (
"key" TEXT PRIMARY KEY,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`;
const done = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
`;
if (done.length) return;
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
try {
const again = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
`;
if (again.length) return;
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const org of orgs) {
try {
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
merged += result.merged;
rewritten += result.rewritten;
failed += result.failed;
} catch (error) {
failed += 1;
this.logger.error(
`یکسان‌سازی موبایل برای محیط ${org.id} انجام نشد`,
error instanceof Error ? error.message : String(error),
);
}
}
if (failed) return;
await this.prisma.$executeRaw`
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
ON CONFLICT ("key") DO NOTHING
`;
this.logger.log(`شماره‌های تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
} finally {
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
}
} catch (error) {
this.logger.error(
'یکسان‌سازی موبایل مشتری‌ها انجام نشد',
error instanceof Error ? error.stack : String(error),
);
}
}
}
@@ -17,11 +17,7 @@ import type {
} from '@mento/shared';
import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service';
import {
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service';
@@ -104,7 +100,13 @@ function arr(v: unknown): unknown[] {
}
function mobileVariants(mobile: string): string[] {
return phoneIdentityKeys(mobile);
const digits = mobile.replace(/\D/g, '');
let core = digits;
if (core.startsWith('0098')) core = core.slice(4);
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
else if (core.startsWith('0')) core = core.slice(1);
if (!core) return [mobile];
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
}
function formatAddress(a: MontifyAddress): string | null {
@@ -415,7 +417,7 @@ export class MontifySyncService {
}> {
const externalId = String(c.id);
const { firstName, lastName } = splitName(c);
const mobile = storedPhoneNumber(c.phone ?? null);
const mobile = normalizePhone(c.phone ?? null);
const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = {
@@ -43,10 +43,6 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null;
lastHealthOk: boolean | null;
lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date;
updatedAt: Date;
}) {
@@ -64,10 +60,6 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk,
lastError: row.lastError,
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
customerSyncLastSummary: row.customerSyncLastSummary,
customerSyncLastError: row.customerSyncLastError,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -209,15 +201,6 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
let customerSyncHours: number[] | undefined;
if (dto.customerSyncHours !== undefined) {
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
if (new Set(hours).size !== hours.length) {
throw new BadRequestException('ساعت‌های بروزرسانی نباید تکراری باشند.');
}
customerSyncHours = [...hours].sort((a, b) => a - b);
}
let enc = {
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
@@ -242,7 +225,6 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
},
});
return this.toDto(updated);
@@ -1,99 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabPersonLinkService } from './person-link.service';
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یک‌بار در هر ساعت. */
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
let hour = Number(read('hour'));
if (!Number.isFinite(hour) || hour === 24) hour = 0;
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
return { hour, slot };
}
@Injectable()
export class TahesabCustomerSyncScheduler {
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
private busy = false;
constructor(
private readonly prisma: PrismaService,
private readonly links: TahesabPersonLinkService,
) {}
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
async tick() {
if (process.env.NODE_ENV === 'test' || this.busy) return;
this.busy = true;
try {
const { hour, slot } = tehranSyncSlot();
const connections = await this.prisma.accountingConnection.findMany({
where: {
provider: 'tahesab',
enabled: true,
customerSyncHours: { has: hour },
},
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
});
for (const connection of connections) {
if (connection.customerSyncLastSlot === slot) continue;
const claimed = await this.prisma.accountingConnection.updateMany({
where: {
id: connection.id,
OR: [
{ customerSyncLastSlot: null },
{ customerSyncLastSlot: { not: slot } },
],
},
data: { customerSyncLastSlot: slot },
});
if (claimed.count !== 1) continue;
await this.run(connection.organizationId, connection.id, connection.label);
}
} catch (err) {
this.logger.warn(
`بررسی زمان‌بندی مشتریان ته‌حساب ناموفق بود: ${
err instanceof Error ? err.message : err
}`,
);
} finally {
this.busy = false;
}
}
private async run(organizationId: string, connectionId: string, label: string) {
try {
const result = await this.links.syncAllCustomers(organizationId, connectionId);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: result.summary,
customerSyncLastError: result.error,
},
});
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
} catch (err) {
const message = (err instanceof Error ? err.message : 'همگام‌سازی ناموفق بود').slice(0, 500);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: null,
customerSyncLastError: message,
},
});
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
}
}
}
-10
View File
@@ -86,16 +86,6 @@ export class UpdateTahesabConnectionDto {
@IsOptional()
@IsBoolean()
allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
}
export class ManualLinkDto {
@@ -8,12 +8,7 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
import {
canonicalIranMobile,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service';
import {
@@ -30,20 +25,6 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
function canonicalMobileUpdate(
incoming: string | null,
current: { mobile: string | null; phone: string | null },
): { mobile?: string; phone?: string } {
const mobile =
canonicalIranMobile(incoming) ??
canonicalIranMobile(current.mobile) ??
canonicalIranMobile(current.phone);
if (!mobile) return {};
const phone = canonicalIranMobile(current.phone);
return phone ? { mobile, phone } : { mobile };
}
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000;
@@ -558,7 +539,6 @@ export class TahesabPersonLinkService {
organizationId: string,
dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) {
const clash = await this.prisma.personAccountingLink.findFirst({
where: {
@@ -597,11 +577,10 @@ export class TahesabPersonLinkService {
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName;
const lastName = named.lastName;
const mobile = storedPhoneNumber(tel);
const mobile = tel || null;
const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
let preserveExistingLink = false;
if (personId) {
const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName;
@@ -625,33 +604,28 @@ export class TahesabPersonLinkService {
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing),
...(mobile
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length
? await this.prisma.person.findMany({
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند.
const byMobile = mobile
? await this.prisma.person.findFirst({
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
OR: [{ mobile }, { phone: mobile }],
accountingLinks: { none: { connectionId: dto.connectionId } },
},
include: {
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
orderBy: { createdAt: 'asc' },
})
: [];
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
: null;
if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
@@ -670,9 +644,8 @@ export class TahesabPersonLinkService {
: {}),
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
},
});
personId = byMobile.id;
@@ -696,32 +669,6 @@ export class TahesabPersonLinkService {
}
}
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
@@ -746,169 +693,18 @@ export class TahesabPersonLinkService {
});
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(link) };
}
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
personId: string,
moshtariCode: number,
row: Record<string, unknown>,
) {
const existing = await this.getPerson(organizationId, personId);
const picked = pickName(row).trim();
const named = picked
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
: null;
const nameChanged =
named != null &&
namesDiffer(
existing.firstName,
existing.lastName,
named.firstName,
named.lastName,
);
await this.prisma.person.update({
where: { id: personId },
data: {
...(named
? {
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}),
...(nameChanged
? {
previousFirstName: existing.firstName || null,
previousLastName: existing.lastName || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
}
/**
* همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند.
* کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود.
*/
async syncAllCustomers(organizationId: string, connectionId: string) {
const { api } = await this.connections.getApi(organizationId, connectionId);
let page = 1;
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let failed = 0;
const errors: string[] = [];
const maxPages = 300;
while (emptyStreak < 2 && page <= maxPages) {
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
try {
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
} catch (err) {
const message = err instanceof Error ? err.message : 'خواندن ته‌حساب ناموفق بود';
errors.push(message);
break;
}
if (!listed.rows.length) {
emptyStreak += 1;
page += 1;
continue;
}
emptyStreak = 0;
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null && code >= 1) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: {
connectionId,
moshtariCode: { in: [...rowsByCode.keys()] },
},
select: { moshtariCode: true, personId: true },
})
: [];
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
for (const [code, row] of rowsByCode) {
try {
const personId = linkedByCode.get(code);
if (personId) {
await this.refreshLinkedCustomer(
organizationId,
connectionId,
personId,
code,
row,
);
updated += 1;
} else {
await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
imported += 1;
}
} catch (err) {
failed += 1;
if (errors.length < 5) {
errors.push(err instanceof Error ? err.message : 'خطا');
}
}
}
page += 1;
}
if (page > maxPages && emptyStreak < 2) {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
}
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
return { personId, link: this.linkDto(link) };
}
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -12,7 +12,6 @@ import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -30,7 +29,6 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
],
exports: [
TahesabCryptoService,
@@ -11,7 +11,7 @@ import type {
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string {
@@ -35,7 +35,6 @@ function phoneVariants(raw: string | null | undefined): string[] {
variants.add(`0${d}`);
variants.add(`98${d}`);
}
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
return [...variants];
}
@@ -6,11 +6,7 @@ import type {
WooCommerceSyncResultDto,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import {
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { WooConnectionsService } from './connections.service';
import type { WooOrder } from './woo-client';
@@ -378,8 +374,7 @@ export class WooSyncService {
): Promise<string | null> {
const billing = order.billing ?? {};
const email = normalizeEmail(billing.email ?? null);
const mobile = storedPhoneNumber(billing.phone ?? null);
const mobileKeys = phoneIdentityKeys(mobile);
const mobile = normalizePhone(billing.phone ?? null);
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
@@ -392,9 +387,7 @@ export class WooSyncService {
organizationId,
OR: [
...(email ? [{ email }] : []),
...(mobileKeys.length
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
...(mobile ? [{ mobile }, { phone: mobile }] : []),
],
},
select: { id: true },
+8 -16
View File
@@ -21,12 +21,9 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
canonicalIranMobile,
emptyToNull,
normalizeEmail,
normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service';
@@ -461,10 +458,7 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({
where: {
organizationId: form.organizationId,
OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true, firstName: true, lastName: true },
})
@@ -647,16 +641,13 @@ export class LeadFormsService {
ownerId: string,
): Promise<string> {
const email = normalizeEmail(mapped.email);
const mobile = storedPhoneNumber(mapped.mobile);
const phone = storedPhoneNumber(mapped.phone);
const mobile = normalizePhone(mapped.mobile);
const phone = normalizePhone(mapped.phone);
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
const mobileKeys = phoneIdentityKeys(mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
const existing = or.length
? await this.prisma.person.findFirst({
where: { organizationId, OR: or },
@@ -1124,12 +1115,13 @@ function slugKey(label: string): string {
}
function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw);
if (!n) return null;
const d = n.replace(/\D/g, '');
return d || null;
if (!d) return null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
}
function normalizeDuplicateMode(raw?: string | null): string {
+18 -110
View File
@@ -38,12 +38,7 @@ import {
collectAddresses,
toJsonAddresses,
} from '../common/custom-fields';
import {
canonicalIranMobile,
emptyToNull,
normalizePhone,
phoneIdentityKeys,
} from '../contacts/normalize';
import { emptyToNull, normalizePhone } from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service';
@@ -60,20 +55,13 @@ const LOGO_EXT: Record<string, string> = {
};
function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw);
if (!n) return null;
const d = n.replace(/\D/g, '');
return d || null;
}
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
const keys = phoneIdentityKeys(mobile);
return {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
};
if (!d) return null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
}
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
@@ -335,7 +323,10 @@ export class LeadsService {
};
}
const person = await this.prisma.person.findFirst({
where: personWithMobile(actor.organizationId, mobile),
where: {
organizationId: actor.organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true, firstName: true, lastName: true },
});
if (person) {
@@ -975,7 +966,10 @@ export class LeadsService {
}
let person = await this.prisma.person.findFirst({
where: personWithMobile(actor.organizationId, lead.mobile),
where: {
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
});
let createdPerson = false;
@@ -1002,7 +996,7 @@ export class LeadsService {
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
mobile: lead.mobile,
phone: lead.phone,
instagram: lead.instagram,
birthDate: lead.birthDate,
@@ -1176,95 +1170,6 @@ export class LeadsService {
};
}
async createFromExternal(input: {
organizationId: string;
siteName: string;
firstName?: string | null;
lastName?: string | null;
mobile: string;
storeName?: string | null;
source?: string | null;
}): Promise<{
ok: true;
created: boolean;
duplicate: boolean;
leadId: string | null;
}> {
const mobile = mobileKey(input.mobile);
if (!mobile || !/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const contactName =
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
const storeName = input.storeName?.trim() ?? '';
const source = emptyToNull(input.source);
const title = storeName || contactName || mobile;
const siteName = input.siteName.trim() || 'سایت بیرونی';
const existing = await this.prisma.lead.findFirst({
where: { organizationId: input.organizationId, mobile, archivedAt: null },
select: { id: true, source: true, contactName: true },
});
if (existing) {
const patch: Prisma.LeadUpdateInput = {};
if (!existing.source && source) patch.source = source;
if (!existing.contactName && contactName) patch.contactName = contactName;
if (Object.keys(patch).length) {
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
}
await this.prisma.leadEvent.create({
data: {
organizationId: input.organizationId,
leadId: existing.id,
type: 'note',
title: 'درخواست دوباره از سایت',
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
.filter(Boolean)
.join('\n'),
},
});
return { ok: true, created: false, duplicate: true, leadId: existing.id };
}
const person = await this.prisma.person.findFirst({
where: personWithMobile(input.organizationId, mobile),
select: { id: true },
});
if (person) {
return { ok: true, created: false, duplicate: true, leadId: null };
}
await this.stages.ensureDefaults(input.organizationId);
const stages = await this.stages.list(input.organizationId);
const stageId = stages[0]?.id;
if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده');
const lead = await this.prisma.lead.create({
data: {
organizationId: input.organizationId,
title,
contactName,
mobile,
source,
notes: `ثبت از سایت: ${siteName}`,
stageId,
},
});
await this.prisma.leadEvent.create({
data: {
organizationId: input.organizationId,
leadId: lead.id,
type: 'created',
title: 'لید از سایت ثبت شد',
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
.filter(Boolean)
.join('\n'),
},
});
return { ok: true, created: true, duplicate: false, leadId: lead.id };
}
async createFromImportRow(
organizationId: string,
createdById: string | null,
@@ -1307,7 +1212,10 @@ export class LeadsService {
}
const existingPerson = await this.prisma.person.findFirst({
where: personWithMobile(organizationId, mobile),
where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true },
});
if (existingPerson) {
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> {
const orgId = actor.organizationId;
const code = cleanCode(row.personCode);
const mobile = storedPhoneNumber(row.personMobile);
const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const mobile = normalizePhone(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
const validMobile = mobileCore.length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,10 +731,7 @@ export class DealsExcelService {
}
if (!found && validMobile) {
found = await this.prisma.person.findFirst({
where: {
organizationId: orgId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
where: { organizationId: orgId, mobile: { contains: mobileCore } },
select: { id: true },
});
}
+6 -13
View File
@@ -19,7 +19,6 @@ import type {
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth';
@@ -63,7 +62,7 @@ export class PortalService {
mobileRaw: string,
): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug);
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobile = normalizeMobile(mobileRaw);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
@@ -124,8 +123,7 @@ export class PortalService {
codeRaw: string,
): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug);
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const mobile = normalizeMobile(mobileRaw);
const code = codeRaw.trim();
if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است');
@@ -150,15 +148,10 @@ export class PortalService {
data: { consumedAt: new Date() },
});
let person = mobileKeys.length
? await this.prisma.person.findFirst({
where: {
organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
let person = await this.prisma.person.findFirst({
where: { organizationId: org.id, mobile },
orderBy: { createdAt: 'asc' },
});
if (!person) {
person = await this.prisma.person.create({
data: {
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { projectCardAccessWhere } from '../common/record-scope';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
@@ -256,13 +256,11 @@ export class ProjectCardsExcelService {
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobileKeys.length) {
const mobile = normalizePhone(row.personMobile);
if (mobile) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const byMobile = await this.prisma.person.findFirst({
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
where: { organizationId, mobile: { contains: needle } },
select: { id: true },
});
if (byMobile) return byMobile.id;
@@ -22,7 +22,6 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string;
role: string;
apiKeyId?: string;
apiKeyName?: string;
};
}>();
@@ -45,7 +44,6 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId,
role: auth.role,
apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
};
return true;
}
+2 -3
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate(
rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true, name: true },
select: { id: true, organizationId: true },
});
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,7 +83,6 @@ export class ApiKeysService {
return {
organizationId: row.organizationId,
apiKeyId: row.id,
name: row.name,
role: 'owner',
};
}
@@ -1,55 +1,18 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpException,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard';
class CreateExternalLeadDto {
@IsOptional()
@IsString()
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MaxLength(80)
lastName?: string;
@IsString()
@MinLength(8)
@MaxLength(20)
mobile!: string;
@IsOptional()
@IsString()
@MaxLength(200)
storeName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
source?: string;
}
const LEAD_RATE_WINDOW_MS = 60_000;
const LEAD_RATE_MAX = 60;
const leadHits = new Map<string, number[]>();
class ListQueryDto {
@IsOptional()
@IsString()
@@ -72,36 +35,7 @@ class ListQueryDto {
@Controller('public/v1')
@UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller {
constructor(
private readonly prisma: PrismaService,
private readonly leads: LeadsService,
) {}
@Post('leads')
@HttpCode(200)
createLead(
@CurrentUser()
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
@Body() body: CreateExternalLeadDto,
) {
const bucket = user.apiKeyId || user.organizationId;
const now = Date.now();
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
if (prev.length >= LEAD_RATE_MAX) {
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
}
prev.push(now);
leadHits.set(bucket, prev);
return this.leads.createFromExternal({
organizationId: user.organizationId,
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
firstName: body.firstName,
lastName: body.lastName,
mobile: body.mobile,
storeName: body.storeName,
source: body.source,
});
}
constructor(private readonly prisma: PrismaService) {}
@Get('people')
async listPeople(
+1 -2
View File
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
@@ -10,7 +9,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
@Module({
imports: [AuthModule, BillingModule, LeadsModule],
imports: [AuthModule, BillingModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService],
+264 -20
View File
@@ -1533,6 +1533,7 @@ 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);
@@ -1543,6 +1544,7 @@ 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);
@@ -1555,17 +1557,25 @@ 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,
openDeals,
openDealRows,
lostDeals,
staleDeals,
peopleCur,
peoplePrev,
peopleRecent,
openActivitiesToday,
openActivitiesYesterday,
openDealsWeekAgo,
goals,
overdueCount,
invoices,
activityTypeGroups,
] = await Promise.all([
this.prisma.deal.findMany({
where: {
@@ -1573,7 +1583,7 @@ export class ReportsService {
{
organizationId: orgId,
status: 'won',
updatedAt: { gte: addMonthsStart(now, -5) },
updatedAt: { gte: trendWindowStart },
},
dealScope,
dealsPrior,
@@ -1590,7 +1600,7 @@ export class ReportsService {
person: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.deal.count({
this.prisma.deal.findMany({
where: {
AND: [
{ organizationId: orgId, status: 'open' },
@@ -1598,6 +1608,30 @@ 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: {
@@ -1624,19 +1658,13 @@ export class ReportsService {
orderBy: { updatedAt: 'asc' },
take: 5,
}),
this.prisma.person.count({
this.prisma.person.findMany({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: curMonthStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
createdAt: { gte: trendWindowStart, lte: curMonthEnd },
},
select: { createdAt: true, importSource: true },
}),
this.prisma.activity.count({
where: {
@@ -1656,6 +1684,7 @@ export class ReportsService {
AND: [
{
organizationId: orgId,
status: 'open',
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
},
activityScope,
@@ -1697,8 +1726,67 @@ 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)
@@ -1707,8 +1795,21 @@ export class ReportsService {
0,
);
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
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 targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount),
0,
@@ -1716,7 +1817,7 @@ export class ReportsService {
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
[];
for (let i = 4; i >= 0; i -= 1) {
for (let i = 11; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
@@ -1729,14 +1830,28 @@ export class ReportsService {
const salesTrend = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
amount: Math.round(sumWonInRange(m.start, m.end)),
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,
}));
const sourceMap = new Map<string, number>();
for (const d of wonDeals) {
const label = normalizeSource(d.source);
for (const p of peopleRecent) {
const label = normalizeImportSource(p.importSource);
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]) => ({
@@ -1780,6 +1895,101 @@ 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]!;
@@ -1857,11 +2067,30 @@ 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,
};
}
}
@@ -1919,6 +2148,21 @@ 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
View File
@@ -185,7 +185,6 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
+13 -28
View File
@@ -200,7 +200,8 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({
data: {
...data,
@@ -215,9 +216,10 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanWriteRoutine(scope, existing);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -230,8 +232,9 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanWriteRoutine(scope, existing);
this.assertCanManageRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
@@ -248,8 +251,9 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanWriteRoutine(scope, existing);
this.assertCanManageRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
@@ -264,8 +268,9 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanWriteRoutine(scope, src);
this.assertCanManageRoutine(scope, src);
const {
id: _id,
createdAt: _c,
@@ -290,8 +295,9 @@ export class RoutinesService {
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanWriteRoutine(scope, existing);
this.assertCanManageRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
@@ -339,27 +345,6 @@ export class RoutinesService {
return row;
}
/** عضو عادی فقط روتینی را می‌نویسد که خودش برای خودش ساخته است. */
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
if (scope.isManager) return dto;
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید');
}
return { ...dto, assigneeId: actor.userId, teamId: null };
}
private assertCanWriteRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.isManager) {
this.assertCanManageRoutine(scope, row);
return;
}
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید');
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
+1 -38
View File
@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
@@ -315,12 +314,7 @@ export class TreasuryOpsService {
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
}
async reportExcel(
actor: Actor,
from?: string,
to?: string,
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
) {
async reportExcel(actor: Actor, from?: string, to?: string) {
const data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی');
@@ -337,41 +331,10 @@ export class TreasuryOpsService {
sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
if (parties) {
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافت‌شده');
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداخت‌شده');
}
const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer);
}
private addPartySheet(
book: ExcelJS.Workbook,
title: string,
rows: TreasuryPartyBalanceDto[],
settledLabel: string,
) {
const sheet = book.addWorksheet(title);
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
let amount = new Prisma.Decimal(0);
let settled = new Prisma.Decimal(0);
let remaining = new Prisma.Decimal(0);
for (const row of rows) {
sheet.addRow([
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
row.amount,
row.settledAmount,
row.remaining,
]);
amount = amount.plus(row.amount);
settled = settled.plus(row.settledAmount);
remaining = remaining.plus(row.remaining);
}
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
}
private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({
where: {
+2 -43
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,20 +43,6 @@ export class TreasuryController {
return this.treasury.createCategory(user, body);
}
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; isActive?: boolean },
) {
return this.treasury.updateCategory(user, id, body);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteCategory(user, id);
}
@Get('accounts')
accounts(
@CurrentUser() user: Actor,
@@ -106,29 +92,6 @@ export class TreasuryController {
return this.treasury.transfer(user, body);
}
@Get('parties')
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.treasury.searchParties(user, q);
}
@Post('parties/person')
createPartyPerson(
@CurrentUser() user: Actor,
@Body() body: { firstName?: string; lastName?: string },
) {
return this.treasury.createPartyPerson(user, body);
}
@Post('parties/company')
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
return this.treasury.createPartyCompany(user, body);
}
@Get('party-balances')
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
}
@Get('documents')
documents(
@CurrentUser() user: Actor,
@@ -269,11 +232,7 @@ export class TreasuryController {
@Query('from') from?: string,
@Query('to') to?: string,
) {
const [debtors, creditors] = await Promise.all([
this.treasury.partyBalances(user, 'income'),
this.treasury.partyBalances(user, 'expense'),
]);
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
const buffer = await this.ops.reportExcel(user, from, to);
return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"',
+1 -2
View File
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -10,7 +9,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Module({
imports: [AuthModule, NotificationsModule, ContactsModule],
imports: [AuthModule, NotificationsModule],
controllers: [TreasuryController],
providers: [
TreasuryService,
+16 -311
View File
@@ -13,14 +13,8 @@ import type {
FinancialCategoryDto,
FinancialDocumentDto,
FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared';
import { CompaniesService } from '../contacts/companies.service';
import { PeopleService } from '../contacts/people.service';
import { dateToJalali } from '../common/jalali';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -71,8 +65,6 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
async ensureCategories(organizationId: string) {
@@ -120,68 +112,19 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> {
const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
try {
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
name,
kind: input.kind,
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async updateCategory(
actor: Actor,
id: string,
input: { name?: string; isActive?: boolean },
): Promise<FinancialCategoryDto> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
name,
kind: input.kind,
},
});
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
const name = input.name == null ? current.name : input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
try {
const row = await this.prisma.financialCategory.update({
where: { id: current.id },
data: {
name,
...(input.isActive == null ? {} : { isActive: input.isActive }),
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
await this.prisma.financialCategory.delete({ where: { id: current.id } });
return { ok: true };
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
}
async listAccounts(
@@ -519,10 +462,7 @@ export class TreasuryService {
bookedAt?: string;
dueAt?: string | null;
categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null;
method?: string | null;
reference?: string | null;
@@ -536,13 +476,9 @@ export class TreasuryService {
const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0)
: dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
}
@@ -561,13 +497,6 @@ export class TreasuryService {
}
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference(
tx,
actor.organizationId,
input.kind,
input.bookedAt,
bookedAt,
);
const created = await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
@@ -577,15 +506,12 @@ export class TreasuryService {
bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
personId: input.personId || null,
partyName: input.partyName?.trim() || null,
accountId: input.accountId || null,
method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference,
reference: input.reference?.trim() || null,
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
@@ -601,7 +527,7 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
reference,
reference: input.reference?.trim() || null,
createdById: actor.userId,
},
});
@@ -614,221 +540,6 @@ export class TreasuryService {
return this.toDocumentDto(doc);
}
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
private async nextDocumentReference(
tx: Prisma.TransactionClient,
organizationId: string,
kind: 'income' | 'expense',
bookedAtInput: string | undefined,
bookedAt: Date,
) {
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
const when = match
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
: bookedAt;
const { jy, jm, jd } = dateToJalali(when);
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
const latest = await tx.financialDocument.findFirst({
where: { organizationId, reference: { startsWith: prefix } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const current = Number(latest?.reference?.slice(prefix.length));
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
const term = q?.trim() ?? '';
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
const [people, companies, users] = await Promise.all([
this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}),
},
select: { id: true, firstName: true, lastName: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.company.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { name: contains } : {}),
},
select: { id: true, name: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.user.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
...(contains ? { fullName: contains } : {}),
},
select: { id: true, fullName: true },
orderBy: { fullName: 'asc' },
take: 20,
}),
]);
return {
people: people.map((row) => ({
id: row.id,
name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری',
})),
companies,
users: users.map((row) => ({ id: row.id, name: row.fullName })),
};
}
async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) {
const firstName = input.firstName?.trim() ?? '';
const lastName = input.lastName?.trim() ?? '';
if (`${firstName} ${lastName}`.trim().length < 2) {
throw new BadRequestException('نام شخص را وارد کنید.');
}
const person = await this.people.create(actor, { firstName, lastName });
return { id: person.id, name: person.fullName, kind: 'person' as const };
}
async createPartyCompany(actor: Actor, input: { name?: string }) {
const name = input.name?.trim() ?? '';
if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.');
const company = await this.companies.create(actor, { name });
return { id: company.id, name: company.name, kind: 'company' as const };
}
async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise<TreasuryPartyBalanceDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
select: {
amount: true,
partyKind: true,
personId: true,
companyId: true,
userId: true,
partyName: true,
movements: { select: { amount: true, voidedAt: true } },
},
});
const buckets = new Map<string, {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
}>();
for (const row of rows) {
if (row.partyKind === 'other') continue;
const settled = this.settledOf(row.movements);
const rawRemaining = row.amount.minus(settled);
if (!rawRemaining.greaterThan(0)) continue;
const linkedId = row.personId || row.companyId || row.userId;
const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user'
? row.partyKind
: null;
const key = partyKind && linkedId
? `${partyKind}:${linkedId}`
: `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`;
const current = buckets.get(key) ?? {
key,
partyKind,
partyName: row.partyName?.trim() || 'بدون طرف حساب',
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current);
}
return [...buckets.values()]
.sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({
key: row.key,
partyKind: row.partyKind,
partyName: row.partyName,
amount: money(row.amount),
settledAmount: money(row.settled),
remaining: money(row.remaining),
}));
}
private async resolveParty(
actor: Actor,
input: {
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
},
) {
const kind = input.partyKind;
if (kind === 'other') {
return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' };
}
if (kind === 'person') {
const id = input.personId?.trim();
if (!id) throw new BadRequestException('مشتری را انتخاب کنید.');
const person = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new BadRequestException('مشتری پیدا نشد.');
return {
partyKind: 'person' as const,
personId: person.id,
companyId: null,
userId: null,
partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری',
};
}
if (kind === 'company') {
const id = input.companyId?.trim();
if (!id) throw new BadRequestException('شرکت را انتخاب کنید.');
const company = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, name: true },
});
if (!company) throw new BadRequestException('شرکت پیدا نشد.');
return {
partyKind: 'company' as const,
personId: null,
companyId: company.id,
userId: null,
partyName: company.name,
};
}
if (kind === 'user') {
const id = input.userId?.trim();
if (!id) throw new BadRequestException('کاربر را انتخاب کنید.');
const user = await this.prisma.user.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, fullName: true },
});
if (!user) throw new BadRequestException('کاربر پیدا نشد.');
return {
partyKind: 'user' as const,
personId: null,
companyId: null,
userId: user.id,
partyName: user.fullName,
};
}
throw new BadRequestException('طرف حساب را انتخاب کنید.');
}
async settleDocument(
actor: Actor,
id: string,
@@ -1237,10 +948,7 @@ export class TreasuryService {
bookedAt: Date;
dueAt: Date | null;
categoryId: string | null;
partyKind?: string | null;
personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null;
accountId: string | null;
method: string | null;
@@ -1267,10 +975,7 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId,
categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName,
accountId: row.accountId,
accountName: row.account?.name ?? null,
+2
View File
@@ -22,7 +22,9 @@
"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"
+51 -113
View File
@@ -4,8 +4,20 @@ 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;
@@ -65,127 +77,53 @@ function insertAnswer() {
</script>
<template>
<section class="ask">
<form @submit.prevent="submit">
<UiIcon name="sparkle" :size="16" class="ico" />
<input
v-model="question"
type="search"
<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
:placeholder="placeholder || brand.askPlaceholderFa"
/>
<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">
: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' }">
<RouterLink
v-for="source in result.sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
>
{{ source.label }}
<a-tag color="cyan" :bordered="false">{{ source.label }}</a-tag>
</RouterLink>
</div>
<button
</a-space>
<a-button
v-if="allowInsert && result.answer.trim()"
type="button"
class="insert-btn"
type="primary"
block
:style="{ marginTop: '8px' }"
@click="insertAnswer"
>
درج در پیام
</button>
</div>
</section>
</a-button>
</a-card>
</a-flex>
</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>
+56 -54
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,61 +73,63 @@ onMounted(load);
</script>
<template>
<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 ?? '')"
/>
<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-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>
<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>
</template>
+68 -104
View File
@@ -1,6 +1,17 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { Select as ASelect } from 'ant-design-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 type {
DataAccessOwnerKind,
DataAccessRule,
@@ -15,9 +26,12 @@ 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;
@@ -429,53 +443,59 @@ const filterSummary = computed(() => {
</script>
<template>
<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>
<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>
<template v-if="mode === 'rules'">
<p class="hint">
<a-typography-text type="secondary">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
</p>
<p class="summary">{{ filterSummary }}</p>
</a-typography-text>
<a-alert
v-if="hadMultipleRules"
type="warning"
show-icon
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود."
/>
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
<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-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>
<label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span>
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
<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>
<a-select
v-else
:value="pickerValue(picker)"
@@ -490,70 +510,14 @@ const filterSummary = computed(() => {
style="width: 100%"
@update:value="setList(picker.key, $event)"
/>
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
</label>
<a-typography-text v-if="picker.hint" type="secondary">
{{ picker.hint }}
</a-typography-text>
</a-form-item>
</template>
<p v-else-if="!hasLimits" class="hint">
<a-typography-text v-else-if="!hasLimits" type="secondary">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p>
</div>
</a-typography-text>
</a-flex>
</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>
+103 -177
View File
@@ -1,5 +1,15 @@
<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,
@@ -19,6 +29,8 @@ 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;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
@@ -115,6 +127,10 @@ 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)
@@ -229,195 +245,105 @@ 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 class="dap">
<p v-if="!visibleSections.length" class="muted">
<div>
<a-typography-text v-if="!visibleSections.length" type="secondary">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</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"
</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'"
:disabled="disabled"
@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"
@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
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
/>
دسترسی به مدیریت مالی
</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"
>
دسترسی به مدیریت مالی
</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
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</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)"
>
حساب‌هایی که خودش مسئول آن‌هاست
</a-checkbox>
<a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<a-input
v-model:value="treasuryAccountQuery"
placeholder="جستجوی حساب"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
allow-clear
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
<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>
</template>
</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>
</a-space>
</a-tab-pane>
</a-tabs>
</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>
</template>
+5 -3
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,7 +259,8 @@ onMounted(load);
:style="{ marginTop: '12px' }"
/>
<a-spin :spinning="loading">
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="5">
<a-list
v-if="items.length"
item-layout="horizontal"
@@ -304,6 +305,7 @@ onMounted(load);
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }"
/>
</a-spin>
</CrmSkeletonList>
</CrmSkeleton>
</a-card>
</template>
@@ -0,0 +1,568 @@
<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 '@ant-design/icons-vue';
import { PrinterOutlined } from '@/icons/ant-bridge';
import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money';
+60 -109
View File
@@ -6,7 +6,7 @@ import {
ClockCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
addJalaliMonths,
dateToJalali,
@@ -52,8 +52,6 @@ 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);
@@ -320,46 +318,21 @@ function place() {
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
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;
const width = panel?.offsetWidth || (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 320);
const height = panel?.offsetHeight || (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 360);
let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width;
}
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;
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 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();
@@ -371,7 +344,6 @@ async function show() {
calView.value = 'days';
clockFace.value = 'hour';
syncViewport();
liftAboveOverlays();
open.value = true;
await nextTick();
place();
@@ -566,7 +538,7 @@ onBeforeUnmount(() => {
'is-desktop': !isMobile,
}"
role="dialog"
:style="{ top: `${top}px`, left: `${left}px`, zIndex: popupZ }"
:style="{ top: `${top}px`, left: `${left}px` }"
>
<header v-if="mode !== 'time'" class="nav">
<a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)">
@@ -754,7 +726,7 @@ onBeforeUnmount(() => {
>
پاک کردن
</a-button>
<a-button type="primary" @click="hide">تأیید</a-button>
<a-button type="primary" class="done" @click="hide">تأیید</a-button>
</footer>
</div>
</Teleport>
@@ -879,11 +851,10 @@ onBeforeUnmount(() => {
.panel {
position: fixed;
z-index: 120;
box-sizing: border-box;
width: min(20.5rem, calc(100vw - 16px));
/* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */
max-height: none;
overflow: hidden;
max-height: calc(100vh - 16px);
overflow-x: hidden;
overflow-y: auto;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
@@ -891,13 +862,6 @@ 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));
}
@@ -929,40 +893,45 @@ onBeforeUnmount(() => {
.pick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.25rem 0;
}
.panel :deep(.pick-cell.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 44px !important;
padding-inline: 0 !important;
.panel :deep(.ant-btn.pick-cell) {
width: 100%;
min-width: 0;
height: 44px;
margin: 0;
padding: 0 4px;
border-radius: var(--radius);
color: #0f172a;
font-size: 0.85rem;
}
.panel :deep(.pick-cell.today) {
.panel :deep(.ant-btn.pick-cell.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.pick-cell.on),
.panel :deep(.pick-cell.ant-btn:hover) {
.panel :deep(.ant-btn.pick-cell.on),
.panel :deep(.ant-btn.pick-cell:hover) {
background: #0d9488;
color: #fff;
}
.nav-btn {
.panel :deep(.ant-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: grid;
place-items: center;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
@@ -975,7 +944,6 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.15rem;
width: 100%;
min-width: 0;
}
@@ -986,43 +954,40 @@ onBeforeUnmount(() => {
padding-bottom: 0.25rem;
}
.panel :deep(.day.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 34px !important;
padding: 0 !important;
.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;
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(.day.ant-btn > span) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.panel :deep(.day.mute) {
.panel :deep(.ant-btn.day.mute) {
color: #cbd5e1;
}
.panel :deep(.day.today) {
.panel :deep(.ant-btn.day.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.day.on),
.panel :deep(.day.ant-btn:hover) {
.panel :deep(.ant-btn.day.on),
.panel :deep(.ant-btn.day:hover) {
background: #0d9488;
color: #fff;
}
.panel :deep(.day.mute:hover) {
.panel :deep(.ant-btn.day.mute:hover) {
background: #f1f5f9;
color: #64748b;
}
@@ -1052,9 +1017,11 @@ onBeforeUnmount(() => {
margin-bottom: 0.85rem;
}
.md-chip {
.panel :deep(.ant-btn.md-chip) {
min-width: 5.1rem;
height: 4.1rem;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
@@ -1067,7 +1034,7 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.md-chip.on {
.panel :deep(.ant-btn.md-chip.on) {
background: #ccfbf1;
color: #0f766e;
box-shadow: inset 0 0 0 2px #99f6e4;
@@ -1141,10 +1108,11 @@ onBeforeUnmount(() => {
pointer-events: none;
}
.md-tick {
.panel :deep(.ant-btn.md-tick) {
position: absolute;
z-index: 2;
width: 36px;
min-width: 36px;
height: 36px;
margin: 0;
border: 0;
@@ -1161,12 +1129,12 @@ onBeforeUnmount(() => {
padding: 0;
}
.md-tick.inner {
.panel :deep(.ant-btn.md-tick.inner) {
font-size: 0.74rem;
color: #64748b;
}
.md-tick.on {
.panel :deep(.ant-btn.md-tick.on) {
background: transparent;
color: #fff;
}
@@ -1215,9 +1183,12 @@ onBeforeUnmount(() => {
overscroll-behavior: contain;
}
.col-item {
.panel :deep(.ant-btn.col-item) {
width: 100%;
min-width: 0;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: transparent;
@@ -1228,11 +1199,11 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.col-item:hover {
.panel :deep(.ant-btn.col-item:hover) {
background: #e2e8f0;
}
.col-item.on {
.panel :deep(.ant-btn.col-item.on) {
background: #0d9488;
color: #fff;
}
@@ -1244,28 +1215,8 @@ onBeforeUnmount(() => {
margin-top: 0.7rem;
}
.link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
.panel :deep(.ant-btn.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 {
+5 -3
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,7 +163,8 @@ onMounted(load);
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<a-spin :spinning="loading">
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="4">
<a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده.
</a-typography-text>
@@ -201,6 +202,7 @@ onMounted(load);
</a-list-item>
</template>
</a-list>
</a-spin>
</CrmSkeletonList>
</CrmSkeleton>
</a-card>
</template>
+95 -166
View File
@@ -5,6 +5,20 @@ 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;
@@ -148,193 +162,108 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
</script>
<template>
<section class="ai-panel">
<header>
<strong>پیشنهاد {{ brand.aiName }}</strong>
<button
<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
v-if="!isPending"
type="button"
class="link"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? '…' : 'تحلیل مجدد' }}
</button>
<button
</a-button>
<a-button
v-else-if="!suggestion"
type="button"
class="link"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</button>
</header>
</a-button>
</a-flex>
<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>
<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>
<template v-else-if="isPending && payload">
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
<a-typography-paragraph
v-if="formatPayloadSummary(payload)"
type="secondary"
:style="{ marginTop: '8px' }"
>
{{ formatPayloadSummary(payload) }}
</a-typography-paragraph>
<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="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="memoryItems.length" class="block">
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p>
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
<label
<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
v-for="(_, index) in memoryItems"
:key="index"
class="memory-row"
:gap="8"
align="flex-start"
>
<input
v-model="selectedMemory[index]"
type="checkbox"
<a-checkbox
v-model:checked="selectedMemory[index]"
:disabled="!hasPerson"
/>
<textarea v-model="memoryEdits[index]" rows="2" />
</label>
</div>
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
</a-flex>
</a-flex>
<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>
<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>
</template>
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
</section>
<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>
</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>
+307 -37
View File
@@ -8,8 +8,20 @@ import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
defineProps<{
compact?: boolean;
const props = withDefaults(
defineProps<{
compact?: boolean;
variant?: 'default' | 'workspace';
}>(),
{
compact: false,
variant: 'default',
},
);
const emit = defineEmits<{
open: [];
close: [];
}>();
const auth = useAuthStore();
@@ -28,10 +40,28 @@ 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 منو همیشه باز می‌شود (لیست org + محیط جدید)؛ در حالت پیش‌فرض فقط وقتی بیش از یکی باشد */
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
@@ -49,34 +79,62 @@ async function loadOrgs() {
}
}
function closeMenu() {
if (!open.value) return;
open.value = false;
emit('close');
}
async function toggle() {
if (!canSwitch.value) return;
if (!menuEnabled.value) return;
error.value = '';
if (open.value) {
open.value = false;
closeMenu();
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 = {
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`,
...beside,
top: 'auto',
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
};
}
async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) {
open.value = false;
closeMenu();
return;
}
loading.value = true;
@@ -91,7 +149,7 @@ async function choose(org: OrganizationMembershipDto) {
}
function openCreate() {
open.value = false;
closeMenu();
newName.value = '';
newField.value = 'personal';
error.value = '';
@@ -123,9 +181,11 @@ function onDocClick(event: MouseEvent) {
const target = event.target;
if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
open.value = false;
closeMenu();
}
defineExpose({ close: closeMenu });
onMounted(() => {
void loadOrgs();
document.addEventListener('click', onDocClick);
@@ -137,27 +197,46 @@ onUnmounted(() => {
</script>
<template>
<div ref="rootEl" class="org-switch">
<div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
<button
class="org-btn"
:class="{ compact, static: !canSwitch }"
:class="{
compact,
static: !menuEnabled,
'org-ws': isWorkspace,
open,
}"
type="button"
:title="currentName"
:aria-disabled="!canSwitch"
:aria-expanded="open"
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
:aria-disabled="!menuEnabled"
@click.stop="toggle"
>
<UiIcon name="building" :size="18" />
<span>{{ currentName }}</span>
<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>
</button>
<Teleport to="body">
<div
v-if="open && canSwitch"
v-if="open && menuEnabled"
ref="menuEl"
class="menu"
:class="{ 'org-ws': isWorkspace }"
:style="menuStyle"
role="listbox"
aria-label="محیط‌های کاری"
>
<p class="hint">ورود به محیط</p>
<p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
<button
v-for="org in orgs"
:key="org.id"
@@ -165,10 +244,21 @@ 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)"
>
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
<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"
/>
</button>
<button type="button" class="create" :disabled="loading" @click="openCreate">
محیط جدید
@@ -234,7 +324,7 @@ onUnmounted(() => {
margin-top: 0.05rem;
}
.org-btn span {
.org-btn > span {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
@@ -253,7 +343,7 @@ onUnmounted(() => {
color: #94a3b8;
}
.org-btn.compact {
.org-btn.compact:not(.org-ws) {
display: grid;
place-items: center;
width: 100%;
@@ -262,18 +352,113 @@ onUnmounted(() => {
border-radius: var(--radius);
}
.org-btn.compact span {
.org-btn.compact:not(.org-ws) > span {
display: none;
}
.org-btn.compact:hover {
.org-btn.compact:not(.org-ws):hover {
background: #f8fafc;
}
.org-btn.compact.static:hover {
.org-btn.compact.static:not(.org-ws):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;
@@ -286,11 +471,21 @@ 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.25rem 0.45rem;
font-size: 0.72rem;
color: var(--muted);
padding: 0.35rem 0.65rem 0.45rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #94a3b8;
}
.org-item,
@@ -305,29 +500,104 @@ onUnmounted(() => {
cursor: pointer;
}
.org-item strong {
font-size: 0.82rem;
.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 span {
font-size: 0.72rem;
color: var(--muted);
.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;
}
.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:hover,
.create:hover {
background: color-mix(in srgb, var(--accent-soft) 70%, white);
background: #f3f4f6;
}
.menu.org-ws .org-item:hover {
background: #f3f4f6;
}
.menu.org-ws .org-item:hover .org-item-mark {
background: #e2e8f0;
}
.org-item.on {
background: var(--accent-soft);
background: #f3f4f6;
}
.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);
padding: 0.7rem 0.65rem 0.55rem;
border-top: 1px solid #eef2f7;
border-radius: 0 0 12px 12px;
color: var(--accent-deep);
font-weight: 700;
font-size: 0.82rem;
font-size: 0.86rem;
}
.menu.org-ws .create:hover {
background: #f8fafc;
color: #0f766e;
}
.err {
@@ -18,7 +18,7 @@ import {
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@/icons/ant-bridge';
import {
filterPhoneContacts,
isContactPickerSupported,
+339 -65
View File
@@ -185,6 +185,13 @@ 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;
@@ -193,28 +200,33 @@ const panelStyle = computed(() => {
left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, 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);
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',
};
if (placeAbove) {
return {
left: `${left}px`,
right: 'auto',
...shared,
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
};
}
return {
left: `${left}px`,
right: 'auto',
...shared,
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
};
});
@@ -238,10 +250,6 @@ function onKeydown(ev: KeyboardEvent) {
}
}
watch(isQuickCreateOpen, (value) => {
document.body.style.overflow = value ? 'hidden' : '';
});
watch(hidden, (value) => {
if (value) close();
});
@@ -264,46 +272,51 @@ 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">
<button
v-if="isQuickCreateOpen"
class="overlay"
type="button"
aria-label="بستن ایجاد سریع"
@click="close"
/>
<Transition name="qc-overlay">
<button
v-if="isQuickCreateOpen"
class="overlay"
type="button"
aria-label="بستن ایجاد سریع"
@click="close"
/>
</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>
<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>
<button
v-if="ready"
@@ -319,6 +332,8 @@ 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>
@@ -338,11 +353,12 @@ onBeforeUnmount(() => {
z-index: 90;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.68);
background: rgba(15, 23, 42, 0.42);
cursor: pointer;
}
.panel {
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
z-index: 92;
width: 300px;
@@ -352,10 +368,90 @@ onBeforeUnmount(() => {
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: 14px;
padding: 12px 12px 10px;
box-shadow: var(--shadow-lg);
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;
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 {
@@ -369,12 +465,20 @@ 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 {
@@ -450,6 +554,7 @@ onBeforeUnmount(() => {
}
.fab {
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
z-index: 93;
box-sizing: border-box;
@@ -459,57 +564,226 @@ onBeforeUnmount(() => {
margin: 0;
border: 0;
border-radius: var(--radius);
overflow: hidden;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
overflow: visible;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
background-size: 140% 140%;
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),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
0 0 0 0 rgba(45, 212, 191, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
cursor: grab;
touch-action: none;
user-select: none;
transition:
filter 0.15s ease,
box-shadow 0.15s ease,
transform 0.12s ease;
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;
}
.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(.ico),
.fab :deep(svg) {
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.06);
filter: brightness(1.07) saturate(1.05);
transform: translateY(-1px) scale(1.04);
box-shadow:
0 6px 18px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
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);
}
.fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
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);
}
.fab.dragging {
cursor: grabbing;
transform: scale(1.05);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
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;
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<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 '@ant-design/icons-vue';
import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Checkbox as ACheckbox,
+81 -97
View File
@@ -118,77 +118,82 @@ defineExpose({ refresh: load });
<div v-if="visible" class="sms-wallet">
<div
class="chip"
:class="toneClass"
:class="[toneClass, (summary?.balanceRials ?? 0) < 0 ? 'is-debt' : '']"
role="button"
tabindex="0"
: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 v-if="chargeInfo" class="charge-body">
<div class="instruction-box">
<p>{{ chargeInfo.instruction }}</p>
</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 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>
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
<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>
<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>
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</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 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>
</div>
</Teleport>
</div>
</template>
@@ -200,15 +205,15 @@ defineExpose({ refresh: load });
flex-shrink: 0;
}
/* چیپ جمع‌وجور؛ دکمه شارژ سمت چپ، برچسب سمت راست */
/* چیپ جمع‌وجور؛ کلیک کل سطح، پنجرهٔ شارژ را باز می‌کند */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 31px;
height: var(--control-h);
box-sizing: border-box;
margin: 0;
padding: 0 4px 0 2px;
padding: 0 8px;
border: 1px solid #c3ead6;
border-radius: 10px;
background: #f2fbf6;
@@ -217,6 +222,7 @@ defineExpose({ refresh: load });
line-height: 1;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
}
.chip.warning {
@@ -232,6 +238,17 @@ 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;
@@ -239,52 +256,19 @@ defineExpose({ refresh: load });
}
.amount {
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;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.layer {
position: fixed;
inset: 0;
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
overflow: auto;
box-sizing: border-box;
}
.backdrop {
@@ -295,9 +279,8 @@ defineExpose({ refresh: load });
.modal {
position: relative;
width: min(420px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
margin: auto;
width: min(420px, 100%);
max-height: min(90vh, 640px);
overflow: auto;
background: #fff;
border-radius: 16px;
@@ -305,6 +288,7 @@ 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 '@ant-design/icons-vue';
import { SettingOutlined } from '@/icons/ant-bridge';
import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali';
+283 -364
View File
@@ -19,9 +19,36 @@ 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[];
@@ -37,7 +64,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('');
const priorPick = ref<string | undefined>(undefined);
const priorSavingId = ref('');
const cutoffLocal = ref('');
const cutoffSaving = ref(false);
@@ -48,10 +75,16 @@ 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: [];
}>();
@@ -74,9 +107,14 @@ 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 = '';
priorPick.value = undefined;
error.value = '';
ok.value = '';
try {
@@ -114,7 +152,10 @@ 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;
@@ -136,7 +177,10 @@ 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 {
@@ -144,9 +188,10 @@ async function onCutoffChange(value: string) {
}
}
function pickPriorUser(userId: string) {
if (!userId) return;
void setPriorArchive(userId, true);
function pickPriorUser(userId: unknown) {
const id = userId == null ? '' : String(userId);
if (!id) return;
void setPriorArchive(id, true);
}
async function load() {
@@ -243,9 +288,19 @@ function toggleMember(userId: string) {
}
}
function setRole(userId: string, role: TeamMemberRole) {
function setRole(userId: string, role: unknown) {
if (!memberDraft.value[userId]) return;
memberDraft.value = { ...memberDraft.value, [userId]: role };
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('، ');
}
async function saveMembers() {
@@ -365,362 +420,226 @@ onMounted(() => {
</script>
<template>
<div class="teams-panel">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<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 = ''" />
<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-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 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-divider orientation="right">تیم‌ها</a-divider>
<CrmSkeleton :loading="loading">
<CrmSkeletonCard :rows="6">
<a-empty
v-if="!loading && !teams.length"
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
</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-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>
<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-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="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<div class="actions">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
</div>
</div>
</div>
</div>
</section>
</div>
<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>
</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>
+7 -346
View File
@@ -1,4 +1,10 @@
<script setup lang="ts">
/**
* آیکون‌های legacy — حالا از پک vuesax-linear رندر می‌شوند.
* API نام‌ها برای سازگاری با صفحات قدیمی حفظ شده.
*/
import VsIcon from '@/components/VsIcon.vue';
defineProps<{
name:
| 'home'
@@ -82,352 +88,7 @@ defineProps<{
</script>
<template>
<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>
<VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
</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 '@ant-design/icons-vue';
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
+76
View File
@@ -0,0 +1,76 @@
<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,8 +10,10 @@ 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,
@@ -429,34 +431,58 @@ function openActivity(id: string) {
</script>
<template>
<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"
/>
<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>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -480,9 +506,4 @@ function openActivity(id: string) {
display: flex;
flex-direction: column;
}
.muted {
margin: 0;
color: #64748b;
}
</style>
@@ -11,12 +11,34 @@ 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 UiIcon from '@/components/UiIcon.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;
const props = defineProps<{
fields: ActivityFieldDef[];
@@ -24,8 +46,6 @@ 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];
@@ -46,9 +66,20 @@ function multi(key: string): string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
}
function toggleMulti(key: string, opt: string) {
const list = multi(key);
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
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));
}
/* ---------- چک‌لیست ---------- */
@@ -56,7 +87,9 @@ 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) {
@@ -115,39 +148,82 @@ function addGuest(key: string) {
const personQuery = ref<Record<string, string>>({});
const personResults = ref<Record<string, PersonDto[]>>({});
const personOpenKey = ref('');
const personSearching = ref<Record<string, boolean>>({});
let personTimer: ReturnType<typeof setTimeout> | undefined;
let personSeq = 0;
function searchPeople(key: string) {
personOpenKey.value = key;
function searchPeople(key: string, raw: string) {
personQuery.value = { ...personQuery.value, [key]: raw };
if (personTimer) clearTimeout(personTimer);
const q = (personQuery.value[key] ?? '').trim();
const q = raw.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]: [] };
if (seq === personSeq) {
personResults.value = { ...personResults.value, [key]: [] };
}
} finally {
if (seq === personSeq) {
personSearching.value = { ...personSearching.value, [key]: false };
}
}
}, 280);
}
function addPerson(key: string, p: PersonDto) {
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;
const cur = participants(key);
if (!cur.people.some((x) => x.id === p.id)) {
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
if (!cur.people.some((x) => x.id === hit.id)) {
patchParticipants(key, { people: [...cur.people, { id: hit.id, name: hit.fullName }] });
}
personQuery.value = { ...personQuery.value, [key]: '' };
personResults.value = { ...personResults.value, [key]: [] };
personOpenKey.value = '';
}
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),
});
}
watch(
@@ -157,523 +233,237 @@ watch(
guestDraft.value = {};
personQuery.value = {};
personResults.value = {};
personSearching.value = {};
},
);
</script>
<template>
<div class="fields-grid">
<div
<a-row :gutter="[16, 0]">
<a-col
v-for="f in fields"
:key="f.key"
class="cf"
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
:xs="24"
:md="fieldSpan(f.type)"
>
<label :for="`cf-${f.key}`">
{{ f.label }}
<i v-if="f.required">*</i>
</label>
<a-form-item :required="f.required">
<template #label>
{{ f.label }}
</template>
<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)"
<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)"
/>
<i />
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
</label>
<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"
<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
:checked="it.done"
aria-label="انجام شد"
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
@update:checked="(v: boolean) => updateChecklistItem(f.key, it.id, { done: v })"
/>
<input
class="item-text"
<a-input
:value="it.text"
maxlength="300"
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
:maxlength="300"
:style="{
flex: 1,
textDecoration: it.done ? 'line-through' : undefined,
}"
@update:value="(v: string) => updateChecklistItem(f.key, it.id, { text: v })"
/>
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
<button
type="button"
class="mini"
<a-button
:disabled="idx === 0"
aria-label="بالا"
@click="moveChecklistItem(f.key, idx, -1)"
>
<template #icon><UpOutlined /></template>
</a-button>
<a-button
:disabled="idx === checklist(f.key).length - 1"
aria-label="پایین"
@click="moveChecklistItem(f.key, idx, 1)"
>
▼
</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>
<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) })"
>
×
</button>
</span>
</div>
<div class="person-search">
<input
v-model="personQuery[f.key]"
type="search"
placeholder="جستجوی نام یا موبایل شخص…"
@input="searchPeople(f.key)"
@focus="personOpenKey = f.key"
<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)"
/>
<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) })"
>
×
</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" />
<a-button type="dashed" @click="addChecklistItem(f.key)">
<template #icon><PlusOutlined /></template>
افزودن
</button>
</div>
</div>
</div>
</div>
</div>
</a-button>
</a-space>
</a-flex>
<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)"
>
<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()
? 'شخصی پیدا نشد.'
: 'نام یا موبایل را جستجو کنید'
"
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)"
>
{{ 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>
</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,10 +12,25 @@ 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;
@@ -62,6 +77,19 @@ 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(
@@ -78,9 +106,9 @@ async function save(stage: ActivityStageDto) {
}
if (
d.kind !== stage.kind &&
!window.confirm(
!(await confirmAsync(
'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟',
)
))
) {
return;
}
@@ -119,23 +147,25 @@ async function move(index: number, dir: -1 | 1) {
}
async function remove(stage: ActivityStageDto) {
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 = '';
}
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 = '';
}
},
});
}
async function create() {
@@ -156,226 +186,163 @@ 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>
<UiModal
<a-modal
:open="open"
:title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'"
@close="emit('close')"
:footer="null"
@cancel="emit('close')"
>
<div class="manager">
<p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <b>لغو شده</b> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</p>
<a-flex vertical gap="middle">
<a-typography-text type="secondary">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <strong>انجام‌شده</strong> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <strong>لغو شده</strong> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</a-typography-text>
<p v-if="error" class="error">{{ error }}</p>
<a-alert v-if="error" type="error" :message="error" show-icon />
<div class="rows">
<div
<a-flex vertical gap="small">
<a-row
v-for="(stage, index) in stages"
:key="stage.id"
class="row"
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }"
:gutter="[8, 8]"
align="middle"
>
<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-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%"
>
▲
</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="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>
<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-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>
<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>
<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>
</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,6 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import AutomationNode from '@/components/automations/AutomationNode.vue';
import {
AUTOMATION_ACTION_LABELS,
@@ -22,6 +21,23 @@ 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 }
@@ -42,8 +58,15 @@ const props = withDefaults(
form?: LeadFormDto | null;
users?: OrgUserDto[];
teams?: TeamDto[];
fullscreen?: boolean;
}>(),
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
{
chainMode: 'stop',
form: null,
users: () => [],
teams: () => [],
fullscreen: false,
},
);
const emit = defineEmits<{
@@ -52,6 +75,7 @@ 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);
@@ -60,6 +84,116 @@ 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);
@@ -151,10 +285,10 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده';
}
function actionIcon(action: AutomationAction) {
if (action === 'create_notification') return 'bell';
if (action === 'add_loyalty_points') return 'gift';
return 'flag';
function actionIcon(action: AutomationAction): Component {
if (action === 'create_notification') return BellOutlined;
if (action === 'add_loyalty_points') return GiftOutlined;
return FlagOutlined;
}
function stepTitle(step: AutomationStep) {
@@ -208,13 +342,20 @@ function stepHint(step: AutomationStep) {
}
function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value) return;
if (!canAdd.value || tool.value === 'hand') 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);
@@ -234,7 +375,19 @@ function removeBranch(lane: BranchLane) {
</script>
<template>
<div class="canvas" @click.self="addAt = null">
<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="flow">
<button
class="node trigger"
@@ -243,7 +396,7 @@ function removeBranch(lane: BranchLane) {
@click="emit('select', 'trigger')"
>
<span class="icon">
<UiIcon name="sparkle" :size="18" />
<ThunderboltOutlined />
</span>
<span class="copy">
<span class="kind">اگر</span>
@@ -265,8 +418,46 @@ function removeBranch(lane: BranchLane) {
title="افزودن گره"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="item.end ? 16 : 14" />
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
</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
@@ -292,7 +483,7 @@ function removeBranch(lane: BranchLane) {
title="حذف این شاخه"
@click.stop="removeBranch(lane)"
>
<UiIcon name="trash" :size="12" />
<DeleteOutlined :style="{ fontSize: '12px' }" />
</button>
</div>
<template v-for="item in lane.items" :key="item.key">
@@ -308,8 +499,46 @@ function removeBranch(lane: BranchLane) {
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="14" />
<PlusOutlined :style="{ fontSize: '14px' }" />
</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
@@ -330,65 +559,146 @@ function removeBranch(lane: BranchLane) {
:title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')"
>
<UiIcon name="plus" :size="14" />
<PlusOutlined :style="{ fontSize: '14px' }" />
شاخهٔ جدید
</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: auto;
overflow: hidden;
background-color: #f3f6f5;
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
background-size: 16px 16px;
padding: 1.6rem 1.1rem 1.25rem;
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;
}
.flow {
@@ -396,7 +706,7 @@ function removeBranch(lane: BranchLane) {
flex-direction: row;
align-items: center;
min-width: max-content;
min-height: 16rem;
min-height: calc(100% - 2rem);
}
.node.trigger {
@@ -433,6 +743,7 @@ function removeBranch(lane: BranchLane) {
flex-shrink: 0;
color: #c2410c;
background: #ffedd5;
font-size: 18px;
}
.copy {
@@ -535,7 +846,7 @@ strong {
position: relative;
display: flex;
align-items: center;
padding-block: 1.3rem;
padding-block: 0.7rem;
}
.branch::before {
@@ -608,66 +919,114 @@ strong {
}
.picker {
margin-top: 1rem;
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
z-index: 30;
width: 220px;
padding: 6px;
display: grid;
gap: 2px;
background: #fff;
border: 1px solid #d5ddd8;
border-radius: var(--radius);
padding: 0.85rem;
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 {
display: grid;
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;
grid-template-columns: 32px 1fr;
align-items: center;
gap: 0.4rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.45rem 0.75rem;
font: inherit;
font-weight: 700;
font-size: 0.8rem;
gap: 8px;
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
padding: 6px 8px;
text-align: start;
cursor: pointer;
color: #0f172a;
font: inherit;
color: inherit;
}
.picks .create_activity {
.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;
}
.picks .create_notification {
color: #1d4ed8;
}
.picks .add_loyalty_points {
color: #7c3aed;
}
.picks .condition {
.pick.condition .pick-ico {
background: #fff7ed;
color: #b45309;
}
.cancel {
justify-self: start;
border: 0;
background: none;
color: #64748b;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
.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;
color: #0f172a;
}
.pick-copy small {
font-size: 11px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cap {
@@ -680,7 +1039,10 @@ strong {
@media (max-width: 700px) {
.canvas {
min-height: 0;
padding: 1rem 0.75rem;
}
.world {
padding: 1rem 0.75rem 4rem;
}
.flow,
@@ -749,7 +1111,7 @@ strong {
}
.picker {
max-width: none;
width: min(220px, 70vw);
}
}
</style>
@@ -31,6 +31,24 @@ 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;
@@ -124,6 +142,87 @@ 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);
@@ -131,15 +230,6 @@ 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 'هیچ‌کدام از این گزینه‌ها نباید انتخاب شده باشد.';
@@ -162,6 +252,94 @@ 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;
@@ -202,440 +380,273 @@ watch(
</script>
<template>
<aside class="inspector">
<a-form layout="vertical" :style="{ marginBottom: 0 }">
<template v-if="selected === 'trigger'">
<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>
<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="برای شرط روی پاسخ فرم، یک فرم مشخص انتخاب کنید."
/>
</template>
<template v-else-if="selectedStep && isCondition">
<h2>گره شرط</h2>
<p class="hint">
{{
chainMode === 'branches'
? 'اگر شرط برقرار نباشد، فقط عمل‌های این شاخه (تا شرط بعدی) اجرا نمی‌شوند.'
: 'اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند.'
}}
</p>
<label>
فیلد
<select
<a-form-item label="فیلد" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
:value="selectedStep.conditionField"
@change="
setConditionField(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationConditionField,
)
"
>
<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>
: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">
<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">
<a-form-item
v-if="conditionFormField?.options?.length"
label="گزینه‌ها"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<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` }"
>
{{ multiValueHint(selectedStep.conditionOp) }}
</p>
</div>
<label v-else>
مقدار
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
</label>
</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>
</template>
</template>
<template v-else-if="selectedStep">
<h2>گره عمل</h2>
<p class="hint">بعد از محرک (و شرط‌ها)، این کار به ترتیب انجام می‌شود.</p>
<label>
آنگاه
<select
<a-form-item label="آنگاه" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
:value="selectedStep.action"
@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>
<label>
عنوان
<input
v-model="selectedStep.title"
type="text"
maxlength="200"
: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="اختیاری"
/>
</label>
<label>
توضیح / متن پیام
<textarea
v-model="selectedStep.body"
rows="3"
maxlength="1000"
</a-form-item>
<a-form-item label="توضیح / متن" :style="{ marginBottom: `${token.marginSM}px` }">
<a-textarea
v-model:value="selectedStep.body"
:rows="2"
:maxlength="1000"
placeholder="اختیاری"
/>
</label>
</a-form-item>
<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"
<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` }"
>
هر بار نفر بعدی به نوبت انتخاب می‌شود تا کارها مساوی تقسیم شوند.
</p>
<div class="due">
<label>
موعد
<select
:value="dueUnitOf(selectedStep)"
@change="
setDue(
selectedStep,
($event.target as HTMLSelectElement).value as DueUnit,
dueAmountOf(selectedStep) || 1,
)
"
<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)"
>
<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>
{{ 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="هنوز تیمی تعریف نشده."
/>
</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'">
<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-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>
<a-select
:value="nullableId(selectedStep.notifyUserId)"
:options="notifyUserOptions"
style="width: 100%"
@change="onNotifyUserIdChange"
/>
</a-form-item>
</template>
<label v-if="selectedStep.action === 'add_loyalty_points'">
امتیاز
<input v-model.number="selectedStep.points" type="number" min="1" />
</label>
<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>
</template>
<p v-else class="hint">یک گره را از بوم انتخاب کنید.</p>
</aside>
<a-typography-text v-else type="secondary">یک گره را از بوم انتخاب کنید.</a-typography-text>
</a-form>
</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,6 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, type Component } from 'vue';
import {
BellOutlined,
DeleteOutlined,
FilterOutlined,
FlagOutlined,
GiftOutlined,
} from '@/icons/ant-bridge';
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{
@@ -15,11 +21,11 @@ const emit = defineEmits<{ select: []; remove: [] }>();
const isCondition = computed(() => isAutomationCondition(props.step));
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 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 kindClass = computed(() =>
@@ -36,7 +42,7 @@ const kindClass = computed(() =>
@click="emit('select')"
>
<span class="icon">
<UiIcon :name="icon" :size="18" />
<component :is="icon" />
</span>
<span class="copy">
<span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span>
@@ -51,7 +57,7 @@ const kindClass = computed(() =>
title="حذف"
@click.stop="emit('remove')"
>
<UiIcon name="trash" :size="14" />
<DeleteOutlined />
</button>
</div>
</template>
@@ -125,6 +131,7 @@ const kindClass = computed(() =>
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
font-size: 18px;
}
.node.create_notification .icon {
@@ -184,6 +191,7 @@ em {
cursor: pointer;
opacity: 0;
pointer-events: none;
font-size: 14px;
}
.node-wrap:hover .trash,
@@ -0,0 +1,81 @@
<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,13 +2,30 @@
import { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import UiIcon from '@/components/UiIcon.vue';
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 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 ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
import { LinkOutlined } from '@/icons/ant-bridge';
const props = defineProps<{
askQuery: string;
@@ -26,11 +43,53 @@ 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: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
{ label: 'فعالیت‌های عقب‌افتاده چیست؟', icon: 'calendar' as const },
{ label: 'مهم‌ترین پیگیری امروز چیست؟', icon: 'send' as const },
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
{ label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' },
{ label: 'پیگیری امروز', prompt: 'مهم‌ترین پیگیری امروز چیست؟' },
{ label: 'خلاصه فروش', prompt: 'خلاصه وضعیت فروش را بده' },
];
const activityTypeOptions = [
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'task', label: 'کار' },
{ value: 'note', label: 'یادداشت' },
];
function sourceTo(source: AskMentoSourceDto) {
@@ -45,13 +104,6 @@ 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('');
@@ -60,6 +112,13 @@ 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;
@@ -272,12 +331,43 @@ watch(
</script>
<template>
<article id="home-ai-assistant" class="panel">
<header class="panel-head">
<h2>
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
{{ brand.aiNameFa }}
</h2>
<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"
/>
<VoiceToTextButton
variant="toolbar"
hide-error
@@ -286,322 +376,526 @@ watch(
@recording="onVoiceRecording"
@error="voiceError = $event"
/>
</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)"
>
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
<span>{{ item.label }}</span>
</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>
<form class="ai-input" @submit.prevent="onTypedSubmit">
<input
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
<a-button
type="primary"
: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>
:loading="asking || voiceBusy"
aria-label="ارسال"
@click="onTypedSubmit"
>
بپرس
</a-button>
</div>
<UiModal
:open="Boolean(draft && draft.intent !== 'none')"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft"
>
<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>
<UiButton variant="ghost" :disabled="confirming" @click="cancelDraft">انصراف</UiButton>
<UiButton variant="primary" :disabled="!draft?.canConfirm || confirming" :loading="confirming" @click="confirmDraft">
تأیید و انجام
</UiButton>
</template>
</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"
<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)"
>
{{ source.label }}
</RouterLink>
{{ item.label }}
</button>
</div>
</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"
/>
<template #footer>
<UiButton variant="primary" @click="closeAnswer">بستن</UiButton>
<a-button :disabled="confirming" @click="cancelDraft">انصراف</a-button>
<a-button
type="primary"
:disabled="!draft?.canConfirm || confirming"
:loading="confirming"
@click="confirmDraft"
>
تأیید و انجام
</a-button>
</template>
</UiModal>
</article>
</a-modal>
<a-modal
v-model:open="answerOpen"
:width="560"
centered
destroy-on-close
:body-style="{ paddingTop: `${token.paddingSM}px` }"
@cancel="closeAnswer"
>
<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>
<template #footer>
<a-flex justify="end" :gap="token.paddingXS">
<a-button type="primary" @click="closeAnswer">متوجه شدم</a-button>
</a-flex>
</template>
</a-modal>
</template>
<style scoped>
.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;
.home-ai {
--home-ai-h: 330px;
position: relative;
display: flex;
flex-direction: column;
}
.panel-head {
margin-bottom: 0.55rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.modal-lead {
margin: 0 0 0.85rem;
color: #334155;
font-size: 0.9rem;
line-height: 1.7;
}
.answer-body {
white-space: pre-wrap;
}
.edit-hint {
margin: -0.35rem 0 0.75rem;
color: #64748b;
font-size: 0.78rem;
}
.modal-fields {
display: grid;
gap: 0.55rem;
}
.modal-fields label {
display: grid;
gap: 0.25rem;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
}
.modal-fields input,
.modal-fields select,
.modal-fields textarea {
width: 100%;
gap: 0.75rem;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
font: inherit;
font-size: 0.88rem;
font-weight: 500;
padding: 0.5rem 0.65rem;
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);
}
.modal-fields textarea {
resize: vertical;
.home-ai__pattern,
.home-ai__orb {
pointer-events: none;
position: absolute;
}
.warn {
display: inline-block;
margin-inline-start: 0.35rem;
color: #b45309;
font-size: 0.75rem;
.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));
}
h2 {
margin: 0;
font-size: 0.92rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
.home-ai__orb {
border-radius: 999px;
filter: blur(28px);
}
.spark {
color: #eab308;
display: grid;
.home-ai__orb--a {
top: -2.5rem;
inset-inline-end: -1.5rem;
width: 7rem;
height: 7rem;
background: rgb(52 211 153 / 0.22);
}
.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;
.home-ai__orb--b {
bottom: -2rem;
inset-inline-start: -1.25rem;
width: 6rem;
height: 6rem;
background: rgb(45 212 191 / 0.16);
}
.ai-shortcuts {
display: grid;
gap: 0.4rem;
margin-bottom: 0.8rem;
flex: 1;
}
.ai-chip {
.home-ai__head,
.home-ai__identity,
.home-ai__composer,
.home-ai__chips,
.home-ai__chip-list {
position: relative;
z-index: 1;
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;
.home-ai__head {
justify-content: space-between;
gap: 0.75rem;
flex-shrink: 0;
}
.ai-input {
display: flex;
gap: 0.4rem;
margin-top: auto;
.home-ai__identity {
gap: 0.7rem;
min-width: 0;
}
.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;
.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;
}
.ai-input button:disabled {
opacity: 0.55;
.home-ai__avatar :deep(.mento-bot) {
width: 42px;
}
.ai-answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
.home-ai__avatar :deep(.bot-img) {
filter: drop-shadow(0 4px 10px rgba(34, 211, 238, 0.28));
}
.ai-answer p {
.home-ai__titles {
min-width: 0;
}
.home-ai__titles h3 {
margin: 0;
color: #134e4a;
font-size: 0.82rem;
line-height: 1.7;
font-size: 0.95rem;
font-weight: 800;
color: #0f172a;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.sources a {
.home-ai__titles p {
margin: 0.1rem 0 0;
font-size: 0.72rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.15rem 0.5rem;
color: #64748b;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-error {
margin: 0 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
.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;
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;
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;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.home-ai__chip:hover:not(:disabled) {
background: #d1fae5;
border-color: #6ee7b7;
}
.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;
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,200 @@
<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,10 +1,18 @@
<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 = {
@@ -28,6 +36,10 @@ 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[]>();
@@ -54,63 +66,78 @@ onMounted(async () => {
</script>
<template>
<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>
<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>
<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-space wrap>
<RouterLink
v-for="person in data.preview"
:key="person.id"
:to="{ name: 'person-detail', params: { id: person.id } }"
>
<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>
<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>
<SmsComposeModal
nest
@@ -126,289 +153,3 @@ 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>
@@ -0,0 +1,98 @@
<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>
@@ -0,0 +1,102 @@
<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>
@@ -0,0 +1,44 @@
<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>
+288 -348
View File
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue';
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 { SearchOutlined, SendOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Input as AInput,
Typography,
} from 'ant-design-vue';
import { brand } 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;
@@ -24,444 +26,382 @@ const emit = defineEmits<{
}>();
const auth = useAuthStore();
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
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 hasCustomPhoto = computed(() => Boolean(auth.user?.hasAvatar));
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>
<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="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" />
<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 class="home-hero__row">
<div class="home-hero__greet">
<div class="home-hero__identity" :aria-label="firstName">
<div class="home-hero__frame">
<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>
</div>
<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>
</div>
<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>
<div class="home-hero__ask">
<span class="home-hero__ask-ico" aria-hidden="true">
<SearchOutlined />
</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
<a-input
id="home-hero-ask"
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
:bordered="false"
class="home-hero__ask-input"
@update:value="emit('update:askQuery', $event)"
@press-enter="emit('ask')"
/>
<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>
<kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</kbd>
<a-button
type="primary"
shape="round"
:disabled="asking"
:loading="asking"
aria-label="ارسال"
@click="emit('ask')"
>
<template #icon><SendOutlined /></template>
<span class="home-hero__ask-label">بپرس</span>
</a-button>
</div>
</div>
</div>
<MentoAiAvatar class="hero-bot" :size="156" />
</header>
<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>
</template>
<style scoped>
.hero {
.home-hero {
position: relative;
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;
}
.hero-surface {
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius);
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
background-color: #134e4a;
background-image:
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(
105deg,
#0b3d3a 0%,
#115e59 22%,
#0f766e 48%,
#134e4a 72%,
#0f3f3c 100%
);
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);
border-radius: 1rem;
border: 1px solid rgb(110 231 183 / 0.5);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
.hero-glow {
position: absolute;
width: 320px;
height: 320px;
border-radius: 999px;
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);
.home-hero__gradient,
.home-hero__pattern,
.home-hero__orb {
pointer-events: none;
position: absolute;
}
.hero-mist {
position: absolute;
.home-hero__gradient,
.home-hero__pattern {
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;
}
.hero-body,
.hero-bot {
.home-hero__gradient {
background:
linear-gradient(
115deg,
#ecfdf5 0%,
#d1fae5 28%,
#ccfbf1 55%,
#f0fdfa 78%,
#ffffff 100%
);
}
.home-hero__pattern {
opacity: 0.45;
background-image:
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.28) 1px, transparent 0),
linear-gradient(
135deg,
rgba(20, 184, 166, 0.08) 25%,
transparent 25%,
transparent 50%,
rgba(20, 184, 166, 0.08) 50%,
rgba(20, 184, 166, 0.08) 75%,
transparent 75%,
transparent
);
background-size:
18px 18px,
28px 28px;
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
}
.home-hero__orb {
border-radius: 999px;
filter: blur(48px);
}
.home-hero__orb--start {
inset-inline-end: -5rem;
top: -6rem;
width: 13rem;
height: 13rem;
background: rgb(52 211 153 / 0.2);
}
.home-hero__orb--end {
inset-inline-start: -4rem;
bottom: -5rem;
width: 12rem;
height: 12rem;
background: rgb(20 184 166 / 0.15);
}
.home-hero__row {
position: relative;
z-index: 1;
}
.hero-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1rem;
width: 100%;
max-width: 100%;
padding-top: 0;
min-width: 0;
box-sizing: border-box;
padding: 1rem 1.25rem;
}
.hero-greet {
.home-hero__greet {
display: flex;
align-items: center;
gap: 0.9rem;
gap: 0.875rem;
min-width: 0;
max-width: 100%;
max-width: 22rem;
}
.greet-text {
min-width: 0;
}
.hero-avatar {
.home-hero__identity {
position: relative;
width: 72px;
height: 72px;
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
width: 3.25rem;
height: 3.25rem;
}
.hero-avatar img {
.home-hero__frame {
width: 100%;
height: 100%;
border-radius: 1rem;
padding: 2px;
background: linear-gradient(145deg, #34d399 0%, #0d9488 48%, #059669 100%);
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.85),
0 8px 18px -10px rgba(4, 120, 87, 0.55);
}
.home-hero__photo,
.home-hero__monogram {
display: flex;
width: 100%;
height: 100%;
border-radius: calc(1rem - 2px);
object-fit: cover;
display: block;
border-radius: 999px;
border: 3px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.22);
box-sizing: border-box;
}
.online {
.home-hero__monogram {
align-items: center;
justify-content: center;
font-size: 1.15rem;
font-weight: 800;
color: #ecfdf5;
background:
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.22), transparent 45%),
linear-gradient(160deg, #10b981 0%, #0f766e 100%);
letter-spacing: -0.02em;
user-select: none;
}
.home-hero__online {
position: absolute;
bottom: 3px;
inset-inline-start: 3px;
width: 14px;
height: 14px;
inset-inline-start: -1px;
bottom: -1px;
width: 0.85rem;
height: 0.85rem;
border-radius: 999px;
background: #22c55e;
background: #10b981;
border: 2px solid #fff;
z-index: 1;
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
}
.greet-text h1 {
.home-hero__copy {
min-width: 0;
}
.home-hero__brand {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
font-size: 11px;
font-weight: 600;
letter-spacing: -0.01em;
color: #fff;
letter-spacing: 0.02em;
color: rgb(4 120 87 / 0.8);
}
.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;
}
.chip {
display: flex;
align-items: center;
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;
.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;
white-space: nowrap;
}
.chip-text small {
font-size: 0.6rem;
color: #64748b;
font-weight: 500;
line-height: 1.25;
.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;
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;
}
.hero-ask {
.home-hero__ask {
display: flex;
align-items: center;
gap: 0.55rem;
gap: 0.5rem;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding-block: 0.375rem;
padding-inline: 0.75rem 0.5rem;
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);
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;
}
.ask-ico {
color: #14b8a6;
.home-hero__ask:focus-within {
border-color: #6ee7b7;
box-shadow:
0 0 0 2px rgb(167 243 208 / 0.7),
0 4px 12px rgb(6 78 59 / 0.05);
}
.home-hero__ask-ico {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.75rem;
background: #ecfdf5;
color: #059669;
}
.hero-ask input {
border: 0;
background: transparent;
flex: 1;
outline: none;
font-size: 0.9rem;
.home-hero__ask-input {
flex: 1 1 auto;
min-width: 0;
background: transparent !important;
}
.hero-ask input::placeholder {
color: #94a3b8;
}
.hero-ask kbd {
font-size: 0.68rem;
color: #94a3b8;
background: #f1f5f9;
.home-hero__ask-kbd {
display: none;
flex-shrink: 0;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.14rem 0.38rem;
flex-shrink: 0;
background: #f8fafc;
color: #94a3b8;
font-size: 11px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.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;
}
.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;
@media (min-width: 640px) {
.home-hero__ask-kbd {
display: inline-flex;
}
.hero-chips {
.home-hero__ask-label {
display: inline;
}
.home-hero__title {
font-size: 1.25rem !important;
}
}
@media (max-width: 1023px) {
.home-hero__row {
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;
}
.hero-bot {
display: none;
}
.hero-ask kbd {
display: none;
.home-hero__greet {
max-width: none;
}
}
</style>
+78 -106
View File
@@ -1,125 +1,97 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
import {
CalendarOutlined,
CheckCircleOutlined,
FundOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@/icons/ant-bridge';
import type { Component } from 'vue';
import type { HomeDashboardKpiDto } from '@mento/shared';
import { currencySuffix } from '@/lib/money';
defineProps<{
const props = defineProps<{
kpis: HomeDashboardKpiDto[];
currency: string;
formatValue: (key: string, value: number) => string;
formatChange: (value: number | null) => string;
}>();
const icons: Record<string, 'calendar' | 'users' | 'briefcase' | 'check'> = {
sales: 'calendar',
customers: 'users',
'open-deals': 'briefcase',
followups: 'check',
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 iconTones: Record<string, string> = {
sales: '#14b8a6',
customers: '#38bdf8',
'open-deals': '#fb923c',
followups: '#a78bfa',
};
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';
}
</script>
<template>
<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>
<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>
<span
v-if="kpi.changePercent != null"
class="kpi-change"
:class="{ down: kpi.changePercent < 0 }"
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'"
>
<span class="arrow">{{ kpi.changePercent >= 0 ? '↑' : '↓' }}</span>
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
<component :is="icons[kpi.key] ?? FundOutlined" />
</span>
</div>
</article>
</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>
</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>
@@ -0,0 +1,83 @@
<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,8 +1,17 @@
<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<{
@@ -10,41 +19,100 @@ const props = defineProps<{
formatMoney: (amount: number, currency: string) => string;
}>();
type Card = { id: string; name: string; amount: string; when: string };
type Col = { id: string; name: string; count: number; cards: Card[] };
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'blue', 'orange'];
function relativeWhen(iso: string | null | undefined) {
if (!iso) return '';
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 };
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days <= 0) return 'امروز';
if (days === 1) return 'دیروز';
return `${toFaDigits(days)} روز پیش`;
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) : '؟';
}
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) => ({
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),
})),
}));
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,
};
}),
};
});
});
function accentOf(key: PipelineAccent) {
return PIPELINE_ACCENT[key];
}
</script>
<template>
<article class="panel">
<header class="panel-head">
<h2>جریان فروش</h2>
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink>
<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>
</header>
<HomePanelEmpty
@@ -54,132 +122,85 @@ const cols = computed<Col[]>(() => {
action-label="معامله جدید"
:action-to="{ name: 'deal-new' }"
/>
<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="card in col.cards"
:key="card.id"
class="pipe-card"
:to="{ name: 'deal-detail', params: { id: card.id } }"
<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"
>
<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>
{{ toFaDigits(col.count) }}
</span>
</div>
<div class="flex flex-col gap-2.5 flex-1">
<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"
>
<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>
</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>
</div>
</article>
</div>
</article>
</section>
</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,6 +1,7 @@
<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;
@@ -8,70 +9,19 @@ defineProps<{
actionLabel: string;
actionTo: RouteLocationRaw;
}>();
const { Text: ATypographyText } = Typography;
</script>
<template>
<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>
<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>
</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,7 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, type Component } from 'vue';
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
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 HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
import type { ActivityDto } from '@mento/shared';
@@ -10,16 +22,21 @@ 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 typeLabel: Record<string, string> = {
const typeLabelMap: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
@@ -31,23 +48,50 @@ 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) {
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;
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;
}
const rows = computed<Row[]>(() =>
props.activities.slice(0, 3).map((act) => {
props.activities.slice(0, 5).map((act) => {
const st = activityStatus(act);
return {
id: act.id,
title: act.title,
when: `${typeLabel[act.type] ?? act.type} · ${props.formatWhen(act.dueAt || act.createdAt)}`,
when: props.formatWhen(act.dueAt || act.createdAt),
typeLabel: typeLabelMap[act.type] ?? act.type,
type: act.type,
tone: st.tone,
label: st.label,
@@ -57,11 +101,16 @@ const rows = computed<Row[]>(() =>
</script>
<template>
<article class="panel">
<header class="panel-head">
<h2>فعالیت‌های اخیر</h2>
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink>
</header>
<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>
<HomePanelEmpty
v-if="!rows.length"
@@ -70,117 +119,44 @@ const rows = computed<Row[]>(() =>
action-label="فعالیت جدید"
:action-to="{ name: 'activity-new' }"
/>
<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>
<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>
</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,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { Segmented as ASegmented } from 'ant-design-vue';
import type { HomeDashboardTrendPointDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
@@ -10,26 +11,57 @@ const props = defineProps<{
formatAmount: (value: number) => string;
}>();
const trend = computed(() => props.trend);
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
const change = computed(() => props.changePercent);
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 option = computed<UiChartOption>(() => ({
animationDuration: 400,
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
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 = trend.value[row.dataIndex ?? 0];
const point = filteredTrend.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.monthLabel}<br/>${props.formatAmount(point.amount)}`;
return `${point.monthLabel}<br/><strong>${props.formatAmount(point.amount)}</strong>`;
},
},
xAxis: {
type: 'category',
data: trend.value.map((p) => p.monthLabel),
data: filteredTrend.value.map((p) => p.monthLabel),
boundaryGap: false,
axisLine: { show: false },
axisTick: { show: false },
@@ -43,18 +75,19 @@ const option = computed<UiChartOption>(() => ({
type: 'value',
min: 0,
...(hasTrend.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#eef2f7' } },
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false },
},
series: [
{
type: 'line',
data: trend.value.map((p) => p.amount),
smooth: 0.35,
name: 'فروش',
data: filteredTrend.value.map((p) => p.amount),
smooth: 0.4,
symbol: 'circle',
symbolSize: 8,
itemStyle: { color: '#14b8a6', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#14b8a6', width: 3 },
symbolSize: period.value === 1 ? 10 : 8,
itemStyle: { color: '#10b981', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#10b981', width: 3 },
areaStyle: {
color: {
type: 'linear',
@@ -63,8 +96,8 @@ const option = computed<UiChartOption>(() => ({
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.28)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0)' },
{ offset: 0, color: 'rgba(16, 185, 129, 0.28)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' },
],
},
},
@@ -74,56 +107,22 @@ const option = computed<UiChartOption>(() => ({
</script>
<template>
<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>
<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>
</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,21 +1,20 @@
<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, { type UiChartOption } from '@/components/ui/UiChart.vue';
import UiChart, { CHART_PALETTE, 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: colors,
color: CHART_PALETTE,
tooltip: {
show: total.value > 0,
trigger: 'item',
@@ -28,147 +27,51 @@ const option = computed<UiChartOption>(() => ({
series: [
{
type: 'pie',
radius: ['58%', '82%'],
radius: ['55%', '78%'],
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: '#e2e8f0' } }],
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
},
],
}));
</script>
<template>
<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>
<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>
</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>
</article>
</section>
</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,6 +1,15 @@
<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';
@@ -9,25 +18,61 @@ const props = defineProps<{
customers: HomeDashboardTopCustomerDto[];
}>();
const rows = computed(() => props.customers.slice(0, 3));
const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
function trophy(rank: number) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return `#${toFaDigits(rank)}`;
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);
}
</script>
<template>
<article class="panel">
<header class="panel-head">
<div>
<h2>مشتریان برتر</h2>
<p class="hint">بر اساس معاملات برندهٔ ۵ ماه اخیر</p>
<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>
</div>
<RouterLink :to="{ name: 'contacts' }">مشاهده همه</RouterLink>
</header>
<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>
<HomePanelEmpty
v-if="!rows.length"
@@ -36,99 +81,51 @@ function trophy(rank: number) {
action-label="مشتری جدید"
:action-to="{ name: 'person-new' }"
/>
<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>
<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>
</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,12 +1,30 @@
<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: 'این ماه' },
@@ -40,316 +58,342 @@ onMounted(() => {
});
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
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 = 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 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 rest = computed(() => {
const items = allItems.value;
return items.length > 3 ? items.slice(3) : [];
});
const meOutside = computed(() => {
const b = board.value;
if (!b?.me) return null;
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
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;
});
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>
<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-card title="جدول قهرمانان و رتبه‌بندی">
<a-flex vertical :gap="16">
<a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
<p v-if="error" class="err">{{ error }}</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<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>
<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' }" />
<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 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>
</template>
</span>
</li>
</template>
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
</ol>
</section>
</a-list>
<a-empty
v-if="board && !board.items.length"
description="هنوز کسی در این بازه امتیاز نگرفته است."
/>
</a-flex>
</a-spin>
</a-flex>
</a-card>
</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>
@@ -0,0 +1,56 @@
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 - 56px);
max-height: calc(100dvh - 16px - var(--control-h) - 28px);
overflow: hidden;
padding: 0.9rem 0.9rem 0.8rem;
display: grid;
align-content: start;
gap: 0.7rem;
border-radius: 28px;
border-radius: 16px;
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: 16px;
border-radius: 12px;
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: 999px;
border-radius: 10px;
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: 24px 24px 0 0;
border-radius: 16px 16px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
}
@@ -1,9 +1,21 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import { useRouter } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Flex as AFlex,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import {
CalendarOutlined,
PlusOutlined,
UserAddOutlined,
} from '@/icons/ant-bridge';
withDefaults(
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
title?: string;
description?: string;
@@ -20,15 +32,24 @@ 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>
<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">
<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' }">
<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"
@@ -52,147 +73,24 @@ withDefaults(
</svg>
</div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<a-typography-title :level="4" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">{{ description }}</a-typography-text>
<div class="actions">
<RouterLink class="btn-primary" :to="primaryTo">
<span class="plus">+</span>
<a-space direction="vertical" style="width: min(100%, 304px)">
<a-button type="primary" block @click="go(primaryTo)">
<template #icon><PlusOutlined /></template>
{{ primaryLabel }}
</RouterLink>
<RouterLink v-if="secondaryLabel && secondaryTo" class="btn-secondary" :to="secondaryTo">
<UiIcon :name="secondaryIcon" :size="18" />
</a-button>
<a-button
v-if="secondaryLabel && secondaryTo"
block
@click="go(secondaryTo)"
>
<template #icon>
<component :is="secondaryIconComp()" />
</template>
{{ secondaryLabel }}
</RouterLink>
</div>
</div>
</a-button>
</a-space>
</a-flex>
</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,8 +1,13 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Dropdown as ADropdown,
Menu as AMenu,
MenuItem as AMenuItem,
} from 'ant-design-vue';
import { MoreOutlined } from '@/icons/ant-bridge';
export type ProfileMoreItem = {
key: string;
@@ -15,207 +20,40 @@ const open = defineModel<boolean>({ default: false });
defineProps<{ items: ProfileMoreItem[] }>();
const emit = defineEmits<{ select: [key: string] }>();
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() {
function onSelect(key: string) {
emit('select', key);
open.value = false;
}
function scheduleClose() {
window.setTimeout(() => {
close();
}, 0);
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
close();
});
function onLinkClick() {
open.value = false;
}
</script>
<template>
<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
<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)"
>
<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>
<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>
</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 '@ant-design/icons-vue';
import { PlusOutlined } from '@/icons/ant-bridge';
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 '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
Avatar as AAvatar,
Button as AButton,
@@ -76,13 +76,11 @@ const priorityFlag = computed(() =>
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
opacity: props.dragging ? 0.45 : 1,
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
boxShadow: token.value.boxShadowTertiary,
}));
</script>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { PlusOutlined } from '@/icons/ant-bridge';
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 '@ant-design/icons-vue';
import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Dropdown as ADropdown,
@@ -13,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
@@ -51,8 +51,6 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const bodyEl = ref<HTMLElement | null>(null);
const insertAt = ref<number | null>(null);
const renaming = ref(false);
const draft = ref('');
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
@@ -108,49 +106,16 @@ const bodyStyle = computed(() => ({
margin: 0,
}));
function trackDrag(event: DragEvent, index: number | null) {
function onCardDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dragScrollColumn(bodyEl.value, event.clientY);
if (index != null) insertAt.value = index;
}
function clearDragUi() {
insertAt.value = null;
stopDragScroll();
}
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
event.stopPropagation();
trackDrag(event, pointerDropIndex(index, event));
emit('dragOver', stageId, event);
}
function onColumnDrop(event: DragEvent) {
const at = insertAt.value ?? undefined;
clearDragUi();
emit('drop', props.stage.id, event, at);
}
function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
const at = insertAt.value ?? pointerDropIndex(index, event);
clearDragUi();
emit('drop', stageId, event, at);
}
function slotStyle(active: boolean, collapseGap = false) {
return {
height: active ? '36px' : '0px',
marginTop: !active && collapseGap ? '-12px' : '0',
borderRadius: `${token.value.borderRadius}px`,
background: active ? token.value.colorPrimaryBg : 'transparent',
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
outlineOffset: '-1px',
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
overflow: 'hidden',
flexShrink: 0,
};
emit('drop', stageId, event, pointerDropIndex(index, event));
}
function startRename() {
@@ -209,8 +174,8 @@ function onMenuClick({ key }: { key: string | number }) {
<template>
<section
:style="columnStyle"
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
@drop.prevent="onColumnDrop"
@dragover.prevent="emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)"
>
<div :style="headStyle">
<span
@@ -295,18 +260,13 @@ function onMenuClick({ key }: { key: string | number }) {
</a-dropdown>
</div>
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
<div role="list" :aria-label="stage.title" :style="bodyStyle">
<div
v-for="(deal, index) in stage.deals"
:key="deal.id"
@dragover="onCardDragOver(stage.id, index, $event)"
@dragover="onCardDragOver(stage.id, $event)"
@drop="onCardDrop(stage.id, index, $event)"
>
<div
:style="slotStyle(dropActive && insertAt === index)"
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
/>
<DealCard
:deal="deal"
:accent="stage.color"
@@ -315,18 +275,13 @@ function onMenuClick({ key }: { key: string | number }) {
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="clearDragUi(); emit('dragEnd')"
@drag-end="emit('dragEnd')"
@open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
/>
</div>
<div
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
/>
<a-typography-text
v-if="!stage.deals.length"
type="secondary"
@@ -5,7 +5,7 @@ import {
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
Avatar as AAvatar,
Button as AButton,
@@ -9,8 +9,31 @@ import type {
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiField, UiSelect } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
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;
type Step = 'target' | 'map' | 'review';
@@ -31,10 +54,23 @@ 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;
@@ -71,6 +107,13 @@ 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 'باخت';
@@ -171,298 +214,181 @@ watch(targetId, () => {
</script>
<template>
<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-flex vertical :gap="16">
<a-steps :current="stepIndex">
<a-step title="مقصد" />
<a-step title="نگاشت" />
<a-step title="بازبینی" />
</a-steps>
<p v-if="busy && !preview" class="muted">در حال آماده‌سازی پیش‌نمایش…</p>
<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>
<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-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>
<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-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>
<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-alert
v-if="selectedTarget?.isDefault"
type="info"
show-icon
message="مقصد انتخاب‌شده کاریز پیش‌فرض است."
/>
</a-flex>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p>
</section>
<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>
<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-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>
<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-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>
<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-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>
<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-typography-text v-else-if="!busy && !error" type="secondary">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</a-typography-text>
</a-spin>
<p v-else-if="!busy && !error" class="muted">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<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-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"
>
ادامه
</UiButton>
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap">
</a-button>
<a-button
v-else-if="step === 'map'"
type="primary"
:disabled="!canContinueMap"
@click="nextFromMap"
>
بازبینی
</UiButton>
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge">
</a-button>
<a-button
v-else
type="primary"
danger
:loading="busy"
:disabled="!canContinueMap"
@click="submitMerge"
>
ادغام و حذف مبدا
</UiButton>
</div>
</div>
</a-button>
</a-space>
</a-flex>
</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 '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
Flex as AFlex,
InputNumber as AInputNumber,

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