14 Commits
Author SHA1 Message Date
Mahdi 0998a1ec37 fix(web): serve real dashboard data instead of mocks
Drop forced home/treasury/reports mock fallbacks so API and database content drive the UI.
2026-11-07 09:04:38 +03:30
Mahdi 4979aa8b75 feat(web): refresh Montact brand and sidebar chrome
Ship the new logo assets, set primary to #10897d, and polish sidebar/home-hero UX around roles, org switcher, and the ask bar.
2026-11-05 17:10:03 +03:30
MahdiandCursor 34d3689448 chore(web): keep the raw vuesax icon dump for regenerating the pack
Track icons (1).json so scripts/_setup-vuesax.mjs can rebuild apps/web/src/icons/vuesax-linear.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:34:34 +03:30
MahdiandCursor 1e0b638a19 fix(web): tidy the notification panel with status icons
Keep cards neutral, show type through icon and color only, and hide the panel scrollbar.

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

+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-*)` پاک‌سازی شده است؟
+264 -20
View File
@@ -1533,6 +1533,7 @@ export class ReportsService {
async home(actor: Actor): Promise<HomeDashboardDto> { async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals'); const dealScope = ownerWhere(actor, 'deals');
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const rates = await loadOrgRates(this.prisma, orgId); const rates = await loadOrgRates(this.prisma, orgId);
@@ -1543,6 +1544,7 @@ export class ReportsService {
const prev = addJalaliMonths(jy, jm, -1); const prev = addJalaliMonths(jy, jm, -1);
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm); const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm); const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
const trendWindowStart = addMonthsStart(now, -12);
const todayStart = startOfDay(now); const todayStart = startOfDay(now);
const todayEnd = endOfDay(now); const todayEnd = endOfDay(now);
@@ -1555,17 +1557,25 @@ export class ReportsService {
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff); const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
'issued',
'partially_paid',
'paid',
];
const [ const [
wonDeals, wonDeals,
openDeals, openDealRows,
lostDeals,
staleDeals, staleDeals,
peopleCur, peopleRecent,
peoplePrev,
openActivitiesToday, openActivitiesToday,
openActivitiesYesterday, openActivitiesYesterday,
openDealsWeekAgo, openDealsWeekAgo,
goals, goals,
overdueCount, overdueCount,
invoices,
activityTypeGroups,
] = await Promise.all([ ] = await Promise.all([
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
@@ -1573,7 +1583,7 @@ export class ReportsService {
{ {
organizationId: orgId, organizationId: orgId,
status: 'won', status: 'won',
updatedAt: { gte: addMonthsStart(now, -5) }, updatedAt: { gte: trendWindowStart },
}, },
dealScope, dealScope,
dealsPrior, dealsPrior,
@@ -1590,7 +1600,7 @@ export class ReportsService {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
}, },
}), }),
this.prisma.deal.count({ this.prisma.deal.findMany({
where: { where: {
AND: [ AND: [
{ organizationId: orgId, status: 'open' }, { organizationId: orgId, status: 'open' },
@@ -1598,6 +1608,30 @@ export class ReportsService {
dealsPrior, 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({ this.prisma.deal.findMany({
where: { where: {
@@ -1624,19 +1658,13 @@ export class ReportsService {
orderBy: { updatedAt: 'asc' }, orderBy: { updatedAt: 'asc' },
take: 5, take: 5,
}), }),
this.prisma.person.count({ this.prisma.person.findMany({
where: { where: {
organizationId: orgId, organizationId: orgId,
...personScope, ...personScope,
createdAt: { gte: curMonthStart, lte: curMonthEnd }, createdAt: { gte: trendWindowStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
}, },
select: { createdAt: true, importSource: true },
}), }),
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
@@ -1656,6 +1684,7 @@ export class ReportsService {
AND: [ AND: [
{ {
organizationId: orgId, organizationId: orgId,
status: 'open',
dueAt: { gte: yesterdayStart, lte: yesterdayEnd }, dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
}, },
activityScope, 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) => const sumWonInRange = (start: Date, end: Date) =>
wonDeals wonDeals
.filter((d) => d.updatedAt >= start && d.updatedAt <= end) .filter((d) => d.updatedAt >= start && d.updatedAt <= end)
@@ -1707,8 +1795,21 @@ export class ReportsService {
0, 0,
); );
const salesCur = sumWonInRange(curMonthStart, curMonthEnd); const salesInRange = (start: Date, end: Date) => {
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd); 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( const targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount), (acc, g) => acc + Number(g.targetAmount),
0, 0,
@@ -1716,7 +1817,7 @@ export class ReportsService {
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> = 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); const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({ trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`, key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
@@ -1729,14 +1830,28 @@ export class ReportsService {
const salesTrend = trendMonths.map((m) => ({ const salesTrend = trendMonths.map((m) => ({
monthKey: m.key, monthKey: m.key,
monthLabel: m.label, 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>(); const sourceMap = new Map<string, number>();
for (const d of wonDeals) { for (const p of peopleRecent) {
const label = normalizeSource(d.source); const label = normalizeImportSource(p.importSource);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1); 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 sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources = [...sourceMap.entries()] const sources = [...sourceMap.entries()]
.map(([label, count]) => ({ .map(([label, count]) => ({
@@ -1780,6 +1895,101 @@ export class ReportsService {
.slice(0, 5) .slice(0, 5)
.map((row, index) => ({ ...row, rank: index + 1 })); .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'] = []; const alerts: HomeDashboardDto['alerts'] = [];
if (staleDeals.length) { if (staleDeals.length) {
const first = staleDeals[0]!; const first = staleDeals[0]!;
@@ -1857,11 +2067,30 @@ export class ReportsService {
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday), changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
changeLabel: 'نسبت به دیروز', changeLabel: 'نسبت به دیروز',
}, },
{
key: 'win-rate',
label: 'نرخ برد',
value: winRate,
changePercent: null,
changeLabel: '۱۲ ماه اخیر',
},
{
key: 'overdue',
label: 'عقب‌افتاده',
value: overdueCount,
changePercent: null,
changeLabel: 'فعالیت باز',
},
], ],
alerts, alerts,
salesTrend, salesTrend,
customerGrowth,
sources, sources,
topCustomers, topCustomers,
salesSummary,
funnel,
lostReasons,
activityByType,
}; };
} }
} }
@@ -1919,6 +2148,21 @@ function normalizeSource(raw: string | null | undefined): string {
return map[value.toLowerCase()] ?? value; 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 { function normalizeLostReason(raw: string | null | undefined): string {
const value = raw?.trim(); const value = raw?.trim();
if (!value) return 'بدون دلیل ثبت‌شده'; if (!value) return 'بدون دلیل ثبت‌شده';
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>منتکت | Montact CRM</title> <title>منتکت | Montact CRM</title>
<link rel="icon" type="image/png" href="/brand/logo.png" /> <link rel="icon" type="image/svg+xml" href="/brand/logo.svg" />
<style> <style>
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */ /* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */
.app-frame { .app-frame {
+2
View File
@@ -22,7 +22,9 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.2.2", "vite": "^6.2.2",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.2.8"
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

+51 -113
View File
@@ -4,8 +4,20 @@ import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali'; import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared'; 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<{ const props = defineProps<{
personId?: string; personId?: string;
@@ -65,127 +77,53 @@ function insertAnswer() {
</script> </script>
<template> <template>
<section class="ask"> <a-flex vertical :gap="8" :style="{ minWidth: 0 }">
<form @submit.prevent="submit"> <a-flex :gap="8" align="center" :style="{ width: '100%' }">
<UiIcon name="sparkle" :size="16" class="ico" /> <a-input
<input v-model:value="question"
v-model="question" allow-clear
type="search"
:placeholder="placeholder || brand.askPlaceholderFa" :placeholder="placeholder || brand.askPlaceholderFa"
/> :style="{ flex: 1, minWidth: 0 }"
<button type="submit" :disabled="asking || question.trim().length < 2"> @pressEnter="submit"
{{ asking ? '…' : 'بپرس' }} >
</button> <template #prefix>
</form> <ThunderboltOutlined />
<p v-if="error" class="error">{{ error }}</p> </template>
<div v-else-if="result" class="answer"> </a-input>
<p>{{ result.answer }}</p> <a-button
<div v-if="result.sources.length" class="sources"> 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 <RouterLink
v-for="source in result.sources" v-for="source in result.sources"
:key="`${source.type}-${source.id}`" :key="`${source.type}-${source.id}`"
:to="sourceTo(source)" :to="sourceTo(source)"
> >
{{ source.label }} <a-tag color="cyan" :bordered="false">{{ source.label }}</a-tag>
</RouterLink> </RouterLink>
</div> </a-space>
<button <a-button
v-if="allowInsert && result.answer.trim()" v-if="allowInsert && result.answer.trim()"
type="button" type="primary"
class="insert-btn" block
:style="{ marginTop: '8px' }"
@click="insertAnswer" @click="insertAnswer"
> >
درج در پیام درج در پیام
</button> </a-button>
</div> </a-card>
</section> </a-flex>
</template> </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, InputNumber as AInputNumber,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Spin as ASpin,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import type { import type {
CustomFieldDefinitionDto, CustomFieldDefinitionDto,
CustomFieldEntity, CustomFieldEntity,
@@ -73,61 +73,63 @@ onMounted(load);
</script> </script>
<template> <template>
<a-spin v-if="loading" /> <CrmSkeleton :loading="loading">
<template v-else-if="fields.length"> <CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
<a-typography-title :level="5" :style="{ marginTop: '16px' }"> <template v-else-if="fields.length">
فیلدهای سفارشی <a-typography-title :level="5" :style="{ marginTop: '16px' }">
</a-typography-title> فیلدهای سفارشی
<a-row :gutter="[16, 0]"> </a-typography-title>
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12"> <a-row :gutter="[16, 0]">
<a-form-item :label="field.label" :required="field.required"> <a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
<a-select <a-form-item :label="field.label" :required="field.required">
v-if="field.fieldType === 'select'" <a-select
:id="`cf-${field.key}`" v-if="field.fieldType === 'select'"
allow-clear :id="`cf-${field.key}`"
:value="String(displayValue(field.key)) || undefined" allow-clear
:options="selectOptions(field)" :value="String(displayValue(field.key)) || undefined"
placeholder="انتخاب کنید" :options="selectOptions(field)"
style="width: 100%" placeholder="انتخاب کنید"
@update:value="setValue(field.key, $event ?? '')" style="width: 100%"
/> @update:value="setValue(field.key, $event ?? '')"
/>
<a-checkbox <a-checkbox
v-else-if="field.fieldType === 'boolean'" v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:checked="Boolean(props.modelValue[field.key])" :checked="Boolean(props.modelValue[field.key])"
@update:checked="setValue(field.key, $event)" @update:checked="setValue(field.key, $event)"
> >
فعال فعال
</a-checkbox> </a-checkbox>
<a-input-number <a-input-number
v-else-if="field.fieldType === 'number'" v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:value=" :value="
props.modelValue[field.key] != null props.modelValue[field.key] != null
? Number(props.modelValue[field.key]) ? Number(props.modelValue[field.key])
: undefined : undefined
" "
style="width: 100%" style="width: 100%"
@update:value="setValue(field.key, $event == null ? null : Number($event))" @update:value="setValue(field.key, $event == null ? null : Number($event))"
/> />
<JalaliDateInput <JalaliDateInput
v-else-if="field.fieldType === 'date'" v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))" :model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)" @update:model-value="setValue(field.key, $event || null)"
/> />
<a-input <a-input
v-else v-else
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:value="String(displayValue(field.key))" :value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)" @update:value="setValue(field.key, $event)"
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
</template> </template>
</CrmSkeleton>
</template> </template>
+68 -104
View File
@@ -1,6 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; 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 { import type {
DataAccessOwnerKind, DataAccessOwnerKind,
DataAccessRule, DataAccessRule,
@@ -15,9 +26,12 @@ import {
PERSON_MOODS, PERSON_MOODS,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
export type IdOption = { id: string; name: string }; export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string }; type Choice = { value: string; label: string };
const { Text: ATypographyText } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: SectionDataAccess; modelValue: SectionDataAccess;
@@ -429,53 +443,59 @@ const filterSummary = computed(() => {
</script> </script>
<template> <template>
<div class="dae" :class="{ compact }"> <a-flex vertical :gap="compact ? 8 : 12">
<div class="dae-mode"> <a-radio-group v-model:value="mode" :disabled="disabled">
<label> <a-flex vertical :gap="4">
<input v-model="mode" type="radio" value="all" :disabled="disabled" /> <a-radio value="all">همهٔ {{ sectionLabel }}</a-radio>
همهٔ {{ sectionLabel }} <a-radio value="rules">محدود با فیلتر</a-radio>
</label> </a-flex>
<label> </a-radio-group>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'"> <template v-if="mode === 'rules'">
<p class="hint"> <a-typography-text type="secondary">
فقط رکوردهایی دیده می‌شوند که با فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong> <strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت. جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p> </a-typography-text>
<p v-if="hadMultipleRules" class="hint warn"> <a-alert
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود. v-if="hadMultipleRules"
</p> type="warning"
<p class="summary">{{ filterSummary }}</p> show-icon
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود."
/>
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
<fieldset :disabled="disabled"> <a-card size="small" title="مالک / مسئول" :bordered="true">
<legend>مالک / مسئول</legend> <a-flex vertical :gap="8">
<p class="muted">خالی = بدون محدودیت مالک</p> <a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text>
<label class="chip"> <a-space wrap :size="8">
<input <a-checkbox
type="checkbox" :checked="filter.owners?.includes('self')"
:checked="filter.owners?.includes('self')" :disabled="disabled"
@change="toggleOwner('self')" @change="toggleOwner('self')"
/> >
{{ ownerSelfLabel }} {{ ownerSelfLabel }}
</label> </a-checkbox>
<label class="chip"> <a-checkbox
<input :checked="filter.owners?.includes('team')"
type="checkbox" :disabled="disabled"
:checked="filter.owners?.includes('team')" @change="toggleOwner('team')"
@change="toggleOwner('team')" >
/> تیم‌های عضو
تیم‌های عضو </a-checkbox>
</label> </a-space>
</fieldset> </a-flex>
</a-card>
<label v-for="picker in pickers" :key="picker.legend" class="pick"> <a-form-item
<span>{{ picker.legend }}</span> v-for="picker in pickers"
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p> :key="picker.legend"
:label="picker.legend"
:style="{ marginBottom: 0 }"
>
<a-typography-text v-if="picker.loading" type="secondary">
در حال بارگذاری منبع‌ها…
</a-typography-text>
<a-select <a-select
v-else v-else
:value="pickerValue(picker)" :value="pickerValue(picker)"
@@ -490,70 +510,14 @@ const filterSummary = computed(() => {
style="width: 100%" style="width: 100%"
@update:value="setList(picker.key, $event)" @update:value="setList(picker.key, $event)"
/> />
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p> <a-typography-text v-if="picker.hint" type="secondary">
</label> {{ picker.hint }}
</a-typography-text>
</a-form-item>
</template> </template>
<p v-else-if="!hasLimits" class="hint"> <a-typography-text v-else-if="!hasLimits" type="secondary">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند. بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p> </a-typography-text>
</div> </a-flex>
</template> </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>
+102 -176
View File
@@ -1,5 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; 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 { import type {
DataAccessPolicy, DataAccessPolicy,
DataAccessSection, DataAccessSection,
@@ -19,6 +29,8 @@ import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups'; import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue'; import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ const props = defineProps<{
modelValue: DataAccessPolicy; modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */ /** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
@@ -115,6 +127,10 @@ function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next); emit('update:treasuryAccess', next);
} }
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
}
function toggleTreasurySection(key: RolePermissionKey) { function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess(); const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key) const sections = current.sections.includes(key)
@@ -229,195 +245,105 @@ onMounted(() => {
void loadOptions(); void loadOptions();
void loadTreasuryAccounts(); 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> </script>
<template> <template>
<div class="dap"> <div>
<p v-if="!visibleSections.length" class="muted"> <a-typography-text v-if="!visibleSections.length" type="secondary">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید. برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</p> </a-typography-text>
<template v-else> <a-tabs v-else v-model:activeKey="active">
<div class="tabs" role="tablist"> <a-tab-pane
<button v-for="s in visibleSections"
v-for="s in visibleSections" :key="s"
:key="s" :tab="DATA_ACCESS_SECTION_LABELS[s]"
type="button" >
class="tab" <DataAccessEditor
:class="{ on: active === s }" :section="s"
role="tab" :model-value="sectionValue(s)"
:aria-selected="active === s" :groups="customerGroups"
@click="active = s" :stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
> :pipelines="pipelines"
{{ DATA_ACCESS_SECTION_LABELS[s] }} :boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
</button> :activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
<button :import-sources="importSources"
v-if="showTreasury" :import-sources-ready="importSourcesReady"
type="button" :import-sources-failed="importSourcesFailed"
class="tab" :disabled="disabled || dataLocked"
:class="{ on: active === 'treasury' }" :compact="compact"
role="tab" @update:model-value="setSection(s, $event)"
:aria-selected="active === 'treasury'" />
@click="active = 'treasury'" </a-tab-pane>
> <a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
مدیریت مالی <a-space direction="vertical">
</button> <a-typography-text type="secondary">
</div> این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
<div v-if="active === 'treasury'" class="treasury"> در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
<p class="muted"> اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند. </a-typography-text>
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست. <a-radio-group
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است. :value="treasuryAccess == null ? 'role' : 'custom'"
</p>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess == null"
:disabled="disabled" :disabled="disabled"
@change="patchTreasury(null)" @change="onTreasuryModeChange"
/> >
مثل نقش <a-space direction="vertical">
</label> <a-radio value="role">مثل نقش</a-radio>
<label class="choice"> <a-radio value="custom">سفارشی برای این کاربر</a-radio>
<input </a-space>
type="radio" </a-radio-group>
name="user-treasury" <template v-if="treasuryAccess">
:checked="treasuryAccess != null" <a-checkbox
:disabled="disabled"
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
/>
سفارشی برای این کاربر
</label>
<template v-if="treasuryAccess">
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.enabled" :checked="treasuryAccess.enabled"
:disabled="disabled" :disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })" @change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
/> >
دسترسی به مدیریت مالی دسترسی به مدیریت مالی
</label> </a-checkbox>
<template v-if="treasuryAccess.enabled"> <template v-if="treasuryAccess.enabled">
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub"> <a-space direction="vertical">
<input <a-checkbox
type="checkbox" v-for="perm in treasurySections"
:checked="treasuryAccess.sections.includes(perm.key)" :key="perm.key"
:disabled="disabled" :checked="treasuryAccess.sections.includes(perm.key)"
@change="toggleTreasurySection(perm.key)" :disabled="disabled"
/> @change="toggleTreasurySection(perm.key)"
{{ perm.label }} >
</label> {{ perm.label }}
<label class="choice"> </a-checkbox>
<input </a-space>
type="checkbox" <a-checkbox
:checked="treasuryAccess.ownAccounts" :checked="treasuryAccess.ownAccounts"
:disabled="disabled" :disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })" @change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
/> >
حساب‌هایی که خودش مسئول آن‌هاست حساب‌هایی که خودش مسئول آن‌هاست
</label> </a-checkbox>
<p class="muted">حساب‌های دیگر</p> <a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" /> <a-input
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice"> v-model:value="treasuryAccountQuery"
<input placeholder="جستجوی حساب"
type="checkbox"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled" :disabled="disabled"
@change="toggleTreasuryAccount(account.id)" allow-clear
/> />
{{ account.name }} <a-space direction="vertical">
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template> <a-checkbox
</label> v-for="account in filteredTreasuryAccounts"
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p> :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>
</template> </a-space>
</div> </a-tab-pane>
<DataAccessEditor </a-tabs>
v-else
:key="active"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(editorSection, $event)"
/>
</template>
</div> </div>
</template> </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>
+5 -3
View File
@@ -10,11 +10,11 @@ import {
Input as AInput, Input as AInput,
List as AList, List as AList,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Tag as ATag, Tag as ATag,
Textarea as ATextarea, Textarea as ATextarea,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
const ASpaceCompact = ASpace.Compact; const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item; const AListItem = AList.Item;
@@ -259,7 +259,8 @@ onMounted(load);
:style="{ marginTop: '12px' }" :style="{ marginTop: '12px' }"
/> />
<a-spin :spinning="loading"> <CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="5">
<a-list <a-list
v-if="items.length" v-if="items.length"
item-layout="horizontal" item-layout="horizontal"
@@ -304,6 +305,7 @@ onMounted(load);
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید." description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }" :style="{ marginTop: '16px' }"
/> />
</a-spin> </CrmSkeletonList>
</CrmSkeleton>
</a-card> </a-card>
</template> </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, Space as ASpace,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { PrinterOutlined } from '@ant-design/icons-vue'; import { PrinterOutlined } from '@/icons/ant-bridge';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money'; import { formatMoney, currencySuffix } from '@/lib/money';
+60 -109
View File
@@ -6,7 +6,7 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
LeftOutlined, LeftOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
addJalaliMonths, addJalaliMonths,
dateToJalali, dateToJalali,
@@ -52,8 +52,6 @@ const hourColRef = ref<HTMLElement | null>(null);
const minuteColRef = ref<HTMLElement | null>(null); const minuteColRef = ref<HTMLElement | null>(null);
const top = ref(0); const top = ref(0);
const left = ref(0); const left = ref(0);
/** بالای مودال Ant (حدود ۱۰۰۰) تا تقویم پشت پنجره نماند */
const popupZ = ref(120);
const todayJ = dateToJalali(new Date()); const todayJ = dateToJalali(new Date());
const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm }); const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm });
const draftHour = ref(9); const draftHour = ref(9);
@@ -320,46 +318,21 @@ function place() {
const panel = panelRef.value; const panel = panelRef.value;
if (!trigger) return; if (!trigger) return;
const rect = trigger.getBoundingClientRect(); const rect = trigger.getBoundingClientRect();
const width = const width = panel?.offsetWidth || (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 320);
panel?.offsetWidth || const height = panel?.offsetHeight || (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 360);
(props.mode === 'time' ? (isMobile.value ? 340 : 280) : 328);
const height =
panel?.offsetHeight ||
(props.mode === 'time' ? (isMobile.value ? 460 : 320) : 340);
const margin = 8;
let nextLeft = rect.left; let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') { if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width; nextLeft = rect.right - width;
} }
nextLeft = Math.min( nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
Math.max(margin, nextLeft), let nextTop = rect.bottom + 8;
Math.max(margin, window.innerWidth - width - margin), if (nextTop + height > window.innerHeight - 8) {
); nextTop = Math.max(8, rect.top - height - 8);
const spaceBelow = window.innerHeight - rect.bottom - margin * 2;
const spaceAbove = rect.top - margin * 2;
let nextTop: number;
if (height <= spaceBelow) {
nextTop = rect.bottom + margin;
} else if (height <= spaceAbove) {
nextTop = rect.top - height - margin;
} else if (spaceBelow >= spaceAbove) {
nextTop = Math.max(margin, window.innerHeight - height - margin);
} else {
nextTop = margin;
} }
left.value = nextLeft; left.value = nextLeft;
top.value = nextTop; 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() { function onWindowChange() {
syncViewport(); syncViewport();
if (open.value) place(); if (open.value) place();
@@ -371,7 +344,6 @@ async function show() {
calView.value = 'days'; calView.value = 'days';
clockFace.value = 'hour'; clockFace.value = 'hour';
syncViewport(); syncViewport();
liftAboveOverlays();
open.value = true; open.value = true;
await nextTick(); await nextTick();
place(); place();
@@ -566,7 +538,7 @@ onBeforeUnmount(() => {
'is-desktop': !isMobile, 'is-desktop': !isMobile,
}" }"
role="dialog" 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"> <header v-if="mode !== 'time'" class="nav">
<a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)"> <a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)">
@@ -754,7 +726,7 @@ onBeforeUnmount(() => {
> >
پاک کردن پاک کردن
</a-button> </a-button>
<a-button type="primary" @click="hide">تأیید</a-button> <a-button type="primary" class="done" @click="hide">تأیید</a-button>
</footer> </footer>
</div> </div>
</Teleport> </Teleport>
@@ -879,11 +851,10 @@ onBeforeUnmount(() => {
.panel { .panel {
position: fixed; position: fixed;
z-index: 120; z-index: 120;
box-sizing: border-box;
width: min(20.5rem, calc(100vw - 16px)); width: min(20.5rem, calc(100vw - 16px));
/* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */ max-height: calc(100vh - 16px);
max-height: none; overflow-x: hidden;
overflow: hidden; overflow-y: auto;
background: #fff; background: #fff;
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18); box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
@@ -891,13 +862,6 @@ onBeforeUnmount(() => {
font-family: IRANSans, Tahoma, sans-serif; font-family: IRANSans, Tahoma, sans-serif;
} }
@media (max-height: 420px) {
.panel {
max-height: calc(100vh - 16px);
overflow: auto;
}
}
.panel-time { .panel-time {
width: min(22.5rem, calc(100vw - 16px)); width: min(22.5rem, calc(100vw - 16px));
} }
@@ -929,40 +893,45 @@ onBeforeUnmount(() => {
.pick-grid { .pick-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem; gap: 0.35rem;
padding: 0.25rem 0; padding: 0.25rem 0;
} }
.panel :deep(.pick-cell.ant-btn) { .panel :deep(.ant-btn.pick-cell) {
width: 100% !important; width: 100%;
min-width: 0 !important; min-width: 0;
height: 44px !important; height: 44px;
padding-inline: 0 !important; margin: 0;
padding: 0 4px;
border-radius: var(--radius); border-radius: var(--radius);
color: #0f172a; color: #0f172a;
font-size: 0.85rem; font-size: 0.85rem;
} }
.panel :deep(.pick-cell.today) { .panel :deep(.ant-btn.pick-cell.today) {
box-shadow: inset 0 0 0 1px #99f6e4; box-shadow: inset 0 0 0 1px #99f6e4;
} }
.panel :deep(.pick-cell.on), .panel :deep(.ant-btn.pick-cell.on),
.panel :deep(.pick-cell.ant-btn:hover) { .panel :deep(.ant-btn.pick-cell:hover) {
background: #0d9488; background: #0d9488;
color: #fff; color: #fff;
} }
.nav-btn { .panel :deep(.ant-btn.nav-btn) {
width: 32px; width: 32px;
min-width: 32px;
height: 32px; height: 32px;
margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: #f1f5f9; background: #f1f5f9;
color: #0f766e; color: #0f766e;
display: grid; display: inline-flex;
place-items: center; align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
} }
@@ -975,7 +944,6 @@ onBeforeUnmount(() => {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.15rem; gap: 0.15rem;
width: 100%;
min-width: 0; min-width: 0;
} }
@@ -986,43 +954,40 @@ onBeforeUnmount(() => {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.panel :deep(.day.ant-btn) { .panel :deep(.ant-btn.day) {
width: 100% !important; display: inline-flex;
min-width: 0 !important; align-items: center;
height: 34px !important; justify-content: center;
padding: 0 !important; width: 100%;
min-width: 0;
height: 34px;
margin: 0;
padding: 0;
line-height: 1;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: none; background: none;
color: #0f172a; color: #0f172a;
font: inherit; font: inherit;
font-size: 0.82rem;
line-height: 1;
cursor: pointer; cursor: pointer;
box-shadow: none;
} }
.panel :deep(.day.ant-btn > span) { .panel :deep(.ant-btn.day.mute) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.panel :deep(.day.mute) {
color: #cbd5e1; color: #cbd5e1;
} }
.panel :deep(.day.today) { .panel :deep(.ant-btn.day.today) {
box-shadow: inset 0 0 0 1px #99f6e4; box-shadow: inset 0 0 0 1px #99f6e4;
} }
.panel :deep(.day.on), .panel :deep(.ant-btn.day.on),
.panel :deep(.day.ant-btn:hover) { .panel :deep(.ant-btn.day:hover) {
background: #0d9488; background: #0d9488;
color: #fff; color: #fff;
} }
.panel :deep(.day.mute:hover) { .panel :deep(.ant-btn.day.mute:hover) {
background: #f1f5f9; background: #f1f5f9;
color: #64748b; color: #64748b;
} }
@@ -1052,9 +1017,11 @@ onBeforeUnmount(() => {
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.md-chip { .panel :deep(.ant-btn.md-chip) {
min-width: 5.1rem; min-width: 5.1rem;
height: 4.1rem; height: 4.1rem;
margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: #f1f5f9; background: #f1f5f9;
@@ -1067,7 +1034,7 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
} }
.md-chip.on { .panel :deep(.ant-btn.md-chip.on) {
background: #ccfbf1; background: #ccfbf1;
color: #0f766e; color: #0f766e;
box-shadow: inset 0 0 0 2px #99f6e4; box-shadow: inset 0 0 0 2px #99f6e4;
@@ -1141,10 +1108,11 @@ onBeforeUnmount(() => {
pointer-events: none; pointer-events: none;
} }
.md-tick { .panel :deep(.ant-btn.md-tick) {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
width: 36px; width: 36px;
min-width: 36px;
height: 36px; height: 36px;
margin: 0; margin: 0;
border: 0; border: 0;
@@ -1161,12 +1129,12 @@ onBeforeUnmount(() => {
padding: 0; padding: 0;
} }
.md-tick.inner { .panel :deep(.ant-btn.md-tick.inner) {
font-size: 0.74rem; font-size: 0.74rem;
color: #64748b; color: #64748b;
} }
.md-tick.on { .panel :deep(.ant-btn.md-tick.on) {
background: transparent; background: transparent;
color: #fff; color: #fff;
} }
@@ -1215,9 +1183,12 @@ onBeforeUnmount(() => {
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.col-item { .panel :deep(.ant-btn.col-item) {
width: 100%; width: 100%;
min-width: 0;
height: 36px; height: 36px;
margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: transparent; background: transparent;
@@ -1228,11 +1199,11 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
} }
.col-item:hover { .panel :deep(.ant-btn.col-item:hover) {
background: #e2e8f0; background: #e2e8f0;
} }
.col-item.on { .panel :deep(.ant-btn.col-item.on) {
background: #0d9488; background: #0d9488;
color: #fff; color: #fff;
} }
@@ -1244,28 +1215,8 @@ onBeforeUnmount(() => {
margin-top: 0.7rem; margin-top: 0.7rem;
} }
.link, .panel :deep(.ant-btn.done) {
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
margin-inline-start: auto; 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 { .panel.is-mobile .time-box {
+5 -3
View File
@@ -9,10 +9,10 @@ import {
List as AList, List as AList,
Modal, Modal,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Textarea as ATextarea, Textarea as ATextarea,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali'; 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-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 v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده. هنوز موردی در حافظه ثبت نشده.
</a-typography-text> </a-typography-text>
@@ -201,6 +202,7 @@ onMounted(load);
</a-list-item> </a-list-item>
</template> </template>
</a-list> </a-list>
</a-spin> </CrmSkeletonList>
</CrmSkeleton>
</a-card> </a-card>
</template> </template>
+95 -166
View File
@@ -5,6 +5,20 @@ import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared'; import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali'; 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<{ const props = defineProps<{
noteId: string; noteId: string;
@@ -148,193 +162,108 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
</script> </script>
<template> <template>
<section class="ai-panel"> <a-card :bordered="true" :style="{ marginTop: '12px' }">
<header> <a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<strong>پیشنهاد {{ brand.aiName }}</strong> <a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text>
<button <a-button
v-if="!isPending" v-if="!isPending"
type="button" type="link"
class="link"
:disabled="loading" :disabled="loading"
@click="analyze" @click="analyze"
> >
{{ loading ? '…' : 'تحلیل مجدد' }} {{ loading ? '…' : 'تحلیل مجدد' }}
</button> </a-button>
<button <a-button
v-else-if="!suggestion" v-else-if="!suggestion"
type="button" type="link"
class="link"
:disabled="loading" :disabled="loading"
@click="analyze" @click="analyze"
> >
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }} {{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</button> </a-button>
</header> </a-flex>
<p v-if="error" class="error">{{ error }}</p> <a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" />
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p> <a-typography-text v-else-if="loading && !suggestion" type="secondary">
<p v-else-if="suggestion?.status === 'failed'" class="error"> در حال تحلیل یادداشت…
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }} </a-typography-text>
<button type="button" class="link" @click="analyze">تلاش دوباره</button> <a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4">
</p> <a-alert
<p v-else-if="!suggestion" class="muted"> type="error"
<button type="button" class="link" :disabled="loading" @click="analyze"> show-icon
تحلیل این یادداشت با AI :message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
</button> />
</p> <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"> <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"> <a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }">
<label class="check"> <a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox>
<input v-model="applyNextAction" type="checkbox" /> <a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" />
کار بعدی <JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" />
</label> </a-flex>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div>
<div v-if="memoryItems.length" class="block"> <a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }">
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p> <a-typography-title :level="5" :style="{ margin: 0 }">
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p> حافظه مشتری (با تأیید شما ذخیره می‌شود)
<label </a-typography-title>
<a-alert
v-if="!hasPerson"
type="warning"
show-icon
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
/>
<a-flex
v-for="(_, index) in memoryItems" v-for="(_, index) in memoryItems"
:key="index" :key="index"
class="memory-row" :gap="8"
align="flex-start"
> >
<input <a-checkbox
v-model="selectedMemory[index]" v-model:checked="selectedMemory[index]"
type="checkbox"
:disabled="!hasPerson" :disabled="!hasPerson"
/> />
<textarea v-model="memoryEdits[index]" rows="2" /> <a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
</label> </a-flex>
</div> </a-flex>
<div class="actions"> <a-space :size="8" wrap :style="{ marginTop: '12px' }">
<button type="button" class="btn" :disabled="applying" @click="apply"> <a-button type="primary" :disabled="applying" :loading="applying" @click="apply">
{{ applying ? '…' : 'اعمال انتخاب‌شده' }} اعمال انتخاب‌شده
</button> </a-button>
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss"> <a-button :disabled="applying" @click="dismiss">رد کردن</a-button>
رد کردن </a-space>
</button>
</div>
</template> </template>
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p> <a-typography-text
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p> v-else-if="suggestion?.status === 'applied'"
</section> 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> </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>
+296 -139
View File
@@ -3,35 +3,59 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles'; import { USER_ROLE_LABELS } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; import type { OrganizationMembershipDto } from '@mento/shared';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
defineProps<{ const props = withDefaults(
compact?: boolean; defineProps<{
compact?: boolean;
variant?: 'default' | 'workspace';
}>(),
{
compact: false,
variant: 'default',
},
);
const emit = defineEmits<{
open: [];
close: [];
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]); const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false); const open = ref(false);
const createOpen = ref(false);
const loading = ref(false); const loading = ref(false);
const creating = ref(false);
const error = ref(''); const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null); const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null); const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({}); const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0; let ignoreDocClickUntil = 0;
const currentName = computed( 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 canSwitch = computed(() => orgs.value.length > 1);
const isWorkspace = computed(() => props.variant === 'workspace');
/** در حالت workspace منو همیشه باز می‌شود؛ در حالت پیش‌فرض فقط وقتی بیش از یکی باشد */
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
function apiError(e: unknown, fallback: string) { function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
@@ -49,34 +73,62 @@ async function loadOrgs() {
} }
} }
function closeMenu() {
if (!open.value) return;
open.value = false;
emit('close');
}
async function toggle() { async function toggle() {
if (!canSwitch.value) return; if (!menuEnabled.value) return;
error.value = ''; error.value = '';
if (open.value) { if (open.value) {
open.value = false; closeMenu();
return; return;
} }
ignoreDocClickUntil = Date.now() + 400; ignoreDocClickUntil = Date.now() + 400;
open.value = true; open.value = true;
emit('open');
await nextTick(); await nextTick();
placeMenu(); placeMenu();
await loadOrgs(); await loadOrgs();
await nextTick();
placeMenu();
} }
function placeMenu() { function placeMenu() {
const btn = rootEl.value?.querySelector('button'); const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return; if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect(); 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 = { menuStyle.value = {
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`, ...beside,
right: `${Math.round(window.innerWidth - box.right)}px`, top: 'auto',
minWidth: `${Math.round(Math.max(box.width, 200))}px`, bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
}; };
} }
async function choose(org: OrganizationMembershipDto) { async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) { if (org.id === auth.user?.organizationId) {
open.value = false; closeMenu();
return; return;
} }
loading.value = true; loading.value = true;
@@ -90,42 +142,16 @@ async function choose(org: OrganizationMembershipDto) {
} }
} }
function openCreate() {
open.value = false;
newName.value = '';
newField.value = 'personal';
error.value = '';
createOpen.value = true;
}
async function submitCreate() {
const name = newName.value.trim();
if (name.length < 2) {
error.value = 'نام محیط باید حداقل دو حرف باشد.';
return;
}
creating.value = true;
error.value = '';
try {
await auth.createOrganization({
name,
businessField: newField.value,
});
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
creating.value = false;
}
}
function onDocClick(event: MouseEvent) { function onDocClick(event: MouseEvent) {
if (!open.value || Date.now() < ignoreDocClickUntil) return; if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target; const target = event.target;
if (!(target instanceof Node)) return; if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return; if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
open.value = false; closeMenu();
} }
defineExpose({ close: closeMenu });
onMounted(() => { onMounted(() => {
void loadOrgs(); void loadOrgs();
document.addEventListener('click', onDocClick); document.addEventListener('click', onDocClick);
@@ -137,27 +163,46 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div ref="rootEl" class="org-switch"> <div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
<button <button
class="org-btn" class="org-btn"
:class="{ compact, static: !canSwitch }" :class="{
compact,
static: !menuEnabled,
'org-ws': isWorkspace,
open,
}"
type="button" type="button"
:title="currentName" :title="currentName"
:aria-disabled="!canSwitch" :aria-expanded="open"
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
:aria-disabled="!menuEnabled"
@click.stop="toggle" @click.stop="toggle"
> >
<UiIcon name="building" :size="18" /> <template v-if="isWorkspace">
<span>{{ currentName }}</span> <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> </button>
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="open && canSwitch" v-if="open && menuEnabled"
ref="menuEl" ref="menuEl"
class="menu" class="menu"
:class="{ 'org-ws': isWorkspace }"
:style="menuStyle" :style="menuStyle"
role="listbox" role="listbox"
aria-label="محیط‌های کاری"
> >
<p class="hint">ورود به محیط</p> <p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
<button <button
v-for="org in orgs" v-for="org in orgs"
:key="org.id" :key="org.id"
@@ -165,41 +210,25 @@ onUnmounted(() => {
class="org-item" class="org-item"
:class="{ on: org.id === auth.user?.organizationId }" :class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading" :disabled="loading"
role="option"
:aria-selected="org.id === auth.user?.organizationId"
@click="choose(org)" @click="choose(org)"
> >
<strong>{{ org.name }}</strong> <span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</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>
<button type="button" class="create" :disabled="loading" @click="openCreate"> <p v-if="error" class="err">{{ error }}</p>
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
</div> </div>
</Teleport> </Teleport>
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
<form class="form" @submit.prevent="submitCreate">
<label>
نام محیط
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
</label>
<label>
زمینه فعالیت
<select v-model="newField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
</button>
</template>
</UiModal>
</div> </div>
</template> </template>
@@ -234,7 +263,7 @@ onUnmounted(() => {
margin-top: 0.05rem; margin-top: 0.05rem;
} }
.org-btn span { .org-btn > span {
min-width: 0; min-width: 0;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -253,7 +282,7 @@ onUnmounted(() => {
color: #94a3b8; color: #94a3b8;
} }
.org-btn.compact { .org-btn.compact:not(.org-ws) {
display: grid; display: grid;
place-items: center; place-items: center;
width: 100%; width: 100%;
@@ -262,18 +291,113 @@ onUnmounted(() => {
border-radius: var(--radius); border-radius: var(--radius);
} }
.org-btn.compact span { .org-btn.compact:not(.org-ws) > span {
display: none; display: none;
} }
.org-btn.compact:hover { .org-btn.compact:not(.org-ws):hover {
background: #f8fafc; background: #f8fafc;
} }
.org-btn.compact.static:hover { .org-btn.compact.static:not(.org-ws):hover {
background: transparent; 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 { .menu {
position: fixed; position: fixed;
z-index: 90; z-index: 90;
@@ -286,11 +410,21 @@ onUnmounted(() => {
gap: 0.2rem; 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 { .hint {
margin: 0; margin: 0;
padding: 0.25rem 0.45rem; padding: 0.35rem 0.65rem 0.45rem;
font-size: 0.72rem; font-size: 0.7rem;
color: var(--muted); font-weight: 700;
letter-spacing: 0.02em;
color: #94a3b8;
} }
.org-item, .org-item,
@@ -305,29 +439,89 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
} }
.org-item strong { .menu.org-ws .org-item {
font-size: 0.82rem; 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 { .org-item-mark {
font-size: 0.72rem; width: 36px;
color: var(--muted); 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, .org-item:hover,
.create: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 { .org-item.on {
background: var(--accent-soft); background: #f3f4f6;
} }
.create { .menu.org-ws .org-item.on {
margin-top: 0.15rem; background: #f3f4f6;
color: var(--accent); }
font-weight: 700;
font-size: 0.82rem; .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;
} }
.err { .err {
@@ -335,41 +529,4 @@ onUnmounted(() => {
color: #b91c1c; color: #b91c1c;
font-size: 0.75rem; font-size: 0.75rem;
} }
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.form input,
.form select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
}
.ghost,
.primary {
border-radius: 10px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
.ghost {
border: 1px solid var(--line);
background: #fff;
}
.primary {
border: 0;
background: var(--accent);
color: #fff;
font-weight: 700;
}
</style> </style>
@@ -18,7 +18,7 @@ import {
Upload as AUpload, Upload as AUpload,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { UploadProps } 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 { import {
filterPhoneContacts, filterPhoneContacts,
isContactPickerSupported, isContactPickerSupported,
+339 -65
View File
@@ -185,6 +185,13 @@ const fabStyle = computed(() => ({
bottom: 'auto', 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 panelStyle = computed(() => {
const gap = 12; const gap = 12;
const edge = 16; const edge = 16;
@@ -193,28 +200,33 @@ const panelStyle = computed(() => {
left = Math.min(left, viewport.value.w - width - edge); left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left); left = Math.max(edge, left);
const spaceAbove = fabPos.value.top - edge; const placeAbove = panelPlacement.value === 'above';
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge; const maxHeight = Math.max(
const placeAbove = spaceAbove >= spaceBelow; 0,
const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap); (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) { if (placeAbove) {
return { return {
left: `${left}px`, ...shared,
right: 'auto',
bottom: `${viewport.value.h - fabPos.value.top + gap}px`, bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto', top: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
}; };
} }
return { return {
left: `${left}px`, ...shared,
right: 'auto',
top: `${fabPos.value.top + FAB_SIZE + gap}px`, top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto', 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) => { watch(hidden, (value) => {
if (value) close(); if (value) close();
}); });
@@ -264,46 +272,51 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown); window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
}); });
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl"> <div v-if="!hidden" class="qc" dir="rtl">
<button <Transition name="qc-overlay">
v-if="isQuickCreateOpen" <button
class="overlay" v-if="isQuickCreateOpen"
type="button" class="overlay"
aria-label="بستن ایجاد سریع" type="button"
@click="close" aria-label="بستن ایجاد سریع"
/> @click="close"
/>
</Transition>
<section <Transition name="qc-panel">
v-if="isQuickCreateOpen" <section
class="panel" v-if="isQuickCreateOpen"
aria-labelledby="qc-title" class="panel"
:style="panelStyle" :data-place="panelPlacement"
> aria-labelledby="qc-title"
<h2 id="qc-title">ایجاد سریع</h2> :style="panelStyle"
<div class="items"> >
<button <h2 id="qc-title">ایجاد سریع</h2>
v-for="item in items" <div class="items">
:key="item.key" <button
class="item" v-for="(item, index) in items"
type="button" :key="item.key"
@click="go(item.to)" class="item"
> type="button"
<span class="item-ico" :class="item.tone"> :style="{ '--qc-i': index }"
<UiIcon :name="item.icon" :size="18" /> @click="go(item.to)"
</span> >
<span class="copy"> <span class="item-ico" :class="item.tone">
<strong>{{ item.title }}</strong> <UiIcon :name="item.icon" :size="18" />
<small>{{ item.desc }}</small> </span>
</span> <span class="copy">
</button> <strong>{{ item.title }}</strong>
</div> <small>{{ item.desc }}</small>
</section> </span>
</button>
</div>
</section>
</Transition>
<button <button
v-if="ready" v-if="ready"
@@ -319,6 +332,8 @@ onBeforeUnmount(() => {
@pointerup="onFabPointerUp" @pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp" @pointercancel="onFabPointerUp"
> >
<span class="fab-pulse" aria-hidden="true" />
<span class="fab-shine" aria-hidden="true" />
<span class="fab-face" aria-hidden="true"> <span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" /> <UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span> </span>
@@ -338,11 +353,12 @@ onBeforeUnmount(() => {
z-index: 90; z-index: 90;
border: 0; border: 0;
padding: 0; padding: 0;
background: rgba(0, 0, 0, 0.68); background: rgba(15, 23, 42, 0.42);
cursor: pointer; cursor: pointer;
} }
.panel { .panel {
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed; position: fixed;
z-index: 92; z-index: 92;
width: 300px; width: 300px;
@@ -352,10 +368,90 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
background: #fff; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: 14px;
padding: 12px 12px 10px; 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; 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 { .panel h2 {
@@ -369,12 +465,20 @@ onBeforeUnmount(() => {
.items { .items {
min-height: 0; min-height: 0;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
padding-left: 2px; padding-left: 2px;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: none;
}
.items::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
} }
.item { .item {
@@ -450,6 +554,7 @@ onBeforeUnmount(() => {
} }
.fab { .fab {
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed; position: fixed;
z-index: 93; z-index: 93;
box-sizing: border-box; box-sizing: border-box;
@@ -459,57 +564,226 @@ onBeforeUnmount(() => {
margin: 0; margin: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: visible;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%); background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
background-size: 140% 140%;
color: #fff; color: #fff;
display: grid; display: grid;
place-items: center; place-items: center;
line-height: 0; line-height: 0;
direction: ltr; direction: ltr;
isolation: isolate;
box-shadow: box-shadow:
0 4px 14px rgba(13, 148, 136, 0.35), 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; cursor: grab;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
transition: transition:
filter 0.15s ease, filter 0.35s var(--fab-ease),
box-shadow 0.15s ease, box-shadow 0.35s var(--fab-ease),
transform 0.12s 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 { .fab-face {
position: relative;
z-index: 2;
display: grid; display: grid;
place-items: center; place-items: center;
place-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0;
padding: 0;
pointer-events: none; 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; display: block;
width: 20px; width: 20px;
height: 20px; height: 20px;
max-width: 20px; max-width: 20px;
max-height: 20px; max-height: 20px;
margin: 0;
stroke-width: 1.7; 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 { .fab:hover {
filter: brightness(1.06); filter: brightness(1.07) saturate(1.05);
transform: translateY(-1px) scale(1.04);
box-shadow: box-shadow:
0 6px 18px rgba(13, 148, 136, 0.42), 0 10px 24px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25); 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 { .fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%); 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 { .fab.dragging {
cursor: grabbing; cursor: grabbing;
transform: scale(1.05); transform: scale(1.08);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45); 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> </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"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue'; import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Checkbox as ACheckbox, Checkbox as ACheckbox,
+76 -89
View File
@@ -118,77 +118,82 @@ defineExpose({ refresh: load });
<div v-if="visible" class="sms-wallet"> <div v-if="visible" class="sms-wallet">
<div <div
class="chip" class="chip"
:class="toneClass" :class="[toneClass, (summary?.balanceRials ?? 0) < 0 ? 'is-debt' : '']"
role="button"
tabindex="0"
:title="loading ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`" :title="loading ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`"
aria-label="شارژ اعتبار پیامک"
@click="chargeOpen = true"
@keydown.enter.prevent="chargeOpen = true"
@keydown.space.prevent="chargeOpen = true"
> >
<span class="label">پیامک</span> <span class="label">پیامک</span>
<span class="amount">{{ balanceLabel }}</span> <span class="amount">{{ balanceLabel }}</span>
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div> </div>
<Teleport to="body"> <Teleport to="body">
<div v-if="chargeOpen" class="layer"> <div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" /> <div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک"> <div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
<header> <header>
<strong>شارژ اعتبار پیامک</strong> <strong>شارژ اعتبار پیامک</strong>
<button type="button" class="x" @click="closeCharge">×</button> <button type="button" class="x" @click="closeCharge">×</button>
</header> </header>
<div v-if="chargeInfo" class="charge-body"> <div v-if="chargeInfo" class="charge-body">
<div class="instruction-box"> <div class="instruction-box">
<p>{{ chargeInfo.instruction }}</p> <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> </div>
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
</div>
<div class="info-card"> <div class="info-card">
<div class="info-head"> <div class="info-head">
<span>شماره شبا</span> <span>شماره کارت</span>
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)"> <button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
کپی کپی
</button> </button>
</div>
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
</div> </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>
<div class="info-card muted-card"> <p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
<span class="small">به نام</span>
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
</div>
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
</div> </div>
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
</div> </div>
</div>
</Teleport> </Teleport>
<Teleport to="body"> <Teleport to="body">
<div v-if="warnOpen" class="layer"> <div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" /> <div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی"> <div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
<header> <header>
<strong>موجودی پیامک منفی است</strong> <strong>موجودی پیامک منفی است</strong>
<button type="button" class="x" @click="warnOpen = false">×</button> <button type="button" class="x" @click="warnOpen = false">×</button>
</header> </header>
<p> <p>
اعتبار پیامک محیط شما منفی شده اعتبار پیامک محیط شما منفی شده
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً ({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
شارژ کنید. شارژ کنید.
</p> </p>
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button> <button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div>
</div> </div>
</div>
</Teleport> </Teleport>
</div> </div>
</template> </template>
@@ -200,15 +205,15 @@ defineExpose({ refresh: load });
flex-shrink: 0; flex-shrink: 0;
} }
/* چیپ جمع‌وجور؛ دکمه شارژ سمت چپ، برچسب سمت راست */ /* چیپ جمع‌وجور؛ کلیک کل سطح، پنجرهٔ شارژ را باز می‌کند */
.chip { .chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
height: 31px; height: var(--control-h);
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0 4px 0 2px; padding: 0 8px;
border: 1px solid #c3ead6; border: 1px solid #c3ead6;
border-radius: 10px; border-radius: 10px;
background: #f2fbf6; background: #f2fbf6;
@@ -217,6 +222,7 @@ defineExpose({ refresh: load });
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
} }
.chip.warning { .chip.warning {
@@ -232,6 +238,17 @@ defineExpose({ refresh: load });
color: #991b1b; color: #991b1b;
} }
.chip.is-debt {
border-color: var(--danger);
background: var(--danger);
color: #fff;
}
.chip.is-debt .label {
color: inherit;
opacity: 0.92;
}
.label { .label {
font-weight: 600; font-weight: 600;
font-size: 0.7rem; font-size: 0.7rem;
@@ -239,41 +256,9 @@ defineExpose({ refresh: load });
} }
.amount { .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; font-weight: 700;
line-height: 1; font-variant-numeric: tabular-nums;
cursor: pointer; letter-spacing: -0.01em;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.charge:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.charge:active {
background: #f1f5f9;
} }
.layer { .layer {
@@ -285,6 +270,7 @@ defineExpose({ refresh: load });
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
overflow: auto; overflow: auto;
box-sizing: border-box;
} }
.backdrop { .backdrop {
@@ -305,6 +291,7 @@ defineExpose({ refresh: load });
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18); box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
box-sizing: border-box;
} }
.modal header { .modal header {
+1 -1
View File
@@ -7,7 +7,7 @@ import {
Popover as APopover, Popover as APopover,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { SettingOutlined } from '@ant-design/icons-vue'; import { SettingOutlined } from '@/icons/ant-bridge';
import { useTableColumns } from '@/lib/tableColumns'; import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
+283 -364
View File
@@ -19,9 +19,36 @@ import {
emptySectionDataAccess, emptySectionDataAccess,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue'; import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali'; 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<{ const props = defineProps<{
users: OrgUserDto[]; users: OrgUserDto[];
@@ -37,7 +64,7 @@ const showPriorArchive = computed(
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false)); const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled)); const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
const priorSearch = ref(''); const priorSearch = ref('');
const priorPick = ref(''); const priorPick = ref<string | undefined>(undefined);
const priorSavingId = ref(''); const priorSavingId = ref('');
const cutoffLocal = ref(''); const cutoffLocal = ref('');
const cutoffSaving = ref(false); const cutoffSaving = ref(false);
@@ -48,10 +75,16 @@ const availablePriorUsers = computed(() => {
return priorUsers.value.filter((u) => { return priorUsers.value.filter((u) => {
if (u.priorArchiveEnabled) return false; if (u.priorArchiveEnabled) return false;
if (!q) return true; 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<{ const emit = defineEmits<{
refreshUsers: []; refreshUsers: [];
}>(); }>();
@@ -74,9 +107,14 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'), 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) { async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId; priorSavingId.value = userId;
priorPick.value = ''; priorPick.value = undefined;
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
@@ -114,7 +152,10 @@ async function loadPriorArchiveCutoff() {
applyCutoffFromOrg(data); applyCutoffFromOrg(data);
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.') ? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تاریخ بایگانی ناموفق بود.',
)
: 'بارگذاری تاریخ بایگانی ناموفق بود.'; : 'بارگذاری تاریخ بایگانی ناموفق بود.';
} finally { } finally {
cutoffLoading.value = false; cutoffLoading.value = false;
@@ -136,7 +177,10 @@ async function onCutoffChange(value: string) {
await auth.hydrate(); await auth.hydrate();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.') ? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره تاریخ بایگانی ناموفق بود.',
)
: 'ذخیره تاریخ بایگانی ناموفق بود.'; : 'ذخیره تاریخ بایگانی ناموفق بود.';
await loadPriorArchiveCutoff(); await loadPriorArchiveCutoff();
} finally { } finally {
@@ -144,9 +188,10 @@ async function onCutoffChange(value: string) {
} }
} }
function pickPriorUser(userId: string) { function pickPriorUser(userId: unknown) {
if (!userId) return; const id = userId == null ? '' : String(userId);
void setPriorArchive(userId, true); if (!id) return;
void setPriorArchive(id, true);
} }
async function load() { 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; 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() { async function saveMembers() {
@@ -365,362 +420,226 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="teams-panel"> <a-flex vertical :gap="12">
<p v-if="error" class="error">{{ error }}</p> <a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<p v-if="ok" class="ok">{{ ok }}</p> <a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<section v-if="canEdit" class="card"> <a-card :bordered="false">
<h3>تیم جدید</h3> <a-form v-if="canEdit" layout="vertical" @finish="createTeam">
<div class="row"> <a-divider orientation="right">تیم جدید</a-divider>
<UiField label="نام تیم"> <a-row :gutter="[16, 0]" align="bottom">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" /> <a-col :xs="24" :md="8">
</UiField> <a-form-item label="نام تیم">
<UiField label="توضیح"> <a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" />
<UiInput v-model="newDesc" placeholder="اختیاری" /> </a-form-item>
</UiField> </a-col>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton> <a-col :xs="24" :md="10">
</div> <a-form-item label="توضیح">
</section> <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"> <a-divider orientation="right">تیم‌ها</a-divider>
<h3>تیم‌ها</h3> <CrmSkeleton :loading="loading">
<UiEmptyState <CrmSkeletonCard :rows="6">
v-if="!loading && !teams.length" <a-empty
title="تیمی تعریف نشده" v-if="!loading && !teams.length"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند." 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"
/> />
</header> <a-list v-else item-layout="vertical" :data-source="teams">
<p class="hint"> <template #renderItem="{ item: t }">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ <a-list-item>
رکوردها را مطابق سطح دسترسی خودشان می‌بینند. <template v-if="canEdit" #actions>
</p> <a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button>
<div class="prior-picker"> <a-button type="link" @click="startEdit(t)">اعضا</a-button>
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" /> <a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button>
<select </template>
v-model="priorPick" <a-list-item-meta>
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)" <template #title>
@change="pickPriorUser(($event.target as HTMLSelectElement).value)" <a-space wrap>
> <a-typography-text strong>{{ t.name }}</a-typography-text>
<option value=""> <a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag>
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعال‌سازی' : 'کاربر دیگری پیدا نشد' }} </a-space>
</option> </template>
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id"> <template #description>
{{ u.fullName }} <a-typography-text type="secondary">
</option> {{ t.description || 'بدون توضیح' }} · {{ teamMembersLabel(t) }}
</select> </a-typography-text>
</div> </template>
<div v-if="selectedPriorUsers.length" class="prior-chips"> </a-list-item-meta>
<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>
<section class="card"> <a-form
<h3>محدوده داده</h3> v-if="editingMetaId === t.id"
<p class="hint"> layout="vertical"
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر style="width: 100%"
جدا بگذارید. @finish="saveMeta"
</p> >
<UiEmptyState <a-row :gutter="[16, 0]">
v-if="!editableUsers.length" <a-col :xs="24" :md="12">
title="کاربر قابل تنظیم نیست" <a-form-item label="نام تیم">
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند." <a-input v-model:value="editName" style="width: 100%" />
/> </a-form-item>
<div v-else class="vis-table"> </a-col>
<div v-for="u in editableUsers" :key="u.id" class="vis-user"> <a-col :xs="24" :md="12">
<div class="vis-row"> <a-form-item label="توضیح">
<span> <a-input v-model:value="editDesc" style="width: 100%" />
{{ u.fullName }} </a-form-item>
<small class="vis-badge">{{ accessSummary(u) }}</small> </a-col>
</span> </a-row>
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)"> <a-space>
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }} <a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
</UiButton> <a-button @click="editingMetaId = null">انصراف</a-button>
</div> </a-space>
<div v-if="expandedAccess === u.id" class="vis-scopes"> </a-form>
<label class="chip-row">
<input v-model="accessMode" type="radio" value="inherit" /> <a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%">
مثل نقش (بدون override) <a-typography-text type="secondary">
</label> اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.
<label class="chip-row"> </a-typography-text>
<input v-model="accessMode" type="radio" value="custom" /> <a-row :gutter="[12, 8]">
سفارشی برای این کاربر <a-col
</label> v-for="u in users.filter((x) => x.isActive !== false)"
<DataAccessPolicyPanel :key="u.id"
v-model="draftAccess" :xs="24"
v-model:treasury-access="draftTreasury" :sm="12"
:show-treasury="showTreasury" :lg="8"
:data-locked="accessMode === 'inherit'" >
compact <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"> <a-list v-else item-layout="vertical" :data-source="editableUsers">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton> <template #renderItem="{ item: u }">
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton> <a-list-item>
</div> <template v-if="canEdit" #actions>
</div> <a-button type="link" @click="openAccessEditor(u)">
</div> {{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
</div> </a-button>
</section> </template>
</div> <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> </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"> <script setup lang="ts">
/**
* آیکون‌های legacy — حالا از پک vuesax-linear رندر می‌شوند.
* API نام‌ها برای سازگاری با صفحات قدیمی حفظ شده.
*/
import VsIcon from '@/components/VsIcon.vue';
defineProps<{ defineProps<{
name: name:
| 'home' | 'home'
@@ -82,352 +88,7 @@ defineProps<{
</script> </script>
<template> <template>
<svg <VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
class="ico"
:width="size ?? 20"
:height="size ?? 20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'home'">
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V21h14V9.5" />
<path d="M10 21v-6h4v6" />
</template>
<template v-else-if="name === 'users'">
<circle cx="9" cy="8" r="3.2" />
<path d="M2.5 19c.8-3 2.9-4.5 6.5-4.5s5.7 1.5 6.5 4.5" />
<circle cx="17" cy="9" r="2.4" />
<path d="M15.2 14.2c2.3.3 3.9 1.5 4.8 3.8" />
</template>
<template v-else-if="name === 'briefcase'">
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5V7" />
<path d="M3 12h18" />
</template>
<template v-else-if="name === 'check'">
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.4 2.4 4.6-5" />
</template>
<template v-else-if="name === 'box'">
<path d="M12 3 3.5 7.5v9L12 21l8.5-4.5v-9L12 3Z" />
<path d="M12 12 3.5 7.5M12 12l8.5-4.5M12 12v9" />
</template>
<template v-else-if="name === 'chart'">
<path d="M4 19h16" />
<path d="M7 16V10" />
<path d="M12 16V6" />
<path d="M17 16v-4" />
</template>
<template v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</template>
<template v-else-if="name === 'search'">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</template>
<template v-else-if="name === 'sparkle'">
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
<path d="m6.5 6.5 2.5 2.5M15 15l2.5 2.5M17.5 6.5 15 9M9 15l-2.5 2.5" />
<circle cx="12" cy="12" r="2.2" />
</template>
<template v-else-if="name === 'bell'">
<path d="M6 9a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5H4.5S6 13 6 9Z" />
<path d="M10 19a2 2 0 0 0 4 0" />
</template>
<template v-else-if="name === 'calendar'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
</template>
<template v-else-if="name === 'calendar-plus'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M12 13v5M9.5 15.5h5" />
</template>
<template v-else-if="name === 'calendar-sun'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<circle cx="12" cy="15.2" r="2.1" />
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
</template>
<template v-else-if="name === 'calendar-edit'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
</template>
<template v-else-if="name === 'mail'">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" />
</template>
<template v-else-if="name === 'megaphone'">
<path d="M3 11v2a2 2 0 0 0 2 2h1l4 4V5L6 9H5a2 2 0 0 0-2 2Z" />
<path d="M14 8.5c2 .8 3.5 2.3 3.5 3.5s-1.5 2.7-3.5 3.5" />
<path d="M10 15v3a2 2 0 0 1-2 2" />
</template>
<template v-else-if="name === 'send'">
<path d="m4 12 16-8-6 16-2.5-6.5L4 12Z" />
</template>
<template v-else-if="name === 'fire'">
<path d="M12 3c2 3-1 4 1 7 1.5 2.2 4 2.5 4 6a5 5 0 1 1-10 0c0-3.5 2-5 3-7 1-2-1-4 2-6Z" />
</template>
<template v-else-if="name === 'warning'">
<path d="m12 4 9 16H3L12 4Z" />
<path d="M12 10v4" />
<path d="M12 17h.01" />
</template>
<template v-else-if="name === 'trend'">
<path d="M4 17 10 11l4 4 6-8" />
<path d="M15 7h5v5" />
</template>
<template v-else-if="name === 'phone'">
<path d="M8 3.5h2.5l1 4-2 1.2a12 12 0 0 0 5.8 5.8L16.5 12.5l4 1V16a2 2 0 0 1-2.2 2A16 16 0 0 1 4 6.2 2 2 0 0 1 6 4Z" />
</template>
<template v-else-if="name === 'trophy'">
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" />
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
</template>
<template v-else-if="name === 'chat'">
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
<path d="M8.5 11h7M8.5 14h4.5" />
</template>
<template v-else-if="name === 'dots'">
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'dots-v'">
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'document'">
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'download'">
<path d="M12 4.5v9" />
<path d="M8.5 10.5 12 14l3.5-3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'upload'">
<path d="M12 14.5v-9" />
<path d="M8.5 8.5 12 5l3.5 3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
</template>
<template v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4.2l2.8 1.8" />
</template>
<template v-else-if="name === 'user-plus'">
<circle cx="10" cy="8" r="3" />
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
<path d="M17 8v5M14.5 10.5h5" />
</template>
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'chevron-left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'chevrons-right'">
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
</template>
<template v-else-if="name === 'chevrons-left'">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'arrow-left'">
<path d="M19 12H5M12 19l-7-7 7-7" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
<template v-else-if="name === 'pin'">
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
<path d="M12 20v-5" />
</template>
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
<template v-else-if="name === 'funnel'">
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M9 18h6" />
<path d="M10 21h4" />
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
</template>
<template v-else-if="name === 'building'">
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
<path d="M4 21h16" />
</template>
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
</template>
<template v-else-if="name === 'flag'">
<path d="M6 21V4" />
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
</template>
<template v-else-if="name === 'refresh'">
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
<path d="M20 4v5h-5" />
</template>
<template v-else-if="name === 'x'">
<path d="M6 6 18 18M18 6 6 18" />
</template>
<template v-else-if="name === 'x-circle'">
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6M15 9l-6 6" />
</template>
<template v-else-if="name === 'mic'">
<rect x="9" y="3.5" width="6" height="10" rx="3" />
<path d="M7 11.5a5 5 0 0 0 10 0" />
<path d="M12 16.5V20M9 20h6" />
</template>
<template v-else-if="name === 'trash'">
<path d="M5 7h14" />
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
</template>
<template v-else-if="name === 'stop'">
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'dollar'">
<circle cx="12" cy="12" r="8.4" />
<path d="M12 6.8v10.4" />
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
<template v-else-if="name === 'layers'">
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
<path d="M4 12.2 12 16.4 20 12.2" />
<path d="M4 16.2 12 20.4 20 16.2" />
</template>
<template v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
<template v-else-if="name === 'edit'">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
</template>
<template v-else-if="name === 'grid'">
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
</template>
<template v-else-if="name === 'list'">
<path d="M8 6h12M8 12h12M8 18h12" />
<circle cx="4.5" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="18" r="1" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'eye'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
<path d="M4 20 20 4" />
</template>
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</template>
<template v-else-if="name === 'truck'">
<path d="M3 7.5h11V16H3z" />
<path d="M14 10h4.2l2.3 3.2V16H14z" />
<circle cx="7" cy="17.5" r="1.6" />
<circle cx="17" cy="17.5" r="1.6" />
<path d="M3 16h1.2M8.7 16H15" />
</template>
<template v-else-if="name === 'grip'">
<circle cx="9" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="17" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="17" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'apps-plus'">
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
</template>
<template v-else-if="name === 'crown'">
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
<path d="M5.2 18.6h13.6" />
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'gift'">
<rect x="4" y="11" width="16" height="9" rx="1.4" />
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
<path d="M12 6.8v13.2" />
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
</template>
<template v-else-if="name === 'medal'">
<circle cx="12" cy="9" r="5.2" />
<circle cx="12" cy="9" r="2.3" />
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
</template>
<template v-else-if="name === 'logout'">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</template>
<template v-else-if="name === 'pulse'">
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
</template>
<template v-else-if="name === 'sliders'">
<path d="M4 7h16M4 17h16" />
<path d="M8 4.5v5M16 14.5v5" />
</template>
<template v-else-if="name === 'camera'">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</template>
<template v-else-if="name === 'clip'">
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
</template>
</svg>
</template> </template>
<style scoped> <style scoped>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-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'; import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography; 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 { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types'; import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; 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 PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import { import {
ACTIVITY_BOARD_COPY, ACTIVITY_BOARD_COPY,
initialsFromTitle, initialsFromTitle,
@@ -429,34 +431,58 @@ function openActivity(id: string) {
</script> </script>
<template> <template>
<div class="kanban-wrap"> <div
<p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p> class="kanban-wrap"
<PipelineBoard :style="{
v-else flex: '1 1 auto',
:stages="boardStages" minHeight: 0,
:dragging-id="draggingId" height: '100%',
:drop-stage-id="dropStageId" minWidth: 0,
:can-manage="canManage" display: 'flex',
:copy="ACTIVITY_BOARD_COPY" flexDirection: 'column',
:select-mode="selectMode" }"
:selected-ids="selectedIds" >
@drag-start="onDragStart" <CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
@drag-end="onDragEnd" <a-flex
@drag-over="onDragOver" v-if="initialLoading && !Object.keys(columns).length"
@drop="onDrop" :gap="12"
@add-deal="emit('add', $event)" :style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
@open-deal="openActivity" >
@menu-deal="onMenuActivity" <div
@toggle-select="emit('toggleSelect', $event)" v-for="i in 4"
@bulk-delete="emit('bulkDelete')" :key="`sk-col-${i}`"
@rename-stage="onRenameStage" :style="{ width: '280px', flex: '0 0 280px' }"
@delete-stage="onDeleteStage" >
@move-stage="onMoveStage" <CrmSkeletonCard :rows="6" :min-height="360" />
@column-drag-start="(id: string) => (draggingColumnId = id)" </div>
@column-drag-end="draggingColumnId = null; dropStageId = null" </a-flex>
@add-stage="onAddStage" <PipelineBoard
@load-more="loadMore" 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"> <Teleport to="body">
<DealCardOverlay <DealCardOverlay
v-if="overlayDeal && draggingId" v-if="overlayDeal && draggingId"
@@ -480,9 +506,4 @@ function openActivity(id: string) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.muted {
margin: 0;
color: #64748b;
}
</style> </style>
@@ -11,12 +11,34 @@ import type {
PersonDto, PersonDto,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.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<{ const props = defineProps<{
fields: ActivityFieldDef[]; fields: ActivityFieldDef[];
@@ -24,8 +46,6 @@ const props = defineProps<{
}>(); }>();
const model = defineModel<ActivityFieldValues>({ required: true }); 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 { function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
const v = model.value[key]; 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[]) : []; return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
} }
function toggleMulti(key: string, opt: string) { function fieldSpan(type: ActivityFieldDef['type']): number {
const list = multi(key); return ['textarea', 'checklist', 'participants', 'multiselect'].includes(type) ? 24 : 12;
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]); }
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[] { function checklist(key: string): ActivityChecklistItem[] {
const v = model.value[key]; 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) { function addChecklistItem(key: string) {
@@ -115,39 +148,82 @@ function addGuest(key: string) {
const personQuery = ref<Record<string, string>>({}); const personQuery = ref<Record<string, string>>({});
const personResults = ref<Record<string, PersonDto[]>>({}); const personResults = ref<Record<string, PersonDto[]>>({});
const personOpenKey = ref(''); const personSearching = ref<Record<string, boolean>>({});
let personTimer: ReturnType<typeof setTimeout> | undefined; let personTimer: ReturnType<typeof setTimeout> | undefined;
let personSeq = 0; let personSeq = 0;
function searchPeople(key: string) { function searchPeople(key: string, raw: string) {
personOpenKey.value = key; personQuery.value = { ...personQuery.value, [key]: raw };
if (personTimer) clearTimeout(personTimer); if (personTimer) clearTimeout(personTimer);
const q = (personQuery.value[key] ?? '').trim(); const q = raw.trim();
if (!q) { if (!q) {
personResults.value = { ...personResults.value, [key]: [] }; personResults.value = { ...personResults.value, [key]: [] };
personSearching.value = { ...personSearching.value, [key]: false };
return; return;
} }
personSearching.value = { ...personSearching.value, [key]: true };
const seq = ++personSeq; const seq = ++personSeq;
personTimer = setTimeout(async () => { personTimer = setTimeout(async () => {
try { try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', { const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 8 }, 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 { } 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); }, 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); const cur = participants(key);
if (!cur.people.some((x) => x.id === p.id)) { if (!cur.people.some((x) => x.id === hit.id)) {
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] }); patchParticipants(key, { people: [...cur.people, { id: hit.id, name: hit.fullName }] });
} }
personQuery.value = { ...personQuery.value, [key]: '' }; personQuery.value = { ...personQuery.value, [key]: '' };
personResults.value = { ...personResults.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( watch(
@@ -157,523 +233,237 @@ watch(
guestDraft.value = {}; guestDraft.value = {};
personQuery.value = {}; personQuery.value = {};
personResults.value = {}; personResults.value = {};
personSearching.value = {};
}, },
); );
</script> </script>
<template> <template>
<div class="fields-grid"> <a-row :gutter="[16, 0]">
<div <a-col
v-for="f in fields" v-for="f in fields"
:key="f.key" :key="f.key"
class="cf" :xs="24"
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }" :md="fieldSpan(f.type)"
> >
<label :for="`cf-${f.key}`"> <a-form-item :required="f.required">
{{ f.label }} <template #label>
<i v-if="f.required">*</i> {{ f.label }}
</label> </template>
<input <a-input
v-if="f.type === 'text' || f.type === 'url'" v-if="f.type === 'text' || f.type === 'url'"
:id="`cf-${f.key}`" :value="str(f.key)"
:type="f.type === 'url' ? 'url' : 'text'" :type="f.type === 'url' ? 'url' : 'text'"
:dir="f.type === 'url' ? 'ltr' : undefined" :dir="f.type === 'url' ? 'ltr' : undefined"
:value="str(f.key)" :placeholder="f.placeholder || ''"
:placeholder="f.placeholder || ''" :maxlength="1000"
maxlength="1000" style="width: 100%"
@input="set(f.key, ($event.target as HTMLInputElement).value)" @update:value="(v: string) => set(f.key, v)"
/>
<textarea
v-else-if="f.type === 'textarea'"
:id="`cf-${f.key}`"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
rows="3"
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
/>
<input
v-else-if="f.type === 'number'"
:id="`cf-${f.key}`"
type="number"
inputmode="decimal"
dir="ltr"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
@input="
set(
f.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
/>
<JalaliDateInput
v-else-if="f.type === 'date'"
:id="`cf-${f.key}`"
:model-value="str(f.key)"
@update:model-value="(v: string) => set(f.key, v || null)"
/>
<select
v-else-if="f.type === 'select'"
:id="`cf-${f.key}`"
:value="str(f.key)"
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
>
<option value="">— انتخاب کنید —</option>
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
</select>
<div v-else-if="f.type === 'multiselect'" class="chips">
<button
v-for="opt in f.options ?? []"
:key="opt"
type="button"
class="chip-opt"
:class="{ on: multi(f.key).includes(opt) }"
@click="toggleMulti(f.key, opt)"
>
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
{{ opt }}
</button>
</div>
<label v-else-if="f.type === 'checkbox'" class="switch">
<input
:id="`cf-${f.key}`"
type="checkbox"
:checked="get<boolean>(f.key, false) === true"
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
/> />
<i />
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
</label>
<div v-else-if="f.type === 'checklist'" class="checklist"> <a-textarea
<p v-if="checklist(f.key).length" class="progress"> v-else-if="f.type === 'textarea'"
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از :value="str(f.key)"
{{ toFaDigits(checklist(f.key).length) }} انجام شده :placeholder="f.placeholder || ''"
</p> :rows="3"
<ul> style="width: 100%"
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }"> @update:value="(v: string) => set(f.key, v)"
<input />
type="checkbox"
<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" :checked="it.done"
aria-label="انجام شد" @update:checked="(v: boolean) => updateChecklistItem(f.key, it.id, { done: v })"
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
/> />
<input <a-input
class="item-text"
:value="it.text" :value="it.text"
maxlength="300" :maxlength="300"
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })" :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> <a-button
<button :disabled="idx === 0"
type="button" aria-label="بالا"
class="mini" @click="moveChecklistItem(f.key, idx, -1)"
>
<template #icon><UpOutlined /></template>
</a-button>
<a-button
:disabled="idx === checklist(f.key).length - 1" :disabled="idx === checklist(f.key).length - 1"
aria-label="پایین" aria-label="پایین"
@click="moveChecklistItem(f.key, idx, 1)" @click="moveChecklistItem(f.key, idx, 1)"
> >
▼ <template #icon><DownOutlined /></template>
</button> </a-button>
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)"> <a-button
<UiIcon name="x" :size="13" /> danger
</button> aria-label="حذف"
</li> @click="removeChecklistItem(f.key, it.id)"
</ul> >
<div class="add-row"> <template #icon><CloseOutlined /></template>
<input </a-button>
v-model="checklistDraft[f.key]" </a-flex>
maxlength="300" <a-space :size="8" style="width: 100%">
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'" <a-input
@keydown.enter.prevent="addChecklistItem(f.key)" :value="checklistDraft[f.key] ?? ''"
/> :maxlength="300"
<button type="button" class="add-btn" @click="addChecklistItem(f.key)"> :placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
<UiIcon name="plus" :size="14" /> style="width: 100%"
افزودن @update:value="(v: string) => (checklistDraft[f.key] = v)"
</button> @pressEnter="addChecklistItem(f.key)"
</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"
/> />
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results"> <a-button type="dashed" @click="addChecklistItem(f.key)">
<li v-for="p in personResults[f.key]" :key="p.id"> <template #icon><PlusOutlined /></template>
<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" />
افزودن افزودن
</button> </a-button>
</div> </a-space>
</div> </a-flex>
</div>
</div> <a-flex v-else-if="f.type === 'participants'" vertical :gap="16">
</div> <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> </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_COLORS,
ACTIVITY_STAGE_KIND_LABELS, ACTIVITY_STAGE_KIND_LABELS,
} from '@mento/shared'; } 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 { api } from '@/lib/api';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
@@ -62,6 +77,19 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; 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) { function isDirty(stage: ActivityStageDto) {
const d = drafts.value[stage.id]; const d = drafts.value[stage.id];
return Boolean( return Boolean(
@@ -78,9 +106,9 @@ async function save(stage: ActivityStageDto) {
} }
if ( if (
d.kind !== stage.kind && d.kind !== stage.kind &&
!window.confirm( !(await confirmAsync(
'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟', 'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟',
) ))
) { ) {
return; return;
} }
@@ -119,23 +147,25 @@ async function move(index: number, dir: -1 | 1) {
} }
async function remove(stage: ActivityStageDto) { async function remove(stage: ActivityStageDto) {
if ( Modal.confirm({
!window.confirm( title: 'حذف ستون',
`ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`, content: `ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
) okText: 'حذف',
) { cancelText: 'انصراف',
return; okButtonProps: { danger: true },
} async onOk() {
busyId.value = stage.id; busyId.value = stage.id;
error.value = ''; error.value = '';
try { try {
await api.delete(`/activities/stages/${stage.id}`); await api.delete(`/activities/stages/${stage.id}`);
emit('changed'); emit('changed');
} catch (e) { } catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.'); error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally { } finally {
busyId.value = ''; busyId.value = '';
} }
},
});
} }
async function create() { async function create() {
@@ -156,226 +186,163 @@ async function create() {
busyId.value = ''; busyId.value = '';
} }
} }
function dotColor(stage: ActivityStageDto) {
const color = drafts.value[stage.id]?.color ?? stage.color;
return PIPELINE_ACCENT[color].solid;
}
</script> </script>
<template> <template>
<UiModal <a-modal
:open="open" :open="open"
:title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'" :title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'"
@close="emit('close')" :footer="null"
@cancel="emit('close')"
> >
<div class="manager"> <a-flex vertical gap="middle">
<p class="hint"> <a-typography-text type="secondary">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت هر ستون یک «نوع» دارد: فعالیتی که به ستون <strong>انجام‌شده</strong> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <b>لغو شده</b> لغو می‌شود. از هر نوع حداقل یک ستون لازم است. می‌شود و در ستون <strong>لغو شده</strong> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</p> </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"> <a-flex vertical gap="small">
<div <a-row
v-for="(stage, index) in stages" v-for="(stage, index) in stages"
:key="stage.id" :key="stage.id"
class="row" :gutter="[8, 8]"
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }" align="middle"
> >
<span class="dot" aria-hidden="true" /> <a-col flex="none">
<input <span
v-if="drafts[stage.id]" aria-hidden="true"
v-model="drafts[stage.id]!.name" :style="{
class="name" display: 'inline-block',
maxlength="80" width: '0.65rem',
aria-label="نام ستون" height: '0.65rem',
@keydown.enter.prevent="save(stage)" borderRadius: '999px',
/> background: dotColor(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> </a-col>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.color" aria-label="رنگ ستون"> <a-col :xs="24" :sm="8">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c"> <a-input
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }} v-if="drafts[stage.id]"
</option> v-model:value="drafts[stage.id]!.name"
</select> :maxlength="80"
<div class="row-actions"> aria-label="نام ستون"
<button @keydown.enter.prevent="save(stage)"
type="button" />
class="icon" </a-col>
:disabled="index === 0 || Boolean(busyId)" <a-col :xs="12" :sm="6">
title="انتقال به قبل" <a-select
aria-label="انتقال به قبل" v-if="drafts[stage.id]"
@click="move(index, -1)" v-model:value="drafts[stage.id]!.kind"
aria-label="نوع ستون"
style="width: 100%"
> >
▲ <a-select-option v-for="k in KINDS" :key="k" :value="k">
</button> {{ ACTIVITY_STAGE_KIND_LABELS[k] }}
<button </a-select-option>
type="button" </a-select>
class="icon" </a-col>
:disabled="index === stages.length - 1 || Boolean(busyId)" <a-col :xs="12" :sm="5">
title="انتقال به بعد" <a-select
aria-label="انتقال به بعد" v-if="drafts[stage.id]"
@click="move(index, 1)" v-model:value="drafts[stage.id]!.color"
aria-label="رنگ ستون"
style="width: 100%"
> >
▼ <a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
</button> {{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
<UiButton </a-select-option>
size="sm" </a-select>
type="button" </a-col>
:disabled="!isDirty(stage) || Boolean(busyId)" <a-col :xs="24" :sm="5">
:loading="busyId === stage.id" <a-flex align="center" gap="small" wrap="wrap" justify="flex-end">
@click="save(stage)" <a-button
> type="text"
ذخیره :disabled="index === 0 || Boolean(busyId)"
</UiButton> title="انتقال به قبل"
<button aria-label="انتقال به قبل"
type="button" @click="move(index, -1)"
class="icon danger" >
:disabled="Boolean(busyId)" <template #icon><ArrowUpOutlined /></template>
title="حذف ستون" </a-button>
aria-label="حذف ستون" <a-button
@click="remove(stage)" type="text"
> :disabled="index === stages.length - 1 || Boolean(busyId)"
<UiIcon name="trash" :size="15" /> title="انتقال به بعد"
</button> aria-label="انتقال به بعد"
</div> @click="move(index, 1)"
</div> >
</div> <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"> <a-divider />
<strong>ستون جدید</strong>
<div class="create-fields"> <a-flex vertical gap="small" @submit.prevent="create">
<input v-model="createForm.name" maxlength="80" placeholder="مثلاً: منتظر پاسخ مشتری" /> <a-typography-text strong>ستون جدید</a-typography-text>
<select v-model="createForm.kind" aria-label="نوع ستون جدید"> <a-row :gutter="[8, 8]" align="middle">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option> <a-col :xs="24" :sm="10">
</select> <a-input
<select v-model="createForm.color" aria-label="رنگ ستون جدید"> v-model:value="createForm.name"
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c"> :maxlength="80"
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }} placeholder="مثلاً: منتظر پاسخ مشتری"
</option> />
</select> </a-col>
<UiButton <a-col :xs="12" :sm="5">
variant="primary" <a-select v-model:value="createForm.kind" aria-label="نوع ستون جدید" style="width: 100%">
type="submit" <a-select-option v-for="k in KINDS" :key="k" :value="k">
:disabled="!createForm.name.trim() || Boolean(busyId)" {{ ACTIVITY_STAGE_KIND_LABELS[k] }}
:loading="busyId === 'create'" </a-select-option>
> </a-select>
افزودن </a-col>
</UiButton> <a-col :xs="12" :sm="5">
</div> <a-select v-model:value="createForm.color" aria-label="رنگ ستون جدید" style="width: 100%">
</form> <a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
</div> {{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</UiModal> </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> </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"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import AutomationNode from '@/components/automations/AutomationNode.vue'; import AutomationNode from '@/components/automations/AutomationNode.vue';
import { import {
AUTOMATION_ACTION_LABELS, AUTOMATION_ACTION_LABELS,
@@ -22,6 +21,23 @@ import {
type TeamDto, type TeamDto,
} from '@mento/shared'; } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali'; 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 = export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction } | { kind: 'action'; action: AutomationAction }
@@ -42,8 +58,15 @@ const props = withDefaults(
form?: LeadFormDto | null; form?: LeadFormDto | null;
users?: OrgUserDto[]; users?: OrgUserDto[];
teams?: TeamDto[]; teams?: TeamDto[];
fullscreen?: boolean;
}>(), }>(),
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] }, {
chainMode: 'stop',
form: null,
users: () => [],
teams: () => [],
fullscreen: false,
},
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -52,6 +75,7 @@ const emit = defineEmits<{
remove: [id: string]; remove: [id: string];
removeRange: [start: number, end: number]; removeRange: [start: number, end: number];
addBranch: []; addBranch: [];
'update:fullscreen': [value: boolean];
}>(); }>();
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null); 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, () => 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 layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
const steps = props.steps; const steps = props.steps;
const { prefixEnd, branches } = splitAutomationBranches(steps); const { prefixEnd, branches } = splitAutomationBranches(steps);
@@ -151,10 +285,10 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده'; return 'مسئول پرونده';
} }
function actionIcon(action: AutomationAction) { function actionIcon(action: AutomationAction): Component {
if (action === 'create_notification') return 'bell'; if (action === 'create_notification') return BellOutlined;
if (action === 'add_loyalty_points') return 'gift'; if (action === 'add_loyalty_points') return GiftOutlined;
return 'flag'; return FlagOutlined;
} }
function stepTitle(step: AutomationStep) { function stepTitle(step: AutomationStep) {
@@ -208,13 +342,20 @@ function stepHint(step: AutomationStep) {
} }
function openAdd(item: { key: string; index: number; allow: AddAllow }) { function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value) return; if (!canAdd.value || tool.value === 'hand') return;
addAt.value = addAt.value =
addAt.value?.key === item.key addAt.value?.key === item.key
? null ? null
: { key: item.key, index: item.index, allow: item.allow }; : { 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) { function pickAction(action: AutomationAction) {
if (!addAt.value) return; if (!addAt.value) return;
emit('add', { kind: 'action', action }, addAt.value.index); emit('add', { kind: 'action', action }, addAt.value.index);
@@ -234,7 +375,19 @@ function removeBranch(lane: BranchLane) {
</script> </script>
<template> <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"> <div class="flow">
<button <button
class="node trigger" class="node trigger"
@@ -243,7 +396,7 @@ function removeBranch(lane: BranchLane) {
@click="emit('select', 'trigger')" @click="emit('select', 'trigger')"
> >
<span class="icon"> <span class="icon">
<UiIcon name="sparkle" :size="18" /> <ThunderboltOutlined />
</span> </span>
<span class="copy"> <span class="copy">
<span class="kind">اگر</span> <span class="kind">اگر</span>
@@ -265,8 +418,46 @@ function removeBranch(lane: BranchLane) {
title="افزودن گره" title="افزودن گره"
@click.stop="openAdd(item)" @click.stop="openAdd(item)"
> >
<UiIcon name="plus" :size="item.end ? 16 : 14" /> <PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
</button> </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> </div>
<AutomationNode <AutomationNode
v-else v-else
@@ -292,7 +483,7 @@ function removeBranch(lane: BranchLane) {
title="حذف این شاخه" title="حذف این شاخه"
@click.stop="removeBranch(lane)" @click.stop="removeBranch(lane)"
> >
<UiIcon name="trash" :size="12" /> <DeleteOutlined :style="{ fontSize: '12px' }" />
</button> </button>
</div> </div>
<template v-for="item in lane.items" :key="item.key"> <template v-for="item in lane.items" :key="item.key">
@@ -308,8 +499,46 @@ function removeBranch(lane: BranchLane) {
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'" :title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
@click.stop="openAdd(item)" @click.stop="openAdd(item)"
> >
<UiIcon name="plus" :size="14" /> <PlusOutlined :style="{ fontSize: '14px' }" />
</button> </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> </div>
<AutomationNode <AutomationNode
v-else v-else
@@ -330,65 +559,146 @@ function removeBranch(lane: BranchLane) {
:title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'" :title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')" @click.stop="emit('addBranch')"
> >
<UiIcon name="plus" :size="14" /> <PlusOutlined :style="{ fontSize: '14px' }" />
شاخهٔ جدید شاخهٔ جدید
</button> </button>
</div> </div>
</template> </template>
</div> </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"> <p v-if="!canAdd" class="cap">
حداکثر {{ toFaDigits(MAX_AUTOMATION_STEPS) }} گره در یک قانون حداکثر {{ toFaDigits(MAX_AUTOMATION_STEPS) }} گره در یک قانون
</p> </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> </div>
</template> </template>
<style scoped> <style scoped>
.canvas-shell {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 14rem;
flex: 1 1 auto;
}
.canvas { .canvas {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%;
min-width: 0; min-width: 0;
min-height: 22rem;
border-radius: var(--radius); border-radius: var(--radius);
overflow: auto; overflow: hidden;
background-color: #f3f6f5; background-color: #f3f6f5;
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px); background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
background-size: 16px 16px; 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 { .flow {
@@ -396,7 +706,7 @@ function removeBranch(lane: BranchLane) {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
min-width: max-content; min-width: max-content;
min-height: 16rem; min-height: calc(100% - 2rem);
} }
.node.trigger { .node.trigger {
@@ -433,6 +743,7 @@ function removeBranch(lane: BranchLane) {
flex-shrink: 0; flex-shrink: 0;
color: #c2410c; color: #c2410c;
background: #ffedd5; background: #ffedd5;
font-size: 18px;
} }
.copy { .copy {
@@ -535,7 +846,7 @@ strong {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
padding-block: 1.3rem; padding-block: 0.7rem;
} }
.branch::before { .branch::before {
@@ -608,66 +919,114 @@ strong {
} }
.picker { .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; background: #fff;
border: 1px solid #d5ddd8; border: 1px solid #d5ddd8;
border-radius: var(--radius); border-radius: 12px;
padding: 0.85rem; 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; display: grid;
gap: 0.65rem; grid-template-columns: 32px 1fr;
max-width: 40rem;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
}
.picker p {
margin: 0;
font-size: 0.82rem;
font-weight: 700;
color: #334155;
}
.picks {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.picks button {
display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 8px;
border: 1px solid #e2e8f0; width: 100%;
background: #fff; border: 0;
border-radius: 999px; border-radius: 8px;
padding: 0.45rem 0.75rem; background: transparent;
font: inherit; padding: 6px 8px;
font-weight: 700; text-align: start;
font-size: 0.8rem;
cursor: pointer; 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; color: #0f766e;
} }
.picks .create_notification {
color: #1d4ed8; .pick.condition .pick-ico {
} background: #fff7ed;
.picks .add_loyalty_points {
color: #7c3aed;
}
.picks .condition {
color: #b45309; color: #b45309;
} }
.cancel { .pick.create_notification .pick-ico {
justify-self: start; background: #eff6ff;
border: 0; color: #1d4ed8;
background: none; }
color: #64748b;
cursor: pointer; .pick.add_loyalty_points .pick-ico {
font: inherit; background: #f5f3ff;
font-size: 0.8rem; 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 { .cap {
@@ -680,7 +1039,10 @@ strong {
@media (max-width: 700px) { @media (max-width: 700px) {
.canvas { .canvas {
min-height: 0; min-height: 0;
padding: 1rem 0.75rem; }
.world {
padding: 1rem 0.75rem 4rem;
} }
.flow, .flow,
@@ -749,7 +1111,7 @@ strong {
} }
.picker { .picker {
max-width: none; width: min(220px, 70vw);
} }
} }
</style> </style>
@@ -31,6 +31,24 @@ import {
formConditionValues, formConditionValues,
isAutomationCondition, isAutomationCondition,
} from '@mento/shared'; } 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<{ const props = defineProps<{
selected: 'trigger' | string; selected: 'trigger' | string;
@@ -124,6 +142,87 @@ function toggleUser(step: AutomationStep, id: string, on: boolean) {
step.assigneeUserIds = [...cur]; 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) { function setConditionKey(step: AutomationStep, key: string) {
step.conditionKey = key || null; step.conditionKey = key || null;
const field = formFields.value.find((f) => f.key === key); 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] : []; 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) { function multiValueHint(op: AutomationConditionOp | undefined) {
if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینه‌ها انتخاب شده باشد.'; if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینه‌ها انتخاب شده باشد.';
if (op === 'not_contains') return 'هیچ‌کدام از این گزینه‌ها نباید انتخاب شده باشد.'; if (op === 'not_contains') return 'هیچ‌کدام از این گزینه‌ها نباید انتخاب شده باشد.';
@@ -162,6 +252,94 @@ const ticketPriorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'urgent', label: 'فوری' }, { 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) { function setAction(step: AutomationStep, action: AutomationAction) {
step.kind = 'action'; step.kind = 'action';
step.action = action; step.action = action;
@@ -202,440 +380,273 @@ watch(
</script> </script>
<template> <template>
<aside class="inspector"> <a-form layout="vertical" :style="{ marginBottom: 0 }">
<template v-if="selected === 'trigger'"> <template v-if="selected === 'trigger'">
<h2>گره محرک</h2> <a-form-item label="اگر" :style="{ marginBottom: `${token.marginSM}px` }">
<p class="hint">وقتی این اتفاق بیفتد، زنجیره از راست به چپ اجرا می‌شود.</p> <a-select v-model:value="trigger" :options="triggerOptions" style="width: 100%" />
<label> </a-form-item>
اگر <a-form-item
<select v-model="trigger"> v-if="trigger === 'lead_submitted'"
<optgroup label="فرم"
v-for="group in AUTOMATION_TRIGGER_GROUPS" :style="{ marginBottom: `${token.marginSM}px` }"
:key="group.label" >
:label="group.label" <a-select v-model:value="sourceIdSelect" :options="formOptions" style="width: 100%" />
> </a-form-item>
<option v-for="t in group.items" :key="t" :value="t"> <a-form-item
{{ AUTOMATION_TRIGGER_LABELS[t] }} v-if="advanced || chainMode === 'branches'"
</option> label="رفتار شرط‌ها"
</optgroup> :style="{ marginBottom: `${token.marginSM}px` }"
</select> >
</label> <a-select v-model:value="chainMode" :options="chainModeOptions" style="width: 100%" />
<label v-if="trigger === 'lead_submitted'"> </a-form-item>
فرم <a-alert
<select v-model="sourceId"> v-if="trigger === 'lead_submitted' && !sourceId"
<option :value="null">همه فرم‌ها</option> type="info"
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option> show-icon
</select> message="برای شرط روی پاسخ فرم، یک فرم مشخص انتخاب کنید."
</label> />
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p>
<label v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها
<select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
</select>
</label>
<p v-if="chainMode === 'branches'" class="hint">
شاخه‌ها روی بوم موازی نمایش داده می‌شوند و هر کدام جدا بررسی می‌شود. شرط‌های یک شاخه با «و»
ترکیب می‌شوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا می‌شود. برای شاخهٔ تازه از
دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
</p>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p>
</template> </template>
<template v-else-if="selectedStep && isCondition"> <template v-else-if="selectedStep && isCondition">
<h2>گره شرط</h2> <a-form-item label="فیلد" :style="{ marginBottom: `${token.marginSM}px` }">
<p class="hint"> <a-select
{{
chainMode === 'branches'
? 'اگر شرط برقرار نباشد، فقط عمل‌های این شاخه (تا شرط بعدی) اجرا نمی‌شوند.'
: 'اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند.'
}}
</p>
<label>
فیلد
<select
:value="selectedStep.conditionField" :value="selectedStep.conditionField"
@change=" :options="conditionFieldOptions"
setConditionField( style="width: 100%"
selectedStep, @change="onConditionFieldChange"
($event.target as HTMLSelectElement).value as AutomationConditionField, />
) </a-form-item>
" <a-form-item
> v-if="selectedStep.conditionField === 'form_field' && selectedForm"
<option v-for="f in conditionFields" :key="f" :value="f"> label="فیلد فرم"
{{ AUTOMATION_CONDITION_FIELD_LABELS[f] }} :style="{ marginBottom: `${token.marginSM}px` }"
</option> >
</select> <a-select
</label> :value="selectedStep.conditionKey ?? undefined"
<template v-if="selectedStep.conditionField === 'form_field'"> :options="formFieldOptions"
<p v-if="!selectedForm" class="hint warn"> placeholder="انتخاب فیلد…"
ابتدا در گره محرک یک فرم مشخص انتخاب کنید. style="width: 100%"
</p> @change="onConditionKeyChange"
<label v-else> />
فیلد فرم </a-form-item>
<select <a-form-item label="مقایسه" :style="{ marginBottom: `${token.marginSM}px` }">
:value="selectedStep.conditionKey ?? ''" <a-select
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)" v-model:value="selectedStep.conditionOp"
> :options="conditionOpOptions"
<option value="" disabled>انتخاب فیلد…</option> style="width: 100%"
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option> />
</select> </a-form-item>
</label> <a-form-item
</template> v-if="selectedStep.conditionField === 'deal_amount'"
<label> label="مبلغ"
مقایسه :style="{ marginBottom: `${token.marginSM}px` }"
<select v-model="selectedStep.conditionOp"> >
<option v-for="op in conditionOps" :key="op" :value="op"> <a-input-number v-model:value="dealAmountValue" :min="0" style="width: 100%" />
{{ AUTOMATION_CONDITION_OP_LABELS[op] }} </a-form-item>
</option> <a-form-item
</select> v-if="selectedStep.conditionField === 'ticket_priority'"
</label> label="اولویت"
<label v-if="selectedStep.conditionField === 'deal_amount'"> :style="{ marginBottom: `${token.marginSM}px` }"
مبلغ >
<input v-model.number="selectedStep.conditionValue" type="number" min="0" /> <a-select
</label> v-model:value="selectedStep.conditionValue"
<label v-if="selectedStep.conditionField === 'ticket_priority'"> :options="ticketPriorities"
اولویت style="width: 100%"
<select v-model="selectedStep.conditionValue"> />
<option v-for="p in ticketPriorities" :key="p.value" :value="p.value"> </a-form-item>
{{ p.label }} <a-alert
</option> v-if="selectedStep.conditionField === 'form_field' && !selectedForm"
</select> type="warning"
</label> show-icon
message="ابتدا در گره محرک یک فرم مشخص انتخاب کنید."
/>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue"> <template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<div v-if="conditionFormField?.options?.length" class="user-picks"> <a-form-item
<p class="field-label">گزینه‌ها (یک یا چند مورد)</p> v-if="conditionFormField?.options?.length"
<label v-for="o in conditionFormField.options" :key="o" class="ch"> label="گزینه‌ها"
<input :style="{ marginBottom: `${token.marginSM}px` }"
type="checkbox" >
:checked="formConditionValues(selectedStep).includes(o)" <a-checkbox-group
@change="toggleConditionValue(selectedStep, o, ($event.target as HTMLInputElement).checked)" :value="formConditionValues(selectedStep)"
/> :options="conditionFormField.options.map((o) => ({ value: o, label: o }))"
{{ o }} @change="onFormOptionValuesChange"
</label> />
<p v-if="formConditionValues(selectedStep).length > 1" class="hint"> <a-typography-text
v-if="formConditionValues(selectedStep).length > 1"
type="secondary"
:style="{ display: 'block', marginTop: `${token.marginXXS}px` }"
>
{{ multiValueHint(selectedStep.conditionOp) }} {{ multiValueHint(selectedStep.conditionOp) }}
</p> </a-typography-text>
</div> </a-form-item>
<label v-else> <a-form-item v-else label="مقدار" :style="{ marginBottom: `${token.marginSM}px` }">
مقدار <a-input v-model:value="formTextValue" :maxlength="200" />
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" /> </a-form-item>
</label>
</template> </template>
</template> </template>
<template v-else-if="selectedStep"> <template v-else-if="selectedStep">
<h2>گره عمل</h2> <a-form-item label="آنگاه" :style="{ marginBottom: `${token.marginSM}px` }">
<p class="hint">بعد از محرک (و شرط‌ها)، این کار به ترتیب انجام می‌شود.</p> <a-select
<label>
آنگاه
<select
:value="selectedStep.action" :value="selectedStep.action"
@change=" :options="actionOptions"
setAction( style="width: 100%"
selectedStep, @change="onActionChange"
($event.target as HTMLSelectElement).value as AutomationAction, />
) </a-form-item>
" <a-form-item label="عنوان" :style="{ marginBottom: `${token.marginSM}px` }">
> <a-input
<option v-for="a in AUTOMATION_ACTIONS" :key="a" :value="a"> v-model:value="selectedStep.title"
{{ AUTOMATION_ACTION_LABELS[a] }} :maxlength="200"
</option>
</select>
</label>
<label>
عنوان
<input
v-model="selectedStep.title"
type="text"
maxlength="200"
placeholder="اختیاری" placeholder="اختیاری"
/> />
</label> </a-form-item>
<label> <a-form-item label="توضیح / متن" :style="{ marginBottom: `${token.marginSM}px` }">
توضیح / متن پیام <a-textarea
<textarea v-model:value="selectedStep.body"
v-model="selectedStep.body" :rows="2"
rows="3" :maxlength="1000"
maxlength="1000"
placeholder="اختیاری" 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'"> <template v-if="selectedStep.action === 'create_activity'">
<label v-if="activityTypeDefs.length"> <a-form-item label="نوع فعالیت" :style="{ marginBottom: `${token.marginSM}px` }">
نوع سفارشی (اختیاری) <a-select
<select v-model="selectedStep.activityTypeDefId"> v-model:value="selectedStep.activityType"
<option :value="null">— بدون نوع سفارشی —</option> :options="activityTypeOptions"
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option> style="width: 100%"
</select> />
</label> </a-form-item>
<a-form-item
<label> v-if="activityTypeDefs.length"
تخصیص به label="نوع سفارشی"
<select v-model="selectedStep.assignMode"> :style="{ marginBottom: `${token.marginSM}px` }"
<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-select
</p> :value="nullableId(selectedStep.activityTypeDefId)"
:options="activityTypeDefOptions"
<div class="due"> style="width: 100%"
<label> @change="onActivityTypeDefChange"
موعد />
<select </a-form-item>
:value="dueUnitOf(selectedStep)" <a-form-item label="تخصیص به" :style="{ marginBottom: `${token.marginSM}px` }">
@change=" <a-select
setDue( v-model:value="selectedStep.assignMode"
selectedStep, :options="assignModeOptions"
($event.target as HTMLSelectElement).value as DueUnit, style="width: 100%"
dueAmountOf(selectedStep) || 1, />
) </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> {{ u.fullName }}
</select> </a-checkbox>
</label> </a-flex>
<label v-if="dueUnitOf(selectedStep) !== 'now'"> </a-form-item>
تعداد <a-form-item label="موعد" :style="{ marginBottom: `${token.marginSM}px` }">
<input <a-select
type="number" :value="dueUnitOf(selectedStep)"
min="1" :options="dueUnitOptions"
:value="dueAmountOf(selectedStep)" style="width: 100%"
@input=" @change="onDueUnitChange"
setDue( />
selectedStep, </a-form-item>
dueUnitOf(selectedStep), <a-form-item
Number(($event.target as HTMLInputElement).value), v-if="dueUnitOf(selectedStep) !== 'now'"
) label="تعداد"
" :style="{ marginBottom: `${token.marginSM}px` }"
/> >
</label> <a-input-number
</div> :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> </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'"> <template v-if="selectedStep.action === 'create_notification'">
<p class="field-label">نحوه ارسال</p> <a-form-item label="نحوه ارسال" :style="{ marginBottom: `${token.marginSM}px` }">
<div class="channels"> <a-radio-group
<label v-model:value="selectedStep.notifyChannel"
v-for="ch in AUTOMATION_NOTIFY_CHANNELS" :options="notifyChannelOptions"
:key="ch" />
class="ch" </a-form-item>
> <a-form-item label="گیرنده" :style="{ marginBottom: `${token.marginSM}px` }">
<input <a-select
v-model="selectedStep.notifyChannel" v-model:value="selectedStep.notifyTarget"
type="radio" :options="[
:value="ch" { value: 'staff', label: 'کاربر تیم' },
/> { value: 'person', label: 'مخاطب معامله / تیکت' },
{{ AUTOMATION_NOTIFY_CHANNEL_LABELS[ch] }} ]"
</label> style="width: 100%"
</div> />
<label> </a-form-item>
گیرنده <a-form-item
<select v-model="selectedStep.notifyTarget"> v-if="selectedStep.notifyTarget !== 'person'"
<option value="staff">کاربر تیم</option> label="کاربر"
<option value="person">مخاطب معامله / تیکت</option> :style="{ marginBottom: `${token.marginSM}px` }"
</select>
</label>
<label v-if="selectedStep.notifyTarget !== 'person'">
کاربر
<select v-model="selectedStep.notifyUserId">
<option :value="null">مسئول معامله / تیکت</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</label>
<p v-if="selectedStep.notifyTarget === 'person'" class="hint">
پیامک به موبایل مخاطب ارسال می‌شود. ناتیفیکیشن داخلی برای مخاطب نیست.
</p>
<p
v-else-if="selectedStep.notifyChannel === 'sms' || selectedStep.notifyChannel === 'both'"
class="hint"
> >
پیامک به موبایل همان کاربر ارسال می‌شود. <a-select
</p> :value="nullableId(selectedStep.notifyUserId)"
:options="notifyUserOptions"
style="width: 100%"
@change="onNotifyUserIdChange"
/>
</a-form-item>
</template> </template>
<label v-if="selectedStep.action === 'add_loyalty_points'"> <a-form-item
امتیاز v-if="selectedStep.action === 'add_loyalty_points'"
<input v-model.number="selectedStep.points" type="number" min="1" /> label="امتیاز"
</label> :style="{ marginBottom: `${token.marginSM}px` }"
>
<a-input-number v-model:value="selectedStep.points" :min="1" style="width: 100%" />
</a-form-item>
</template> </template>
<p v-else class="hint">یک گره را از بوم انتخاب کنید.</p> <a-typography-text v-else type="secondary">یک گره را از بوم انتخاب کنید.</a-typography-text>
</aside> </a-form>
</template> </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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, type Component } from 'vue';
import UiIcon from '@/components/UiIcon.vue'; import {
BellOutlined,
DeleteOutlined,
FilterOutlined,
FlagOutlined,
GiftOutlined,
} from '@/icons/ant-bridge';
import { isAutomationCondition, type AutomationStep } from '@mento/shared'; import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{ const props = defineProps<{
@@ -15,11 +21,11 @@ const emit = defineEmits<{ select: []; remove: [] }>();
const isCondition = computed(() => isAutomationCondition(props.step)); const isCondition = computed(() => isAutomationCondition(props.step));
const icon = computed(() => { const icon = computed((): Component => {
if (isCondition.value) return 'filter'; if (isCondition.value) return FilterOutlined;
if (props.step.action === 'create_notification') return 'bell'; if (props.step.action === 'create_notification') return BellOutlined;
if (props.step.action === 'add_loyalty_points') return 'gift'; if (props.step.action === 'add_loyalty_points') return GiftOutlined;
return 'flag'; return FlagOutlined;
}); });
const kindClass = computed(() => const kindClass = computed(() =>
@@ -36,7 +42,7 @@ const kindClass = computed(() =>
@click="emit('select')" @click="emit('select')"
> >
<span class="icon"> <span class="icon">
<UiIcon :name="icon" :size="18" /> <component :is="icon" />
</span> </span>
<span class="copy"> <span class="copy">
<span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span> <span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span>
@@ -51,7 +57,7 @@ const kindClass = computed(() =>
title="حذف" title="حذف"
@click.stop="emit('remove')" @click.stop="emit('remove')"
> >
<UiIcon name="trash" :size="14" /> <DeleteOutlined />
</button> </button>
</div> </div>
</template> </template>
@@ -125,6 +131,7 @@ const kindClass = computed(() =>
flex-shrink: 0; flex-shrink: 0;
color: #0f766e; color: #0f766e;
background: #ecfdf8; background: #ecfdf8;
font-size: 18px;
} }
.node.create_notification .icon { .node.create_notification .icon {
@@ -184,6 +191,7 @@ em {
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
font-size: 14px;
} }
.node-wrap:hover .trash, .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 { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import axios from 'axios'; 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 VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali'; import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import { api } from '@/lib/api'; 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<{ const props = defineProps<{
askQuery: string; askQuery: string;
@@ -26,11 +43,53 @@ const emit = defineEmits<{
notice: [value: { error: string; done: string }]; 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 = [ const shortcuts = [
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const }, { label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
{ label: 'فعالیت‌های عقب‌افتاده چیست؟', icon: 'calendar' as const }, { label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' },
{ label: 'مهم‌ترین پیگیری امروز چیست؟', icon: 'send' as const }, { label: 'پیگیری امروز', prompt: 'مهم‌ترین پیگیری امروز چیست؟' },
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const }, { label: 'خلاصه فروش', prompt: 'خلاصه وضعیت فروش را بده' },
];
const activityTypeOptions = [
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'task', label: 'کار' },
{ value: 'note', label: 'یادداشت' },
]; ];
function sourceTo(source: AskMentoSourceDto) { function sourceTo(source: AskMentoSourceDto) {
@@ -45,13 +104,6 @@ function pick(label: string) {
emit('ask', label); emit('ask', label);
} }
const ACTIVITY_LABEL: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const voiceBuf = ref(''); const voiceBuf = ref('');
const voiceBusy = ref(false); const voiceBusy = ref(false);
const voiceError = ref(''); const voiceError = ref('');
@@ -60,6 +112,13 @@ const draft = ref<VoiceCommandDraft | null>(null);
const confirming = ref(false); const confirming = ref(false);
const answerOpen = 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) { function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback; if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message; const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -272,12 +331,43 @@ watch(
</script> </script>
<template> <template>
<article id="home-ai-assistant" class="panel"> <section id="home-ai-assistant" class="home-ai">
<header class="panel-head"> <div class="home-ai__pattern" aria-hidden="true" />
<h2> <div class="home-ai__orb home-ai__orb--a" aria-hidden="true" />
<span class="spark"><UiIcon name="sparkle" :size="15" /></span> <div class="home-ai__orb home-ai__orb--b" aria-hidden="true" />
{{ brand.aiNameFa }}
</h2> <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 <VoiceToTextButton
variant="toolbar" variant="toolbar"
hide-error hide-error
@@ -286,322 +376,526 @@ watch(
@recording="onVoiceRecording" @recording="onVoiceRecording"
@error="voiceError = $event" @error="voiceError = $event"
/> />
</header> <a-button
type="primary"
<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"
:disabled="asking || voiceBusy || confirming" :disabled="asking || voiceBusy || confirming"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)" :loading="asking || voiceBusy"
/> aria-label="ارسال"
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming"> @click="onTypedSubmit"
<UiIcon name="send" :size="15" /> >
</button> بپرس
</form> </a-button>
</div>
<UiModal <div class="home-ai__chips">
:open="Boolean(draft && draft.intent !== 'none')" <span class="home-ai__chips-label">پیشنهادهای سریع</span>
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'" <div class="home-ai__chip-list">
@close="cancelDraft" <button
> v-for="item in shortcuts"
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p> :key="item.label"
<p class="edit-hint">قبل از تأیید می‌توانید متن را اصلاح کنید.</p> type="button"
<div v-if="draft?.person" class="modal-fields"> class="home-ai__chip"
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label> :disabled="asking"
<label>نام خانوادگی <input v-model="draft.person.lastName" /></label> @click="pick(item.prompt)"
<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"
> >
{{ source.label }} {{ item.label }}
</RouterLink> </button>
</div> </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> <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> </template>
</UiModal> </a-modal>
</article>
<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> </template>
<style scoped> <style scoped>
.panel { .home-ai {
background: #fff; --home-ai-h: 330px;
border-radius: var(--radius); position: relative;
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} gap: 0.75rem;
.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%;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid #e2e8f0; width: 100%;
border-radius: var(--radius); height: var(--home-ai-h);
background: #fff; min-height: var(--home-ai-h);
color: #0f172a; max-height: var(--home-ai-h);
font: inherit; padding: 0.85rem;
font-size: 0.88rem; overflow: hidden;
font-weight: 500; border-radius: 1rem;
padding: 0.5rem 0.65rem; 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 { .home-ai__pattern,
resize: vertical; .home-ai__orb {
pointer-events: none;
position: absolute;
} }
.warn { .home-ai__pattern {
display: inline-block; inset: 0;
margin-inline-start: 0.35rem; opacity: 0.55;
color: #b45309; background-image:
font-size: 0.75rem; 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 { .home-ai__orb {
margin: 0; border-radius: 999px;
font-size: 0.92rem; filter: blur(28px);
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
} }
.spark { .home-ai__orb--a {
color: #eab308; top: -2.5rem;
display: grid; inset-inline-end: -1.5rem;
width: 7rem;
height: 7rem;
background: rgb(52 211 153 / 0.22);
} }
.ai-lead { .home-ai__orb--b {
margin: 0 0 0.75rem; bottom: -2rem;
padding: 0.55rem 0.75rem; inset-inline-start: -1.25rem;
width: fit-content; width: 6rem;
max-width: 100%; height: 6rem;
background: #d1fae5; background: rgb(45 212 191 / 0.16);
color: #115e59;
font-size: 0.8rem;
font-weight: 600;
border-radius: 14px 14px 4px 14px;
} }
.ai-shortcuts { .home-ai__head,
display: grid; .home-ai__identity,
gap: 0.4rem; .home-ai__composer,
margin-bottom: 0.8rem; .home-ai__chips,
flex: 1; .home-ai__chip-list {
} position: relative;
z-index: 1;
.ai-chip {
display: flex; display: flex;
align-items: center; 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) { .home-ai__head {
background: #f0fdfa; justify-content: space-between;
border-color: #99f6e4; gap: 0.75rem;
}
.ai-chip:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-ico {
width: 26px;
height: 26px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
flex-shrink: 0; flex-shrink: 0;
} }
.ai-input { .home-ai__identity {
display: flex; gap: 0.7rem;
gap: 0.4rem; min-width: 0;
margin-top: auto;
} }
.ai-input input { .home-ai__avatar {
flex: 1; flex-shrink: 0;
border: 1px solid #e2e8f0; width: 2.75rem;
border-radius: var(--radius); height: 2.75rem;
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
}
.ai-input button {
width: 40px;
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid; display: grid;
place-items: center; 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 { .home-ai__avatar :deep(.mento-bot) {
opacity: 0.55; width: 42px;
} }
.ai-answer { .home-ai__avatar :deep(.bot-img) {
background: #f0fdfa; filter: drop-shadow(0 4px 10px rgba(34, 211, 238, 0.28));
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
} }
.ai-answer p { .home-ai__titles {
min-width: 0;
}
.home-ai__titles h3 {
margin: 0; margin: 0;
color: #134e4a; font-size: 0.95rem;
font-size: 0.82rem; font-weight: 800;
line-height: 1.7; color: #0f172a;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.sources { .home-ai__titles p {
display: flex; margin: 0.1rem 0 0;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.sources a {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; color: #64748b;
color: #0f766e; line-height: 1.35;
background: #fff; white-space: nowrap;
border-radius: 999px; overflow: hidden;
padding: 0.15rem 0.5rem; text-overflow: ellipsis;
} }
.ai-error { .home-ai__tip {
margin: 0 0 0.7rem; position: relative;
color: #b91c1c; z-index: 1;
font-size: 0.8rem; 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> </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"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router'; 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 { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue'; import SmsComposeModal from '@/components/SmsComposeModal.vue';
type BirthdayItem = { type BirthdayItem = {
@@ -28,6 +36,10 @@ type BirthdayMonth = {
const data = ref<BirthdayMonth | null>(null); const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false); const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null); const smsTarget = ref<BirthdayItem | null>(null);
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const groups = computed(() => { const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>(); const map = new Map<number, BirthdayItem[]>();
@@ -54,63 +66,78 @@ onMounted(async () => {
</script> </script>
<template> <template>
<section v-if="data" class="birthdays"> <section v-if="data" class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm">
<div class="head"> <a-flex align="center" wrap="wrap" gap="middle" justify="space-between">
<span class="ico"><UiIcon name="gift" :size="18" /></span> <a-flex align="center" gap="middle">
<div> <a-typography-text type="secondary"><GiftOutlined /></a-typography-text>
<strong>تولدهای {{ data.monthName }}</strong> <div>
<p>{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند</p> <a-typography-text strong>تولدهای {{ data.monthName }}</a-typography-text>
</div> <br />
</div> <a-typography-text type="secondary">
{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند
</a-typography-text>
</div>
</a-flex>
<div class="chips"> <a-space wrap>
<RouterLink <RouterLink
v-for="person in data.preview" v-for="person in data.preview"
:key="person.id" :key="person.id"
class="chip" :to="{ name: 'person-detail', params: { id: person.id } }"
: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 }"
> >
<h3> <a-tag>
<b>{{ toFaDigits(day) }}</b> {{ toFaDigits(person.day) }} — {{ person.fullName }}
<span>{{ data.monthName }}</span> </a-tag>
<em v-if="day < data.today">گذشته</em> </RouterLink>
<em v-else-if="day === data.today">امروز</em> <a-button type="link" @click="listOpen = true">
</h3> {{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
<ul> </a-button>
<li v-for="person in people" :key="person.id"> </a-space>
<RouterLink :to="{ name: 'person-detail', params: { id: person.id } }" @click="listOpen = false"> </a-flex>
{{ person.fullName }}
</RouterLink> <a-modal
<button v-model:open="listOpen"
type="button" :title="`تولدهای ${data.monthName}`"
class="sms" :footer="null"
:disabled="!person.mobile" destroy-on-close
@click="openSms(person)" >
> <a-list item-layout="vertical" :data-source="groups">
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }} <template #renderItem="{ item }">
</button> <a-list-item>
</li> <template #actions>
</ul> <a-tag v-if="item[0] < data!.today">گذشته</a-tag>
</section> <a-tag v-else-if="item[0] === data!.today" color="processing">امروز</a-tag>
</div> </template>
</UiModal> <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 <SmsComposeModal
nest nest
@@ -126,289 +153,3 @@ onMounted(async () => {
/> />
</section> </section>
</template> </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>
+272 -346
View File
@@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router'; import { SendOutlined } from '@/icons/ant-bridge';
import UiIcon from '@/components/UiIcon.vue'; import {
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue'; Button as AButton,
import { brand, type HomeDashboardAlertDto } from '@mento/shared'; Input as AInput,
Typography,
} from 'ant-design-vue';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
const props = defineProps<{ const props = defineProps<{
greeting: string; greeting: string;
firstName: string; firstName: string;
userInitial: string; userInitial: string;
alerts: HomeDashboardAlertDto[];
askQuery: string; askQuery: string;
asking?: boolean; asking?: boolean;
askAnswer?: string; askAnswer?: string;
@@ -24,444 +26,368 @@ const emit = defineEmits<{
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
type HeroChip = { /** فقط عکس واقعی کاربر؛ پیش‌فرض عمومی را نشان نده */
key: string; const hasCustomPhoto = computed(() => Boolean(auth.user?.hasAvatar));
tone: HomeDashboardAlertDto['tone'];
title: string;
subtitle?: string;
link: string;
};
const chips = computed<HeroChip[]>(() =>
props.alerts.slice(0, 3).map((a) => {
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
const title = parts[0]?.trim() || a.text;
const subtitle = parts.slice(1).join(' ').trim() || undefined;
return {
key: a.key,
tone: a.tone,
title,
subtitle,
link: a.link || '/',
};
}),
);
function alertIcon(tone: string) {
if (tone === 'urgent') return 'fire' as const;
if (tone === 'warning') return 'warning' as const;
if (tone === 'success') return 'trend' as const;
return 'sparkle' as const;
}
const shortcutLabel = computed(() => const shortcutLabel = computed(() =>
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform) typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform)
? '⌘K' ? '⌘K'
: 'Ctrl+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> </script>
<template> <template>
<header class="hero home-hero" style="position: relative"> <div class="flex flex-col gap-3">
<div <div class="home-hero">
class="hero-surface" <div class="home-hero__gradient" aria-hidden="true" />
aria-hidden="true" <div class="home-hero__pattern" aria-hidden="true" />
style="position: absolute; inset: 0; overflow: hidden; pointer-events: none" <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-glow" style="position: absolute" />
<div class="hero-mist" style="position: absolute; inset: 0" />
</div>
<div class="hero-body"> <div class="home-hero__row">
<div class="hero-greet"> <div class="home-hero__greet">
<span class="hero-avatar"> <div class="home-hero__identity" :aria-label="firstName">
<img :src="auth.avatarSrc" :alt="firstName" width="72" height="72" /> <img
<i class="online" /> v-if="hasCustomPhoto"
</span> :src="auth.avatarSrc"
<div class="greet-text"> :alt="firstName"
<h1>{{ greeting }} {{ firstName }} 👋</h1> class="home-hero__photo"
<p v-if="chips.length">{{ brand.nameFa }} اینا رو برات پیدا کرده</p> />
<p v-else>از {{ brand.nameFa }} بپرس یا کار امروز را شروع کن</p> <span v-else class="home-hero__monogram" aria-hidden="true">
{{ userInitial }}
</span>
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
</div>
<div class="home-hero__copy">
<p class="home-hero__brand">{{ brand.nameFa }}</p>
<a-typography-title :level="4" class="home-hero__title">
{{ greeting }}، {{ firstName }}
</a-typography-title>
<a-typography-text class="home-hero__subtitle">
از داده‌ها بپرسید یا کار امروز را از همین‌جا شروع کنید
</a-typography-text>
</div>
</div> </div>
</div>
<div v-if="chips.length" class="hero-chips"> <div class="home-hero__ask">
<RouterLink <a-input
v-for="chip in chips"
:key="chip.key"
class="chip"
:class="`chip-${chip.tone}`"
:to="chip.link"
>
<span class="chip-ico"><UiIcon :name="alertIcon(chip.tone)" :size="13" /></span>
<span class="chip-text">
<strong>{{ chip.title }}</strong>
<small v-if="chip.subtitle">{{ chip.subtitle }}</small>
</span>
</RouterLink>
</div>
<div class="hero-ask-wrap">
<form class="hero-ask" @submit.prevent="emit('ask')">
<UiIcon name="sparkle" :size="18" class="ask-ico" />
<input
id="home-hero-ask" id="home-hero-ask"
:value="askQuery" :value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa" :placeholder="brand.askPlaceholderFa"
:disabled="asking" :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> <kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</kbd>
<button type="submit" class="ask-send" aria-label="ارسال" :disabled="asking"> <a-button
<UiIcon name="send" :size="15" /> type="primary"
</button> shape="round"
</form> class="home-hero__ask-btn"
<p v-if="asking" class="hero-ask-status">{{ statusText || 'در حال پاسخ…' }}</p> :disabled="asking"
<p v-else-if="askError" class="hero-ask-status err">{{ askError }}</p> :loading="asking"
<p v-else-if="askAnswer" class="hero-ask-status">{{ askAnswer }}</p> aria-label="ارسال"
@click="emit('ask')"
>
<span class="home-hero__ask-btn-inner">
<SendOutlined />
<span class="home-hero__ask-label">بپرس</span>
</span>
</a-button>
</div>
</div> </div>
</div> </div>
<MentoAiAvatar class="hero-bot" :size="156" /> <div
</header> 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> </template>
<style scoped> <style scoped>
.hero { .home-hero {
position: relative; position: relative;
display: grid; display: block;
/* RTL: body سمت راست، ربات سمت چپ — فضای اضافه برای پاپ‌آپ */ width: 100%;
grid-template-columns: minmax(0, 1fr) 210px; max-width: 100%;
gap: 0.7rem 1rem; min-width: 0;
align-items: start; box-sizing: border-box;
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);
overflow: hidden; overflow: hidden;
background-color: #134e4a; border-radius: 1rem;
background-image: border: 1px solid rgb(16 137 125 / 0.28);
radial-gradient(ellipse 55% 90% at 8% 55%, rgba(103, 232, 249, 0.55) 0%, rgba(45, 212, 191, 0.22) 35%, transparent 68%), box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
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);
} }
.hero-glow { .home-hero__gradient,
position: absolute; .home-hero__pattern,
width: 320px; .home-hero__orb {
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);
pointer-events: none; pointer-events: none;
position: absolute;
} }
.hero-mist { .home-hero__gradient,
position: absolute; .home-hero__pattern {
inset: 0; 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, .home-hero__gradient {
.hero-bot { background:
linear-gradient(
115deg,
#eef8f7 0%,
#d0ebe8 28%,
#c5e6e2 55%,
#eef8f7 78%,
#ffffff 100%
);
}
.home-hero__pattern {
opacity: 0.45;
background-image:
radial-gradient(circle at 1px 1px, rgba(16, 137, 125, 0.28) 1px, transparent 0),
linear-gradient(
135deg,
rgba(16, 137, 125, 0.08) 25%,
transparent 25%,
transparent 50%,
rgba(16, 137, 125, 0.08) 50%,
rgba(16, 137, 125, 0.08) 75%,
transparent 75%,
transparent
);
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(22 193 168 / 0.22);
}
.home-hero__orb--end {
inset-inline-start: -4rem;
bottom: -5rem;
width: 12rem;
height: 12rem;
background: rgb(16 137 125 / 0.16);
}
.home-hero__row {
position: relative; position: relative;
z-index: 1; z-index: 1;
}
.hero-body {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem; align-items: center;
min-width: 0; gap: 1rem;
width: 100%;
max-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; display: flex;
align-items: center; align-items: center;
gap: 0.9rem; gap: 0.875rem;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 22rem;
} }
.greet-text { .home-hero__identity {
min-width: 0;
}
.hero-avatar {
position: relative; position: relative;
width: 72px;
height: 72px;
border-radius: 999px;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12); width: 3rem;
height: 3rem;
} }
.hero-avatar img { .home-hero__photo,
.home-hero__monogram {
display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 0.85rem;
object-fit: cover; 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.1rem;
font-weight: 800;
color: #eef8f7;
background: linear-gradient(160deg, #16c1a8 0%, #10897d 100%);
letter-spacing: -0.02em;
user-select: none;
}
.home-hero__online {
position: absolute; position: absolute;
bottom: 3px; inset-inline-start: 0;
inset-inline-start: 3px; bottom: 0;
width: 14px; width: 0.7rem;
height: 14px; height: 0.7rem;
border-radius: 999px; border-radius: 999px;
background: #22c55e; background: #10897d;
border: 2px solid #fff; border: 2px solid #fff;
z-index: 1;
} }
.greet-text h1 { .home-hero__copy {
min-width: 0;
}
.home-hero__brand {
margin: 0; margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem); font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: 0.02em;
color: #fff; color: rgb(4 120 87 / 0.8);
} }
.greet-text p { .home-hero__title {
margin: 0.25rem 0 0; margin: 0.125rem 0 0 !important;
color: rgba(255, 255, 255, 0.88); color: #1e293b !important;
font-size: 0.9rem; font-size: 1.125rem !important;
font-weight: 500; font-weight: 800 !important;
} line-height: 1.75rem !important;
white-space: nowrap;
.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;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.chip-text small { .home-hero__subtitle {
font-size: 0.6rem; display: block !important;
color: #64748b; margin-top: 0.125rem !important;
font-weight: 500; color: #64748b !important;
line-height: 1.25; font-size: 0.875rem !important;
white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.chip-ico { .home-hero__ask {
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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.5rem;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
max-width: 100%; box-sizing: border-box;
padding-block: 0.375rem;
padding-inline: 0.75rem 0.5rem;
background: #fff; background: #fff;
color: #0f172a; border: 1px solid rgb(255 255 255 / 0.8);
border-radius: 999px; border-radius: 1rem;
padding: 0.45rem 0.45rem 0.45rem 0.9rem; box-shadow:
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); 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 { .home-hero__ask:focus-within {
color: #14b8a6; border-color: #16c1a8;
flex-shrink: 0; box-shadow:
0 0 0 2px rgb(16 137 125 / 0.18),
0 4px 12px rgb(10 92 84 / 0.06);
} }
.hero-ask input { .home-hero__ask-input {
border: 0; flex: 1 1 auto;
background: transparent;
flex: 1;
outline: none;
font-size: 0.9rem;
min-width: 0; min-width: 0;
background: transparent !important;
} }
.hero-ask input::placeholder { .home-hero__ask-kbd {
color: #94a3b8; display: none;
} flex-shrink: 0;
align-items: center;
.hero-ask kbd { padding: 0.25rem 0.5rem;
font-size: 0.68rem; border-radius: 0.5rem;
color: #94a3b8;
background: #f1f5f9;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: var(--radius); background: #f8fafc;
padding: 0.14rem 0.38rem; color: #94a3b8;
flex-shrink: 0; font-size: 11px;
font-family: ui-sans-serif, system-ui, sans-serif;
} }
.ask-send { .home-hero__ask-btn {
width: 34px; display: inline-flex !important;
height: 34px; align-items: center;
border: 0; justify-content: center;
border-radius: 999px;
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
} }
.ask-send:disabled { .home-hero__ask-btn-inner {
opacity: 0.55; display: inline-flex;
cursor: wait; align-items: center;
justify-content: center;
gap: 0.35rem;
line-height: 1;
} }
.hero-ask-status { .home-hero__ask-label {
margin: 0; display: none;
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 { @media (min-width: 640px) {
background: rgba(185, 28, 28, 0.28); .home-hero__ask-kbd {
} display: inline-flex;
.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;
} }
.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); 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 { .home-hero__greet {
display: none; max-width: none;
}
.hero-ask kbd {
display: none;
} }
} }
</style> </style>
+78 -106
View File
@@ -1,125 +1,97 @@
<script setup lang="ts"> <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 type { HomeDashboardKpiDto } from '@mento/shared';
import { currencySuffix } from '@/lib/money';
defineProps<{ const props = defineProps<{
kpis: HomeDashboardKpiDto[]; kpis: HomeDashboardKpiDto[];
currency: string; currency: string;
formatValue: (key: string, value: number) => string; formatValue: (key: string, value: number) => string;
formatChange: (value: number | null) => string; formatChange: (value: number | null) => string;
}>(); }>();
const icons: Record<string, 'calendar' | 'users' | 'briefcase' | 'check'> = { function salesAmount(value: number) {
sales: 'calendar', return new Intl.NumberFormat('fa-IR').format(value);
customers: 'users', }
'open-deals': 'briefcase',
followups: 'check', 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> = { function pillClass(kpi: HomeDashboardKpiDto) {
sales: '#14b8a6', if (kpi.key === 'overdue') return 'bg-rose-100/70 text-rose-600';
customers: '#38bdf8', if (kpi.changePercent == null) return 'text-slate-400';
'open-deals': '#fb923c', return kpi.changePercent < 0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600';
followups: '#a78bfa', }
};
</script> </script>
<template> <template>
<div class="kpi-row"> <section class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<article v-for="kpi in kpis" :key="kpi.key" class="kpi-card"> <div
<div class="kpi-icon" :style="{ color: iconTones[kpi.key] ?? '#14b8a6' }"> v-for="kpi in kpis"
<UiIcon :name="icons[kpi.key] ?? 'chart'" :size="20" /> :key="kpi.key"
</div> class="rounded-2xl p-4 border shadow-sm flex flex-col justify-between transition duration-200 hover:-translate-y-0.5"
<div class="kpi-text"> :class="
<p class="kpi-label">{{ kpi.label }}</p> kpi.key === 'overdue'
<strong class="kpi-value">{{ formatValue(kpi.key, kpi.value) }}</strong> ? '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 <span
v-if="kpi.changePercent != null" class="w-8 h-8 rounded-xl flex items-center justify-center"
class="kpi-change" :class="kpi.key === 'overdue' ? 'bg-rose-100/80 text-rose-600' : 'bg-emerald-50 text-emerald-600'"
:class="{ down: kpi.changePercent < 0 }"
> >
<span class="arrow">{{ kpi.changePercent >= 0 ? '↑' : '↓' }}</span> <component :is="icons[kpi.key] ?? FundOutlined" />
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
</span> </span>
</div> </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> </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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import {
FundOutlined,
LeftOutlined,
PlusOutlined,
} from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import type { PipelineBoardDto } from '@mento/shared'; import type { PipelineBoardDto } from '@mento/shared';
import {
PIPELINE_ACCENT,
type PipelineAccent,
} from '@/lib/pipeline-board';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue'; import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
const props = defineProps<{ const props = defineProps<{
@@ -10,41 +19,100 @@ const props = defineProps<{
formatMoney: (amount: number, currency: string) => string; formatMoney: (amount: number, currency: string) => string;
}>(); }>();
type Card = { id: string; name: string; amount: string; when: string }; const ACCENT_CYCLE: PipelineAccent[] = ['green', 'blue', 'orange'];
type Col = { id: string; name: string; count: number; cards: Card[] };
function relativeWhen(iso: string | null | undefined) { type Card = {
if (!iso) return ''; 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 diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000); const days = Math.floor(diff / 86400000);
if (days <= 0) return 'امروز'; if (days <= 0) return { label: 'امروز', isToday: true };
if (days === 1) return 'دیروز'; if (days === 1) return { label: 'دیروز', isToday: false };
return `${toFaDigits(days)} روز پیش`; 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 cols = computed<Col[]>(() => {
const stages = props.board?.stages ?? []; const stages = props.board?.stages ?? [];
const hasDeals = stages.some((s) => s.deals.length); const hasDeals = stages.some((s) => s.deals.length);
if (!hasDeals) return []; if (!hasDeals) return [];
return stages.slice(0, 3).map((stage) => ({
id: stage.id, return stages.slice(0, 3).map((stage, index) => {
name: stage.name, const accent = ACCENT_CYCLE[index % ACCENT_CYCLE.length]!;
count: stage.deals.length, const currency = stage.deals[0]?.currency ?? 'IRR';
cards: stage.deals.slice(0, 2).map((deal) => ({ return {
id: deal.id, id: stage.id,
name: deal.company?.name || deal.person?.fullName || deal.title, name: stage.name,
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '', count: stage.deals.length,
when: relativeWhen(deal.updatedAt || deal.createdAt), 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> </script>
<template> <template>
<article class="panel"> <section class="bg-white rounded-2xl border border-slate-200/80 p-5 sm:p-6 shadow-sm">
<header class="panel-head"> <header class="flex items-center justify-between gap-3 mb-5">
<h2>جریان فروش</h2> <div class="flex items-center gap-2.5 min-w-0">
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink> <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> </header>
<HomePanelEmpty <HomePanelEmpty
@@ -54,132 +122,85 @@ const cols = computed<Col[]>(() => {
action-label="معامله جدید" action-label="معامله جدید"
:action-to="{ name: 'deal-new' }" :action-to="{ name: 'deal-new' }"
/> />
<div v-else class="mini-pipeline">
<div v-for="col in cols" :key="col.id" class="pipe-col"> <div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
<header> <article
<strong>{{ col.name }} ({{ toFaDigits(col.count) }})</strong> v-for="col in cols"
</header> :key="col.id"
<div class="pipe-cards"> class="rounded-2xl border border-slate-200/70 bg-slate-50/90 p-4 flex flex-col gap-3 min-w-0"
<RouterLink >
v-for="card in col.cards" <div class="flex items-start justify-between gap-2 pb-2 border-b border-slate-200/80">
:key="card.id" <div class="min-w-0">
class="pipe-card" <div class="flex items-center gap-2 min-w-0">
:to="{ name: 'deal-detail', params: { id: card.id } }" <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> {{ toFaDigits(col.count) }}
<div> </span>
<strong>{{ card.name }}</strong> </div>
<span v-if="card.amount">{{ card.amount }}</span>
<em>{{ card.when }}</em> <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> </div>
</RouterLink> </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>
</div> </article>
</div> </div>
</article> </section>
</template> </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"> <script setup lang="ts">
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router'; import type { RouteLocationRaw } from 'vue-router';
import { Button as AButton, Empty as AEmpty, Typography } from 'ant-design-vue';
defineProps<{ defineProps<{
title: string; title: string;
@@ -8,70 +9,19 @@ defineProps<{
actionLabel: string; actionLabel: string;
actionTo: RouteLocationRaw; actionTo: RouteLocationRaw;
}>(); }>();
const { Text: ATypographyText } = Typography;
</script> </script>
<template> <template>
<div class="empty-card"> <a-empty :description="null">
<div class="art" aria-hidden="true"> <template #description>
<svg viewBox="0 0 168 148" fill="none"> <a-typography-text strong>{{ title }}</a-typography-text>
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" /> <br />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" /> <a-typography-text type="secondary">{{ description }}</a-typography-text>
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" /> </template>
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" /> <RouterLink :to="actionTo">
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" /> <a-button type="primary">{{ actionLabel }}</a-button>
<circle cx="118" cy="112" r="20" fill="#14b8a6" /> </RouterLink>
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" /> </a-empty>
</svg>
</div>
<strong>{{ title }}</strong>
<p>{{ description }}</p>
<RouterLink class="cta" :to="actionTo">{{ actionLabel }}</RouterLink>
</div>
</template> </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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, type Component } from 'vue';
import { RouterLink } from 'vue-router'; 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 HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
import type { ActivityDto } from '@mento/shared'; import type { ActivityDto } from '@mento/shared';
@@ -10,16 +22,21 @@ const props = defineProps<{
formatWhen: (value: string | null) => string; formatWhen: (value: string | null) => string;
}>(); }>();
const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
const ATimelineItem = ATimeline.Item;
type Row = { type Row = {
id: string; id: string;
title: string; title: string;
when: string; when: string;
typeLabel: string;
type: string; type: string;
tone: 'done' | 'pending' | 'held' | 'late'; tone: 'done' | 'pending' | 'held' | 'late';
label: string; label: string;
}; };
const typeLabel: Record<string, string> = { const typeLabelMap: Record<string, string> = {
call: 'تماس', call: 'تماس',
meeting: 'جلسه', meeting: 'جلسه',
task: 'وظیفه', task: 'وظیفه',
@@ -31,23 +48,50 @@ function activityStatus(act: ActivityDto) {
if (act.dueAt && new Date(act.dueAt) < new Date()) { if (act.dueAt && new Date(act.dueAt) < new Date()) {
return { label: 'عقب‌افتاده', tone: 'late' as const }; return { label: 'عقب‌افتاده', tone: 'late' as const };
} }
return { label: 'در انتظار پاسخ', tone: 'pending' as const }; return { label: 'در انتظار', tone: 'pending' as const };
} }
function activityIcon(type: string) { function activityIcon(type: string): Component {
if (type === 'call') return 'phone' as const; if (type === 'call') return PhoneOutlined;
if (type === 'meeting') return 'calendar' as const; if (type === 'meeting') return CalendarOutlined;
if (type === 'task') return 'check' as const; if (type === 'task') return CheckOutlined;
return 'mail' as const; 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[]>(() => const rows = computed<Row[]>(() =>
props.activities.slice(0, 3).map((act) => { props.activities.slice(0, 5).map((act) => {
const st = activityStatus(act); const st = activityStatus(act);
return { return {
id: act.id, id: act.id,
title: act.title, 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, type: act.type,
tone: st.tone, tone: st.tone,
label: st.label, label: st.label,
@@ -57,11 +101,16 @@ const rows = computed<Row[]>(() =>
</script> </script>
<template> <template>
<article class="panel"> <section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
<header class="panel-head"> <div class="flex items-center justify-between mb-4">
<h2>فعالیت‌های اخیر</h2> <div class="flex items-center gap-2">
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink> <h3 class="font-bold text-slate-800 text-base">فعالیت‌های اخیر</h3>
</header> <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 <HomePanelEmpty
v-if="!rows.length" v-if="!rows.length"
@@ -70,117 +119,44 @@ const rows = computed<Row[]>(() =>
action-label="فعالیت جدید" action-label="فعالیت جدید"
:action-to="{ name: 'activity-new' }" :action-to="{ name: 'activity-new' }"
/> />
<ul v-else class="activity-list"> <a-timeline v-else>
<li v-for="act in rows" :key="act.id"> <a-timeline-item v-for="item in rows" :key="item.id" :color="timelineColor(item.tone)">
<span class="act-icon"> <a-flex
<UiIcon :name="activityIcon(act.type)" :size="16" /> align="flex-start"
</span> justify="space-between"
<div class="act-body"> :gap="token.marginSM"
<strong>{{ act.title }}</strong> :style="{
<span>{{ act.when }}</span> padding: `${token.paddingXS}px ${token.paddingSM}px`,
</div> borderRadius: `${token.borderRadius}px`,
<span class="status-pill" :class="act.tone">{{ act.label }}</span> background: token.colorFillAlter,
</li> minWidth: 0,
</ul> }"
</article> >
<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> </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"> <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 type { HomeDashboardTrendPointDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue'; import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
@@ -10,26 +11,57 @@ const props = defineProps<{
formatAmount: (value: number) => string; formatAmount: (value: number) => string;
}>(); }>();
const trend = computed(() => props.trend); type PeriodMonths = 1 | 3 | 6 | 12;
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
const change = computed(() => props.changePercent); 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>(() => ({ const option = computed<UiChartOption>(() => ({
animationDuration: 400, animationDuration: 450,
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true }, grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' }, textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => { formatter: (params) => {
const row = Array.isArray(params) ? params[0] : 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 ''; if (!point) return '';
return `${point.monthLabel}<br/>${props.formatAmount(point.amount)}`; return `${point.monthLabel}<br/><strong>${props.formatAmount(point.amount)}</strong>`;
}, },
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: trend.value.map((p) => p.monthLabel), data: filteredTrend.value.map((p) => p.monthLabel),
boundaryGap: false, boundaryGap: false,
axisLine: { show: false }, axisLine: { show: false },
axisTick: { show: false }, axisTick: { show: false },
@@ -43,18 +75,19 @@ const option = computed<UiChartOption>(() => ({
type: 'value', type: 'value',
min: 0, min: 0,
...(hasTrend.value ? {} : { max: 1 }), ...(hasTrend.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#eef2f7' } }, splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false }, axisLabel: { show: false },
}, },
series: [ series: [
{ {
type: 'line', type: 'line',
data: trend.value.map((p) => p.amount), name: 'فروش',
smooth: 0.35, data: filteredTrend.value.map((p) => p.amount),
smooth: 0.4,
symbol: 'circle', symbol: 'circle',
symbolSize: 8, symbolSize: period.value === 1 ? 10 : 8,
itemStyle: { color: '#14b8a6', borderColor: '#fff', borderWidth: 2 }, itemStyle: { color: '#10b981', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#14b8a6', width: 3 }, lineStyle: { color: '#10b981', width: 3 },
areaStyle: { areaStyle: {
color: { color: {
type: 'linear', type: 'linear',
@@ -63,8 +96,8 @@ const option = computed<UiChartOption>(() => ({
x2: 0, x2: 0,
y2: 1, y2: 1,
colorStops: [ colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.28)' }, { offset: 0, color: 'rgba(16, 185, 129, 0.28)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0)' }, { offset: 1, color: 'rgba(16, 185, 129, 0)' },
], ],
}, },
}, },
@@ -74,56 +107,22 @@ const option = computed<UiChartOption>(() => ({
</script> </script>
<template> <template>
<article class="panel"> <section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<header class="panel-head"> <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2>روند فروش</h2> <div>
<span v-if="hasTrend && change != null" class="mini-badge" :class="{ down: change < 0 }"> <h3 class="font-bold text-slate-800 text-base">روند فروش</h3>
{{ formatChange(change) }} نسبت به ماه قبل <span
</span> v-if="hasTrend && periodChange != null"
</header> class="mt-1 inline-flex text-xs font-bold px-2 py-0.5 rounded-md"
<UiChart :option="option" height="210px" /> :class="periodChange < 0 ? 'text-rose-600 bg-rose-50' : 'text-emerald-600 bg-emerald-50'"
</article> >
{{ 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> </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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSourceDto } from '@mento/shared'; 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<{ const props = defineProps<{
sources: HomeDashboardSourceDto[]; sources: HomeDashboardSourceDto[];
}>(); }>();
const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8'];
const rows = computed(() => props.sources); const rows = computed(() => props.sources);
const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0)); const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0));
const option = computed<UiChartOption>(() => ({ const option = computed<UiChartOption>(() => ({
animationDuration: 400, animationDuration: 400,
color: colors, color: CHART_PALETTE,
tooltip: { tooltip: {
show: total.value > 0, show: total.value > 0,
trigger: 'item', trigger: 'item',
@@ -28,147 +27,51 @@ const option = computed<UiChartOption>(() => ({
series: [ series: [
{ {
type: 'pie', type: 'pie',
radius: ['58%', '82%'], radius: ['55%', '78%'],
center: ['50%', '50%'], center: ['50%', '50%'],
silent: total.value === 0, silent: total.value === 0,
avoidLabelOverlap: true, avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false }, label: { show: false },
labelLine: { show: false }, labelLine: { show: false },
data: data:
total.value > 0 total.value > 0
? rows.value.map((s) => ({ name: s.label, value: s.count })) ? rows.value.map((s) => ({ name: s.label, value: s.count }))
: [{ name: '', value: 1, itemStyle: { color: '#e2e8f0' } }], : [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
}, },
], ],
})); }));
</script> </script>
<template> <template>
<article class="panel"> <section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<header class="panel-head"> <div class="flex items-center justify-between mb-4">
<h2>منابع مشتریان</h2> <h3 class="font-bold text-slate-800 text-base">منابع ورود مشتریان</h3>
</header> <span v-if="total" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
</div>
<div class="donut-wrap"> <div class="flex flex-col sm:flex-row items-center gap-4">
<div class="donut-chart-box"> <div class="w-full sm:w-[48%] sm:max-w-[220px] shrink-0">
<UiChart :option="option" height="132px" /> <div class="relative w-full aspect-square">
<div class="donut-center"> <UiChart :option="option" height="100%" />
<strong>{{ toFaDigits(total) }}</strong> <div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span>مشتری</span> <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>
</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> </div>
</article> </section>
</template> </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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router'; 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 { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardTopCustomerDto } from '@mento/shared'; import type { HomeDashboardTopCustomerDto } from '@mento/shared';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue'; import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
@@ -9,25 +18,61 @@ const props = defineProps<{
customers: HomeDashboardTopCustomerDto[]; customers: HomeDashboardTopCustomerDto[];
}>(); }>();
const rows = computed(() => props.customers.slice(0, 3)); const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
function trophy(rank: number) { const rows = computed(() => props.customers.slice(0, 5));
if (rank === 1) return '🥇'; const maxDeals = computed(() => Math.max(...rows.value.map((c) => c.dealCount), 1));
if (rank === 2) return '🥈';
if (rank === 3) return '🥉'; function rankStyle(rank: number) {
return `#${toFaDigits(rank)}`; 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> </script>
<template> <template>
<article class="panel"> <section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
<header class="panel-head"> <div class="flex items-center justify-between mb-2">
<div> <div class="flex items-center gap-2">
<h2>مشتریان برتر</h2> <TrophyOutlined class="text-amber-500" />
<p class="hint">بر اساس معاملات برندهٔ ۵ ماه اخیر</p> <h3 class="font-bold text-slate-800 text-base">مشتریان برتر</h3>
</div> </div>
<RouterLink :to="{ name: 'contacts' }">مشاهده همه</RouterLink> <RouterLink :to="{ name: 'contacts' }" class="text-sm font-bold text-emerald-600">
</header> مشاهده همه
</RouterLink>
</div>
<a-typography-text type="secondary" style="display: block; margin-bottom: 12px">
بر اساس معاملات برندهٔ اخیر
</a-typography-text>
<HomePanelEmpty <HomePanelEmpty
v-if="!rows.length" v-if="!rows.length"
@@ -36,99 +81,51 @@ function trophy(rank: number) {
action-label="مشتری جدید" action-label="مشتری جدید"
:action-to="{ name: 'person-new' }" :action-to="{ name: 'person-new' }"
/> />
<ul v-else class="top-list"> <a-flex v-else vertical :gap="token.marginSM">
<li v-for="row in rows" :key="row.id"> <a-flex
<span class="cust-avatar">{{ row.name.slice(0, 1) }}</span> v-for="item in rows"
<div> :key="item.id"
<strong>{{ row.name }}</strong> align="center"
<span>{{ toFaDigits(row.dealCount) }} معامله</span> :gap="token.marginSM"
</div> class="rounded-xl border"
<span class="rank">{{ trophy(row.rank) }}</span> :class="item.rank === 1 ? 'bg-amber-50/50 border-amber-200/80' : 'bg-slate-50 border-slate-200/70'"
</li> :style="{ padding: `${token.paddingSM}px`, minWidth: 0 }"
</ul> >
</article> <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> </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"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared'; 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 UserAvatar from '@/components/UserAvatar.vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification'; import { apiError, faNum } from '@/lib/gamification';
import crownImg from '@/assets/club/crown.png'; import crownImg from '@/assets/club/crown.png';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [ const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [
{ value: 'today', label: 'امروز' }, { value: 'today', label: 'امروز' },
{ value: 'month', label: 'این ماه' }, { value: 'month', label: 'این ماه' },
@@ -40,316 +58,342 @@ onMounted(() => {
}); });
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load)); onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0)); const allItems = computed(() => board.value?.items ?? []);
/** ترتیب نمایش از راست: سوم، اول، دوم */
const podium = computed(() => { interface PodiumSlot {
const [a, b, c] = top3.value; rank: number;
return [c, a, b].filter((x): x is GamificationLeaderEntryDto => !!x); entry: GamificationLeaderEntryDto | null;
}); stepHeight: number;
const rest = computed(() => { avatarSize: number;
const items = board.value?.items ?? []; label: string;
return top3.value.length ? items.slice(top3.value.length) : items; gradient: string;
}); numberColor: string;
function chgClass(change: number | null) { ringColor: string;
return change && change > 0 ? 'up' : change && change < 0 ? 'down' : ''; 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 meOutside = computed(() => {
const b = board.value; const b = board.value;
if (!b?.me) return null; 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> </script>
<template> <template>
<section class="board"> <a-card title="جدول قهرمانان و رتبه‌بندی">
<div class="periods" role="tablist" aria-label="بازهٔ جدول امتیازات"> <a-flex vertical :gap="16">
<button <a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
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>
<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 }"> <a-spin :spinning="loading">
<div <a-flex vertical :gap="16">
v-for="e in podium" <!-- بخش سکوی قهرمانی ۳ پله با ارتفاع‌های نامساوی (Podium) -->
:key="e.userId" <div
class="t-card" v-if="podiumSlots.length"
:class="[`r${e.rank}`, { me: e.isMe }]" :style="{
> position: 'relative',
<img v-if="e.rank === 1" :src="crownImg" alt="" class="t-crown" /> borderRadius: `${token.borderRadiusLG}px`,
<span class="t-ava"> background: 'radial-gradient(ellipse at top, rgba(16, 185, 129, 0.06), transparent 70%), linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%)',
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="e.rank === 1 ? 50 : 46" /> border: `1px solid ${token.colorBorderSecondary}`,
</span> padding: '24px 12px 0 12px',
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong> overflow: 'hidden',
<small>{{ faNum(e.score) }} امتیاز</small> }"
</div> >
</div> <!-- ردیف ۳ پله قهرمانی هم‌تراز در کف سکو -->
<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 }"> <template v-if="slot.entry">
<span class="rank">{{ toFaDigits(e.rank) }}</span> <span
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="28" /> :style="{
<b class="who">{{ e.isMe ? 'شما' : e.fullName }}</b> display: 'inline-flex',
<span class="score">{{ faNum(e.score) }}</span> padding: '2px',
<span class="chg" :class="chgClass(e.change)"> borderRadius: '999px',
<template v-if="e.change">{{ e.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(e.change)) }}</template> background: '#ffffff',
</span> boxShadow: `0 0 0 3px ${slot.ringColor}, 0 4px 10px rgba(0, 0, 0, 0.1)`,
</li> }"
<template v-if="meOutside"> >
<li class="sep" aria-hidden="true">⋯</li> <UserAvatar
<li class="me"> :user-id="slot.entry.userId"
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span> :has-avatar="slot.entry.hasAvatar"
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="28" /> :name="slot.entry.fullName"
<b class="who">شما</b> :size="slot.avatarSize"
<span class="score">{{ faNum(meOutside.score) }}</span> />
<span class="chg" :class="chgClass(meOutside.change)"> </span>
<template v-if="meOutside.change">
{{ meOutside.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(meOutside.change)) }} <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> </template>
</span> </a-list>
</li>
</template> <a-empty
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li> v-if="board && !board.items.length"
</ol> description="هنوز کسی در این بازه امتیاز نگرفته است."
</section> />
</a-flex>
</a-spin>
</a-flex>
</a-card>
</template> </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> <style scoped>
.hub { .hub {
width: min(28rem, calc(100vw - var(--hub-offset, 16rem) - 1.5rem)); 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; overflow: hidden;
padding: 0.9rem 0.9rem 0.8rem; padding: 0.9rem 0.9rem 0.8rem;
display: grid; display: grid;
align-content: start; align-content: start;
gap: 0.7rem; gap: 0.7rem;
border-radius: 28px; border-radius: 16px;
background: #fff; background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
} }
@@ -204,7 +204,7 @@ onUnmounted(() => {
gap: 0.28rem; gap: 0.28rem;
padding: 0.65rem 0.7rem 0.55rem; padding: 0.65rem 0.7rem 0.55rem;
border: 0; border: 0;
border-radius: 16px; border-radius: 12px;
background: #f7f8fa; background: #f7f8fa;
cursor: pointer; cursor: pointer;
text-align: start; text-align: start;
@@ -267,7 +267,7 @@ onUnmounted(() => {
min-height: 2.4rem; min-height: 2.4rem;
padding: 0.45rem 0.7rem; padding: 0.45rem 0.7rem;
border: 0; border: 0;
border-radius: 999px; border-radius: 10px;
background: #ecfdf8; background: #ecfdf8;
color: #0f766e; color: #0f766e;
font: inherit; font: inherit;
@@ -316,7 +316,7 @@ onUnmounted(() => {
max-height: min(86dvh, calc(100dvh - 3.5rem)); max-height: min(86dvh, calc(100dvh - 3.5rem));
overflow: auto; overflow: auto;
padding: 0.85rem 0.9rem 0.9rem; 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); box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
} }
@@ -1,9 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink } from 'vue-router'; import { useRouter } from 'vue-router';
import type { RouteLocationRaw } 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<{ defineProps<{
title?: string; title?: string;
description?: string; description?: string;
@@ -20,15 +32,24 @@ withDefaults(
secondaryIcon: 'calendar-plus', 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> </script>
<template> <template>
<div class="empty-state"> <a-flex vertical align="center" :gap="16" :style="{ width: '100%', textAlign: 'center' }">
<div class="art" aria-hidden="true"> <div aria-hidden="true" :style="{ width: '168px' }">
<span class="spark s1">✦</span> <span aria-hidden="true">✦</span>
<span class="spark s2">✦</span> <svg viewBox="0 0 168 132" fill="none" :style="{ width: '168px', height: 'auto', display: 'block' }">
<span class="spark s3">✦</span>
<svg viewBox="0 0 168 132" fill="none">
<path <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" 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" fill="#D9E6E8"
@@ -52,147 +73,24 @@ withDefaults(
</svg> </svg>
</div> </div>
<h3>{{ title }}</h3> <a-typography-title :level="4" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<p>{{ description }}</p> <a-typography-text type="secondary">{{ description }}</a-typography-text>
<div class="actions"> <a-space direction="vertical" style="width: min(100%, 304px)">
<RouterLink class="btn-primary" :to="primaryTo"> <a-button type="primary" block @click="go(primaryTo)">
<span class="plus">+</span> <template #icon><PlusOutlined /></template>
{{ primaryLabel }} {{ primaryLabel }}
</RouterLink> </a-button>
<RouterLink v-if="secondaryLabel && secondaryTo" class="btn-secondary" :to="secondaryTo"> <a-button
<UiIcon :name="secondaryIcon" :size="18" /> v-if="secondaryLabel && secondaryTo"
block
@click="go(secondaryTo)"
>
<template #icon>
<component :is="secondaryIconComp()" />
</template>
{{ secondaryLabel }} {{ secondaryLabel }}
</RouterLink> </a-button>
</div> </a-space>
</div> </a-flex>
</template> </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"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } 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 = { export type ProfileMoreItem = {
key: string; key: string;
@@ -15,207 +20,40 @@ const open = defineModel<boolean>({ default: false });
defineProps<{ items: ProfileMoreItem[] }>(); defineProps<{ items: ProfileMoreItem[] }>();
const emit = defineEmits<{ select: [key: string] }>(); const emit = defineEmits<{ select: [key: string] }>();
const triggerRef = ref<HTMLButtonElement | null>(null); function onSelect(key: string) {
const panelRef = ref<HTMLElement | null>(null); emit('select', key);
const top = ref(0);
const left = ref(0);
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 220;
const height = panel?.offsetHeight || 0;
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
let nextLeft = rtl ? rect.right - width : rect.left;
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
watch(open, async (value) => {
if (!value) {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
return;
}
await nextTick();
place();
await nextTick();
place();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false; open.value = false;
} }
function scheduleClose() { function onLinkClick() {
window.setTimeout(() => { open.value = false;
close();
}, 0);
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
close();
});
} }
</script> </script>
<template> <template>
<div class="wrap" @click.stop> <a-dropdown v-model:open="open" :trigger="['click']" placement="bottomLeft">
<button <a-button aria-label="منوی بیشتر" @click.stop>
ref="triggerRef" <template #icon><MoreOutlined /></template>
class="icon-btn" </a-button>
type="button" <template #overlay>
aria-label="منوی بیشتر" <a-menu @click.stop>
:aria-expanded="open" <a-menu-item
@click.stop="toggle" v-for="item in items"
> :key="item.key"
<UiIcon name="dots-v" :size="18" /> :danger="item.danger"
</button> @click="item.to ? onLinkClick() : onSelect(item.key)"
<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
> >
<li v-for="item in items" :key="item.key" role="none"> <RouterLink
<RouterLink v-if="item.to"
v-if="item.to" :to="item.to"
class="item" :style="{ color: 'inherit', display: 'block' }"
:class="{ danger: item.danger }" @click="onLinkClick"
role="menuitem" >
:to="item.to" {{ item.label }}
@click.stop="scheduleClose" </RouterLink>
> <span v-else>{{ item.label }}</span>
{{ item.label }} </a-menu-item>
</RouterLink> </a-menu>
<button </template>
v-else </a-dropdown>
class="item"
:class="{ danger: item.danger }"
type="button"
role="menuitem"
@click.stop.prevent="onSelect(item.key)"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</Teleport>
</div>
</template> </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"> <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 { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board'; import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
@@ -6,7 +6,7 @@ import {
DeleteOutlined, DeleteOutlined,
FlagFilled, FlagFilled,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Avatar as AAvatar, Avatar as AAvatar,
Button as AButton, Button as AButton,
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref } from 'vue'; import { nextTick, ref } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue'; import { PlusOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue'; import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Dropdown as ADropdown, Dropdown as ADropdown,
@@ -5,7 +5,7 @@ import {
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Avatar as AAvatar, Avatar as AAvatar,
Button as AButton, Button as AButton,
@@ -9,8 +9,31 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiField, UiSelect } from '@/components/ui'; import {
import UiIcon from '@/components/UiIcon.vue'; 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'; type Step = 'target' | 'map' | 'review';
@@ -31,10 +54,23 @@ const stageMap = ref<Record<string, string>>({});
const busy = ref(false); const busy = ref(false);
const error = ref(''); const error = ref('');
const stepIndex = computed(() => {
if (step.value === 'target') return 0;
if (step.value === 'map') return 1;
return 2;
});
const selectedTarget = computed( const selectedTarget = computed(
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null, () => 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 canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
const canContinueMap = computed(() => { const canContinueMap = computed(() => {
const current = preview.value; 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'>) { function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
if (stage.isWon) return 'برد'; if (stage.isWon) return 'برد';
if (stage.isLost) return 'باخت'; if (stage.isLost) return 'باخت';
@@ -171,298 +214,181 @@ watch(targetId, () => {
</script> </script>
<template> <template>
<div class="merge-wizard"> <a-flex vertical :gap="16">
<ol class="steps" aria-label="مراحل ادغام کاریز"> <a-steps :current="stepIndex">
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li> <a-step title="مقصد" />
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li> <a-step title="نگاشت" />
<li :class="{ active: step === 'review' }">۳. بازبینی</li> <a-step title="بازبینی" />
</ol> </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"> <a-form layout="vertical">
<section v-if="step === 'target'" class="step-panel"> <a-form-item label="کاریز مقصد" required>
<div class="intro"> <a-select
<UiIcon name="layers" :size="20" /> v-model:value="targetId"
<div> :options="targetOptions"
<h3>کاریز مقصد را انتخاب کنید</h3> placeholder="انتخاب کنید"
<p> style="width: 100%"
معاملات و فرم‌های سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل می‌شوند. />
</p> </a-form-item>
</div> </a-form>
</div>
<UiField label="کاریز مقصد" required> <a-row :gutter="[16, 16]">
<UiSelect v-model="targetId" required> <a-col :xs="24" :md="8">
<option value="" disabled>انتخاب کنید</option> <a-card :bordered="true">
<option v-for="target in preview.targets" :key="target.id" :value="target.id"> <a-typography-text strong>{{ toFaDigits(preview.dealCount) }}</a-typography-text>
{{ target.name }} <br />
</option> <a-typography-text type="secondary">معامله منتقل می‌شود</a-typography-text>
</UiSelect> </a-card>
</UiField> </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"> <a-alert
<article> v-if="selectedTarget?.isDefault"
<strong>{{ toFaDigits(preview.dealCount) }}</strong> type="info"
<span>معامله منتقل می‌شود</span> show-icon
</article> message="مقصد انتخاب‌شده کاریز پیش‌فرض است."
<article> />
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong> </a-flex>
<span>فرم سرنخ به مقصد وصل می‌شود</span>
</article>
<article v-if="preview.source.isDefault" class="warn">
<strong>هشدار</strong>
<span>کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود.</span>
</article>
</div>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p> <a-flex v-else-if="step === 'map'" vertical :gap="16">
</section> <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"> <a-form layout="vertical">
<div class="intro"> <a-row
<UiIcon name="target" :size="20" /> v-for="sourceStage in preview.stages"
<div> :key="sourceStage.id"
<h3>مرحله‌های مبدا را نگاشت کنید</h3> :gutter="[16, 0]"
<p>هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.</p> align="middle"
</div> >
</div> <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"> <a-flex v-else vertical :gap="16">
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row"> <a-flex :gap="8" align="flex-start">
<div class="stage-info"> <CheckCircleOutlined />
<strong>{{ sourceStage.name }}</strong> <a-flex vertical :gap="4">
<span> <a-typography-title :level="5" :style="{ margin: 0 }">بازبینی نهایی</a-typography-title>
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله <a-typography-paragraph type="secondary" :style="{ margin: 0 }">
</span> «{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
</div> {{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]"> کاریز «{{ preview.source.name }}» حذف می‌شود.
<option value="" disabled>مرحله مقصد</option> </a-typography-paragraph>
<option </a-flex>
v-for="targetStage in compatibleTargets(sourceStage)" </a-flex>
:key="targetStage.id"
:value="targetStage.id"
>
{{ targetStage.name }}
</option>
</UiSelect>
</article>
</div>
</section>
<section v-else class="step-panel"> <a-descriptions :column="1" size="small" bordered>
<div class="intro"> <a-descriptions-item label="مبدا">{{ preview.source.name }}</a-descriptions-item>
<UiIcon name="check" :size="20" /> <a-descriptions-item label="مقصد">{{ selectedTarget?.name }}</a-descriptions-item>
<div> <a-descriptions-item label="روش فرم‌ها">
<h3>بازبینی نهایی</h3> همه فرم‌های سرنخ به مقصد وصل می‌شوند.
<p> </a-descriptions-item>
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود. </a-descriptions>
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود. </a-flex>
کاریز «{{ preview.source.name }}» حذف می‌شود. </template>
</p>
</div>
</div>
<dl class="review-list"> <a-typography-text v-else-if="!busy && !error" type="secondary">
<div> <template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<dt>مبدا</dt> <template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
<dd>{{ preview.source.name }}</dd> </a-typography-text>
</div> </a-spin>
<div>
<dt>مقصد</dt>
<dd>{{ selectedTarget?.name }}</dd>
</div>
<div>
<dt>روش فرم‌ها</dt>
<dd>همه فرم‌های سرنخ به مقصد وصل می‌شوند.</dd>
</div>
</dl>
</section>
</template>
<p v-else-if="!busy && !error" class="muted"> <a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</p>
<p v-if="error" class="error">{{ error }}</p> <a-space wrap>
<a-button :disabled="busy" @click="emit('close')">بستن</a-button>
<div class="actions"> <a-button v-if="step === 'map'" :disabled="busy" @click="step = 'target'">قبلی</a-button>
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton> <a-button v-else-if="step === 'review'" :disabled="busy" @click="step = 'map'">قبلی</a-button>
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'"> <a-button
قبلی v-if="step === 'target'"
</UiButton> type="primary"
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'"> :disabled="!preview || !canContinueTarget"
قبلی @click="nextFromTarget"
</UiButton> >
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
ادامه ادامه
</UiButton> </a-button>
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap"> <a-button
v-else-if="step === 'map'"
type="primary"
:disabled="!canContinueMap"
@click="nextFromMap"
>
بازبینی بازبینی
</UiButton> </a-button>
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge"> <a-button
v-else
type="primary"
danger
:loading="busy"
:disabled="!canContinueMap"
@click="submitMerge"
>
ادغام و حذف مبدا ادغام و حذف مبدا
</UiButton> </a-button>
</div> </a-space>
</div> </a-flex>
</template> </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 { import {
AppstoreOutlined, AppstoreOutlined,
FundOutlined, FundOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Flex as AFlex, Flex as AFlex,
InputNumber as AInputNumber, InputNumber as AInputNumber,
@@ -11,6 +11,20 @@ import {
weekdayIndex, weekdayIndex,
} from '@/lib/routines'; } from '@/lib/routines';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared'; import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import {
Card as ACard,
Col as ACol,
Flex as AFlex,
Progress as AProgress,
Row as ARow,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = defineProps<{ const props = defineProps<{
score: RoutineScoreDto | null; score: RoutineScoreDto | null;
@@ -29,216 +43,98 @@ const rows = computed(() => {
}); });
const strip = computed(() => props.days ?? []); const strip = computed(() => props.days ?? []);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
const STATE_STYLES: Record<string, { bg: string; fg: string }> = {
complete: { bg: 'var(--ant-color-success-bg)', fg: 'var(--ant-color-success)' },
partial: { bg: 'var(--ant-color-warning-bg)', fg: 'var(--ant-color-warning)' },
missed: { bg: 'var(--ant-color-error-bg)', fg: 'var(--ant-color-error)' },
empty: { bg: 'var(--ant-color-fill-secondary)', fg: 'var(--ant-color-text-quaternary)' },
};
function stateStyle(state: string) {
return STATE_STYLES[state] ?? STATE_STYLES.empty;
}
</script> </script>
<template> <template>
<section class="score-card"> <a-card :bordered="false">
<header> <a-flex vertical :gap="16">
<h3>امتیاز روزانه</h3> <a-flex justify="space-between" align="center" :gap="8">
<span class="streak" :title="'روزهای متوالی با انجام همه کارهای اجباری'"> <a-typography-title :level="5" :style="{ margin: 0 }">امتیاز روزانه</a-typography-title>
<strong>{{ toFaDigits(score?.streak ?? 0) }}</strong> <a-typography-text type="secondary" title="روزهای متوالی با انجام همه کارهای اجباری">
روز پیاپی کامل <a-typography-text strong :style="{ color: 'var(--ant-color-primary)' }">
</span> {{ toFaDigits(score?.streak ?? 0) }}
</header> </a-typography-text>
روز پیاپی کامل
</a-typography-text>
</a-flex>
<div class="scores"> <a-row :gutter="[16, 16]">
<div v-for="r in rows" :key="r.label" class="score" :class="`tone-${percentTone(r.value)}`"> <a-col v-for="r in rows" :key="r.label" :span="12">
<span>{{ r.label }}</span> <a-flex justify="space-between" align="baseline" :gap="8">
<strong>{{ formatPercent(r.value) }}</strong> <a-typography-text type="secondary">{{ r.label }}</a-typography-text>
<i class="bar"><b :style="{ width: `${r.value ?? 0}%` }" /></i> <a-typography-text strong>{{ formatPercent(r.value) }}</a-typography-text>
</div> </a-flex>
</div> <a-progress
:percent="r.value ?? 0"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(r.value)]"
/>
</a-col>
</a-row>
<div v-if="strip.length" class="strip-wrap"> <template v-if="strip.length">
<div class="strip" role="list" aria-label="وضعیت روزهای اخیر"> <a-flex :gap="4" role="list" aria-label="وضعیت روزهای اخیر">
<span <a-flex
v-for="d in strip" v-for="d in strip"
:key="d.date" :key="d.date"
role="listitem" vertical
class="cell" align="center"
:class="`s-${d.state}`" :gap="2"
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`" role="listitem"
> :style="{ flex: 1, minWidth: 0 }"
<em>{{ DAY_STATE_SYMBOLS[d.state] }}</em> >
<small>{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}</small> <a-tooltip
</span> :title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
</div> >
<div class="legend"> <a-flex
<span><i class="s-complete">✓</i> کامل</span> justify="center"
<span><i class="s-partial">△</i> ناقص</span> align="center"
<span><i class="s-missed">×</i> از دست رفته</span> :style="{
</div> width: '26px',
</div> height: '26px',
</section> flexShrink: 0,
borderRadius: token.borderRadiusSM,
background: stateStyle(d.state).bg,
color: stateStyle(d.state).fg,
}"
>
<a-typography-text
strong
:style="{ color: stateStyle(d.state).fg, lineHeight: 1 }"
>
{{ DAY_STATE_SYMBOLS[d.state] }}
</a-typography-text>
</a-flex>
</a-tooltip>
<a-typography-text type="secondary">
{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}
</a-typography-text>
</a-flex>
</a-flex>
<a-flex wrap="wrap" :gap="8">
<a-tag color="success" :bordered="false">✓ کامل</a-tag>
<a-tag color="warning" :bordered="false">△ ناقص</a-tag>
<a-tag color="error" :bordered="false">× از دست رفته</a-tag>
</a-flex>
</template>
</a-flex>
</a-card>
</template> </template>
<style scoped>
.score-card {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.streak {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-size: var(--fs-xs);
color: var(--muted);
}
.streak strong {
font-size: var(--fs-lg);
color: var(--accent-deep);
}
.scores {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.score {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 4px 8px;
}
.score span {
font-size: var(--fs-xs);
color: var(--muted);
}
.score strong {
font-size: var(--fs-md);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--line-strong);
transition: width 0.4s ease;
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.strip-wrap {
display: grid;
gap: var(--sp-2);
}
.strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 4px;
}
.cell {
display: grid;
justify-items: center;
gap: 2px;
}
.cell em {
width: 100%;
aspect-ratio: 1;
max-width: 26px;
border-radius: 6px;
display: grid;
place-items: center;
font-style: normal;
font-size: 0.75rem;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted-light);
}
.cell small {
font-size: 0.62rem;
color: var(--muted-light);
}
.s-complete,
.cell.s-complete em {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial,
.cell.s-partial em {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed,
.cell.s-missed em {
background: var(--danger-soft);
color: var(--danger);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.legend i {
width: 16px;
height: 16px;
border-radius: 4px;
display: inline-grid;
place-items: center;
font-style: normal;
font-size: 0.65rem;
font-weight: var(--fw-bold);
}
</style>
@@ -10,7 +10,7 @@ import {
Tag as ATag, Tag as ATag,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue'; import { CloseOutlined } from '@/icons/ant-bridge';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines'; import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
+80 -129
View File
@@ -1,14 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue'; import { computed, type Component } from 'vue';
import {
Avatar as AAvatar,
Card as ACard,
Flex as AFlex,
Statistic as AStatistic,
Typography,
} from 'ant-design-vue';
import {
AimOutlined,
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
FlagOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@/icons/ant-bridge';
type IconName = InstanceType<typeof UiIcon>['$props']['name']; const { Text: ATypographyText } = Typography;
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
label: string; label: string;
value: string; value: string;
hint?: string; hint?: string;
icon?: IconName; icon?: string;
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral'; tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
/** تغییر نسبت به دوره قبل (درصد یا عدد)؛ مثبت = بهتر */ /** تغییر نسبت به دوره قبل (درصد یا عدد)؛ مثبت = بهتر */
delta?: number | null; delta?: number | null;
@@ -17,135 +35,68 @@ withDefaults(
{ hint: '', icon: undefined, tone: 'neutral', delta: null, deltaSuffix: '' }, { hint: '', icon: undefined, tone: 'neutral', delta: null, deltaSuffix: '' },
); );
const ICONS: Record<string, Component> = {
check: CheckOutlined,
clock: ClockCircleOutlined,
warning: WarningOutlined,
target: AimOutlined,
'x-circle': CloseCircleOutlined,
calendar: CalendarOutlined,
flag: FlagOutlined,
users: TeamOutlined,
refresh: SyncOutlined,
};
const TONES: Record<string, { bg: string; fg: string }> = {
accent: { bg: 'var(--ant-color-primary-bg)', fg: 'var(--ant-color-primary)' },
success: { bg: 'var(--ant-color-success-bg)', fg: 'var(--ant-color-success)' },
warning: { bg: 'var(--ant-color-warning-bg)', fg: 'var(--ant-color-warning)' },
danger: { bg: 'var(--ant-color-error-bg)', fg: 'var(--ant-color-error)' },
info: { bg: 'var(--ant-color-info-bg)', fg: 'var(--ant-color-info)' },
neutral: { bg: 'var(--ant-color-fill-secondary)', fg: 'var(--ant-color-text-secondary)' },
};
const iconComp = computed(() => (props.icon ? (ICONS[props.icon] ?? AimOutlined) : null));
const toneStyle = computed(() => TONES[props.tone] ?? TONES.neutral);
function fa(n: number) { function fa(n: number) {
return Math.abs(n).toLocaleString('fa-IR'); return Math.abs(n).toLocaleString('fa-IR');
} }
</script> </script>
<template> <template>
<div class="kpi" :class="`tone-${tone}`"> <a-card :bordered="false" :style="{ height: '100%' }">
<div class="kpi-top"> <a-flex vertical :gap="6">
<span class="kpi-label">{{ label }}</span> <a-flex justify="space-between" align="center" :gap="8">
<span v-if="icon" class="kpi-ico"><UiIcon :name="icon" :size="16" /></span> <a-typography-text type="secondary">{{ label }}</a-typography-text>
</div> <a-avatar
<strong class="kpi-value">{{ value }}</strong> v-if="iconComp"
<div v-if="hint || delta != null" class="kpi-foot"> :style="{ background: toneStyle.bg, color: toneStyle.fg, flexShrink: 0 }"
<span v-if="delta != null && delta !== 0" class="delta" :class="delta > 0 ? 'up' : 'down'"> >
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }} <template #icon><component :is="iconComp" /></template>
</span> </a-avatar>
<span v-else-if="delta === 0" class="delta flat">بدون تغییر</span> </a-flex>
<span v-if="hint" class="kpi-hint">{{ hint }}</span> <a-statistic
</div> :value="value"
</div> :value-style="
tone === 'danger'
? { color: 'var(--ant-color-error)', fontWeight: 700 }
: { fontWeight: 700 }
"
/>
<a-flex v-if="hint || delta != null" align="center" wrap="wrap" :gap="6">
<a-typography-text
v-if="delta != null && delta !== 0"
:type="delta > 0 ? 'success' : 'danger'"
strong
>
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
</a-typography-text>
<a-typography-text v-else-if="delta === 0" type="secondary">
بدون تغییر
</a-typography-text>
<a-typography-text v-if="hint" type="secondary">{{ hint }}</a-typography-text>
</a-flex>
</a-flex>
</a-card>
</template> </template>
<style scoped>
.kpi {
display: grid;
gap: 6px;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
min-width: 0;
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.kpi-label {
font-size: var(--fs-sm);
color: var(--muted);
font-weight: var(--fw-medium);
}
.kpi-ico {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.tone-accent .kpi-ico {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.tone-success .kpi-ico {
background: var(--success-soft);
color: var(--success-deep);
}
.tone-warning .kpi-ico {
background: var(--warning-soft);
color: var(--warning-deep);
}
.tone-danger .kpi-ico {
background: var(--danger-soft);
color: var(--danger);
}
.tone-info .kpi-ico {
background: var(--info-soft);
color: var(--info-deep);
}
.kpi-value {
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.tone-danger .kpi-value {
color: var(--danger);
}
.kpi-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: var(--fs-xs);
}
.kpi-hint {
color: var(--muted);
}
.delta {
font-weight: var(--fw-semibold);
}
.delta.up {
color: var(--success-deep);
}
.delta.down {
color: var(--danger);
}
.delta.flat {
color: var(--muted);
}
@media (max-width: 900px) {
.kpi {
padding: var(--sp-3);
}
.kpi-value {
font-size: var(--fs-xl);
}
}
</style>
@@ -7,11 +7,11 @@ import {
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Tag as ATag, Tag as ATag,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue'; import { CheckOutlined, PlusOutlined } from '@/icons/ant-bridge';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared'; import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue'; import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue'; import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
@@ -132,7 +132,8 @@ function onCreated(t: RoutineTaskDto) {
:style="{ marginTop: '12px' }" :style="{ marginTop: '12px' }"
/> />
<a-spin :spinning="loading && !items.length"> <CrmSkeleton :loading="loading && !items.length">
<CrmSkeletonList :rows="4">
<a-empty <a-empty
v-if="!loading && !items.length" v-if="!loading && !items.length"
:description="emptyTitle" :description="emptyTitle"
@@ -185,7 +186,8 @@ function onCreated(t: RoutineTaskDto) {
/> />
</template> </template>
</a-flex> </a-flex>
</a-spin> </CrmSkeletonList>
</CrmSkeleton>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" /> <QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" /> <RoutineTaskModals :actions="actions" />
@@ -4,6 +4,15 @@ import type { RoutineCalendarDayDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared'; import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import { dateToJalali, toFaDigits } from '@/lib/jalali'; import { dateToJalali, toFaDigits } from '@/lib/jalali';
import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines'; import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines';
import {
Button as AButton,
Flex as AFlex,
Typography,
theme,
} from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -47,6 +56,39 @@ function heatLevel(d?: RoutineCalendarDayDto): number {
return 1; return 1;
} }
const STATE_BG: Record<string, string> = {
complete: 'var(--ant-color-success-bg)',
partial: 'var(--ant-color-warning-bg)',
missed: 'var(--ant-color-error-bg)',
empty: 'var(--ant-color-bg-container)',
future: 'var(--ant-color-fill-quaternary)',
};
const STATE_DOT: Record<string, string> = {
complete: 'var(--ant-color-success)',
partial: 'var(--ant-color-warning)',
missed: 'var(--ant-color-error)',
empty: 'var(--ant-color-text-quaternary)',
future: 'var(--ant-color-text-quaternary)',
};
const HEAT_BG: Record<number, string> = {
0: 'var(--ant-color-fill-quaternary)',
1: 'var(--ant-color-info-bg)',
2: 'var(--ant-color-primary-bg)',
3: 'var(--ant-color-success-bg)',
4: 'var(--ant-color-success)',
};
function cellKey(c: (typeof cells.value)[number]) {
return c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty');
}
function cellBackground(c: (typeof cells.value)[number]) {
if (props.mode === 'heat') return HEAT_BG[heatLevel(c.day)];
return STATE_BG[cellKey(c)] ?? STATE_BG.empty;
}
function title(c: (typeof cells.value)[number]) { function title(c: (typeof cells.value)[number]) {
const d = c.day; const d = c.day;
if (!d || !d.total) return 'بدون کار'; if (!d || !d.total) return 'بدون کار';
@@ -55,211 +97,78 @@ function title(c: (typeof cells.value)[number]) {
</script> </script>
<template> <template>
<div class="mgrid" :class="[mode, { compact }]"> <a-flex vertical :gap="6">
<div v-for="w in ROUTINE_WEEKDAY_LABELS" :key="w" class="wd">{{ compact ? w.charAt(0) : w }}</div> <a-flex :gap="6">
<template v-for="c in cells" :key="c.key"> <a-typography-text
<span v-if="c.blank" class="cell blank" /> v-for="w in ROUTINE_WEEKDAY_LABELS"
<button :key="w"
v-else type="secondary"
type="button" strong
class="cell" :style="{ flex: '1 1 0', minWidth: 0, textAlign: 'center' }"
:class="[
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
{ sel: selected === c.key, today: c.key === today },
]"
:title="title(c)"
@click="emit('select', c.key)"
> >
<span class="num">{{ toFaDigits(c.jd) }}</span> {{ compact ? w.charAt(0) : w }}
<template v-if="!compact && c.day?.total"> </a-typography-text>
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span> </a-flex>
<i v-if="mode === 'state'" class="dot" /> <a-flex wrap="wrap" :gap="6">
</template> <template v-for="c in cells" :key="c.key">
</button> <a-flex
</template> v-if="c.blank"
</div> :style="{ flex: '0 0 calc((100% - 36px) / 7)', minWidth: 0 }"
/>
<a-button
v-else
:title="title(c)"
:style="{
flex: '0 0 calc((100% - 36px) / 7)',
minWidth: 0,
height: compact ? '44px' : '74px',
padding: '8px',
background: cellBackground(c),
borderColor:
selected === c.key
? token.colorPrimary
: c.key === today
? token.colorPrimaryBorder
: undefined,
boxShadow: selected === c.key ? `0 0 0 1px ${token.colorPrimary}` : undefined,
}"
@click="emit('select', c.key)"
>
<a-flex
vertical
align="start"
justify="space-between"
:style="{ width: '100%', height: '100%' }"
>
<a-typography-text
strong
:style="c.key === today ? { color: 'var(--ant-color-primary)' } : undefined"
>
{{ toFaDigits(c.jd) }}
</a-typography-text>
<a-flex
v-if="!compact && c.day?.total"
justify="space-between"
align="center"
:style="{ width: '100%' }"
>
<a-typography-text type="secondary">
{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}
</a-typography-text>
<a-flex
v-if="mode === 'state'"
:style="{
width: '8px',
height: '8px',
borderRadius: token.borderRadiusLG,
background: STATE_DOT[cellKey(c)],
flexShrink: 0,
}"
/>
</a-flex>
</a-flex>
</a-button>
</template>
</a-flex>
</a-flex>
</template> </template>
<style scoped>
.mgrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.wd {
text-align: center;
font-size: var(--fs-xs);
color: var(--muted);
font-weight: var(--fw-semibold);
padding-bottom: 2px;
}
.cell {
position: relative;
min-height: 74px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
cursor: pointer;
font: inherit;
text-align: right;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.cell.blank {
border: 0;
background: transparent;
cursor: default;
}
.cell:not(.blank):hover {
border-color: var(--line-strong);
}
.cell.sel {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.cell.today .num {
background: var(--accent);
color: #fff;
}
.num {
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 99px;
display: inline-grid;
place-items: center;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.frac {
font-size: var(--fs-xs);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
position: absolute;
top: 10px;
inset-inline-end: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-strong);
}
/* وضعیت روز */
.s-complete {
background: color-mix(in srgb, var(--success-soft) 45%, var(--surface));
}
.s-complete .dot {
background: var(--success);
}
.s-partial {
background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface));
}
.s-partial .dot {
background: var(--warning);
}
.s-missed {
background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface));
}
.s-missed .dot {
background: var(--danger);
}
.s-future {
background: var(--surface);
}
.s-empty {
background: var(--surface);
}
/* Heatmap */
.h-0 {
background: var(--surface-subtle);
}
.h-1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h-2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h-3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h-4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.h-3 .num,
.h-4 .num,
.h-3 .frac,
.h-4 .frac {
color: #fff;
}
.compact {
gap: 4px;
}
.compact .cell {
min-height: 0;
aspect-ratio: 1;
padding: 2px;
align-items: center;
justify-content: center;
border-color: transparent;
}
.compact .num {
font-size: 0.7rem;
min-width: 0;
height: auto;
}
@media (max-width: 700px) {
.mgrid {
gap: 3px;
}
.cell {
min-height: 54px;
padding: 4px;
align-items: center;
}
.frac {
font-size: 0.62rem;
}
.dot {
position: static;
width: 7px;
height: 7px;
}
}
</style>
@@ -19,7 +19,7 @@ import {
FlagOutlined, FlagOutlined,
MoreOutlined, MoreOutlined,
WarningOutlined, WarningOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
const AMenuItem = AMenu.Item; const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue'; import UserAvatar from '@/components/UserAvatar.vue';
@@ -1,29 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed } from 'vue';
import type { RoutineMemberStatDto } from '@mento/shared'; import type { RoutineMemberStatDto } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue'; import UserAvatar from '@/components/UserAvatar.vue';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines'; import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
import {
Empty as AEmpty,
Flex as AFlex,
Progress as AProgress,
Space as ASpace,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ members: RoutineMemberStatDto[] }>(); const props = defineProps<{ members: RoutineMemberStatDto[] }>();
const emit = defineEmits<{ select: [userId: string] }>(); const emit = defineEmits<{ select: [userId: string] }>();
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg'; type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
const sortKey = ref<SortKey>('percent');
const sortDir = ref<'asc' | 'desc'>('desc');
const columns: Array<{ key: SortKey; label: string }> = [
{ key: 'name', label: 'کاربر' },
{ key: 'total', label: 'کل کارها' },
{ key: 'completed', label: 'انجام شده' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'skipped', label: 'رد شده' },
{ key: 'percent', label: 'درصد انجام' },
{ key: 'streak', label: 'روز پیاپی کامل' },
{ key: 'avg', label: 'میانگین زمان انجام' },
];
function value(m: RoutineMemberStatDto, key: SortKey): number | string { function value(m: RoutineMemberStatDto, key: SortKey): number | string {
switch (key) { switch (key) {
case 'name': case 'name':
@@ -37,155 +34,101 @@ function value(m: RoutineMemberStatDto, key: SortKey): number | string {
} }
} }
const rows = computed(() => { function sorterFor(key: SortKey) {
const dir = sortDir.value === 'asc' ? 1 : -1; return (a: RoutineMemberStatDto, b: RoutineMemberStatDto) => {
return [...props.members].sort((a, b) => { const va = value(a, key);
const va = value(a, sortKey.value); const vb = value(b, key);
const vb = value(b, sortKey.value); if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa');
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir; return (va as number) - (vb as number);
return ((va as number) - (vb as number)) * dir; };
}); }
});
function sortBy(key: SortKey) { const columns = computed<TableColumnsType<RoutineMemberStatDto>>(() => [
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'; { title: 'کاربر', key: 'name', sorter: sorterFor('name'), defaultSortOrder: undefined },
else { {
sortKey.value = key; title: 'کل کارها',
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc'; key: 'total',
} sorter: sorterFor('total'),
customRender: ({ record }) => toFaDigits(record.total),
},
{
title: 'انجام شده',
key: 'completed',
sorter: sorterFor('completed'),
customRender: ({ record }) => toFaDigits(record.completed),
},
{
title: 'عقب‌افتاده',
key: 'overdue',
sorter: sorterFor('overdue'),
customRender: ({ record }) => toFaDigits(record.overdue),
},
{
title: 'رد شده',
key: 'skipped',
sorter: sorterFor('skipped'),
customRender: ({ record }) => toFaDigits(record.skipped),
},
{ title: 'درصد انجام', key: 'percent', sorter: sorterFor('percent'), defaultSortOrder: 'descend' },
{
title: 'روز پیاپی کامل',
key: 'streak',
sorter: sorterFor('streak'),
customRender: ({ record }) => toFaDigits(record.streak),
},
{
title: 'میانگین زمان انجام',
key: 'avg',
sorter: sorterFor('avg'),
customRender: ({ record }) => formatMinutes(record.avgCompletionMinutes),
},
]);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
function rowProps(record: RoutineMemberStatDto) {
return {
onClick: () => emit('select', record.user.id),
style: { cursor: 'pointer' },
};
} }
</script> </script>
<template> <template>
<div class="crm-table-container"> <a-table
<table class="crm-table"> :columns="columns"
<thead> :data-source="props.members"
<tr> :pagination="false"
<th :row-key="(r: RoutineMemberStatDto) => r.user.id"
v-for="c in columns" :custom-row="rowProps"
:key="c.key" :scroll="{ x: 'max-content' }"
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'" >
> <template #emptyText>
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)"> <a-empty description="داده‌ای برای این بازه نیست." />
{{ c.label }} </template>
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span> <template #bodyCell="{ column, record }">
</button> <template v-if="column.key === 'name'">
</th> <a-space :size="8">
</tr> <UserAvatar :name="record.user.fullName" :user-id="record.user.id" :size="28" />
</thead> <a-typography-text strong>{{ record.user.fullName }}</a-typography-text>
<tbody> </a-space>
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)"> </template>
<td> <template v-else-if="column.key === 'percent'">
<span class="who"> <a-flex align="center" :gap="8" :style="{ minWidth: '8rem' }">
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" /> <a-typography-text strong>{{ formatPercent(record.percent) }}</a-typography-text>
{{ m.user.fullName }} <a-progress
</span> :percent="Math.max(0, Math.min(100, Math.round(record.percent ?? 0)))"
</td> :show-info="false"
<td>{{ toFaDigits(m.total) }}</td> :stroke-color="TONE_COLORS[percentTone(record.percent)]"
<td class="ok">{{ toFaDigits(m.completed) }}</td> :style="{ flex: 1 }"
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td> />
<td>{{ toFaDigits(m.skipped) }}</td> </a-flex>
<td> </template>
<span class="pct" :class="`tone-${percentTone(m.percent)}`"> </template>
<strong>{{ formatPercent(m.percent) }}</strong> </a-table>
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
</span>
</td>
<td>{{ toFaDigits(m.streak) }}</td>
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty">داده‌ای برای این بازه نیست.</td>
</tr>
</tbody>
</table>
</div>
</template> </template>
<style scoped>
.sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.sort.on {
color: var(--accent-deep);
}
.arrow {
font-size: 0.6rem;
opacity: 0.7;
}
.clickable {
cursor: pointer;
}
.who {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-weight: var(--fw-medium);
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.nowrap {
white-space: nowrap;
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { provide, toRef } from 'vue';
import { CRM_SKELETON_LOADING } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
}>(),
{ loading: false },
);
provide(CRM_SKELETON_LOADING, toRef(props, 'loading'));
</script>
<template>
<div class="crm-skeleton-root" :data-loading="loading ? '1' : '0'">
<slot />
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonAvatar = Skeleton.Avatar;
const props = withDefaults(
defineProps<{
loading?: boolean;
size?: number;
shape?: 'circle' | 'square';
}>(),
{ size: 40, shape: 'circle' },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const sizePx = computed(() => props.size ?? token.value.controlHeightLG ?? 40);
</script>
<template>
<a-skeleton-avatar
v-if="isLoading"
active
:size="sizePx"
:shape="shape"
/>
<slot v-else />
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
width?: string | number;
height?: string | number;
round?: boolean;
block?: boolean;
}>(),
{
round: false,
block: true,
},
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const widthCss = computed(() =>
props.width == null ? '100%' : typeof props.width === 'number' ? `${props.width}px` : props.width,
);
const heightCss = computed(() =>
props.height == null
? `${token.value.controlHeight}px`
: typeof props.height === 'number'
? `${props.height}px`
: props.height,
);
</script>
<template>
<a-skeleton-button
v-if="isLoading"
active
:block="block"
:shape="round ? 'round' : 'default'"
:style="{
width: widthCss,
minWidth: widthCss,
height: heightCss,
borderRadius: round ? '999px' : `${token.borderRadius}px`,
}"
/>
<slot v-else />
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Card as ACard, Skeleton as ASkeleton } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
/** Approximate body lines inside the card */
rows?: number;
bordered?: boolean;
/** Min height so layout does not jump */
minHeight?: number;
}>(),
{ rows: 4, bordered: false },
);
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-card
v-if="isLoading"
:bordered="bordered"
:style="minHeight ? { minHeight: `${minHeight}px` } : undefined"
>
<a-skeleton active :title="{ width: '40%' }" :paragraph="{ rows }" />
</a-card>
<slot v-else />
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const ASkeletonInput = Skeleton.Input;
const props = withDefaults(
defineProps<{
loading?: boolean;
width?: string | number;
/** button | input | select — all use controlHeight 36 */
shape?: 'button' | 'input' | 'select' | 'default';
}>(),
{ shape: 'default', width: 120 },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const widthCss = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width,
);
const isField = computed(() => props.shape === 'input' || props.shape === 'select');
</script>
<template>
<a-skeleton-input
v-if="isLoading && isField"
active
:style="{
width: widthCss,
minWidth: widthCss,
height: `${token.controlHeight}px`,
}"
/>
<a-skeleton-button
v-else-if="isLoading"
active
:style="{
width: widthCss,
minWidth: widthCss,
height: `${token.controlHeight}px`,
borderRadius: `${token.borderRadius}px`,
}"
/>
<slot v-else />
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Card as ACard, Flex as AFlex, Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
bordered?: boolean;
}>(),
{ bordered: false },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-card v-if="isLoading" :bordered="bordered">
<a-flex vertical :gap="token.paddingXS">
<a-skeleton-button
active
:style="{
width: '45%',
minWidth: 0,
height: `${token.fontSizeSM}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
<a-skeleton-button
active
:style="{
width: '60%',
minWidth: 0,
height: '28px',
borderRadius: `${token.borderRadius}px`,
}"
/>
</a-flex>
</a-card>
<slot v-else />
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Flex as AFlex, Skeleton as ASkeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
rows?: number;
avatar?: boolean;
}>(),
{ rows: 6, avatar: true },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-flex v-if="isLoading" vertical :gap="token.paddingSM">
<a-skeleton
v-for="i in rows"
:key="i"
active
:avatar="avatar ? { size: token.controlHeight, shape: 'circle' } : false"
:title="{ width: '40%' }"
:paragraph="{ rows: 1, width: ['70%'] }"
/>
</a-flex>
<slot v-else />
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { Skeleton as ASkeleton } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const props = withDefaults(
defineProps<{
loading?: boolean;
rows?: number;
width?: string | number | Array<string | number>;
}>(),
{ rows: 3 },
);
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
</script>
<template>
<a-skeleton
v-if="isLoading"
active
:title="false"
:paragraph="{ rows, width }"
/>
<slot v-else />
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Card as ACard, Flex as AFlex, Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
rows?: number;
columns?: number;
/** Wrap bones in a card (matches most list pages) */
card?: boolean;
bordered?: boolean;
}>(),
{ rows: 8, columns: 5, card: true, bordered: false },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const colWidths = computed(() => {
const n = Math.max(1, props.columns);
return Array.from({ length: n }, (_, i) => {
if (i === 0) return '22%';
if (i === n - 1) return '12%';
return `${Math.floor(66 / Math.max(1, n - 2))}%`;
});
});
const rowHeight = computed(() => token.value.controlHeight + (token.value.paddingXS ?? 8));
const headerH = computed(() => Math.round((token.value.fontSizeSM ?? 12) + 4));
const cellH = computed(() => token.value.fontSize ?? 14);
</script>
<template>
<a-card v-if="isLoading && card" :bordered="bordered">
<a-flex vertical :gap="token.paddingXS">
<a-flex :gap="token.paddingSM" :style="{ marginBottom: `${token.marginXS}px` }">
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`h-${i}`"
active
:style="{
width: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${headerH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
<a-flex
v-for="r in rows"
:key="`r-${r}`"
:gap="token.paddingSM"
align="center"
:style="{ minHeight: `${rowHeight}px` }"
>
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`c-${r}-${i}`"
active
:style="{
width: i === 0 ? '85%' : '70%',
maxWidth: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${cellH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
</a-flex>
</a-card>
<a-flex v-else-if="isLoading" vertical :gap="token.paddingXS">
<a-flex :gap="token.paddingSM" :style="{ marginBottom: `${token.marginXS}px` }">
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`h-${i}`"
active
:style="{
width: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${headerH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
<a-flex
v-for="r in rows"
:key="`r-${r}`"
:gap="token.paddingSM"
align="center"
:style="{ minHeight: `${rowHeight}px` }"
>
<a-skeleton-button
v-for="(w, i) in colWidths"
:key="`c-${r}-${i}`"
active
:style="{
width: i === 0 ? '85%' : '70%',
maxWidth: w,
minWidth: 0,
flex: `0 0 ${w}`,
height: `${cellH}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
</a-flex>
</a-flex>
<slot v-else />
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Skeleton, theme } from 'ant-design-vue';
import { useCrmSkeletonLoading } from './context';
const ASkeletonButton = Skeleton.Button;
const props = withDefaults(
defineProps<{
loading?: boolean;
width?: string | number;
/** Typography title level approximation: 1≈38, 2≈30, 3≈24, 4≈20, 5≈16 */
level?: 1 | 2 | 3 | 4 | 5;
}>(),
{ level: 3, width: 180 },
);
const { token } = theme.useToken();
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
const height = computed(() => {
switch (props.level) {
case 1:
return 38;
case 2:
return 30;
case 3:
return 24;
case 4:
return 20;
default:
return 16;
}
});
const widthCss = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width,
);
</script>
<template>
<a-skeleton-button
v-if="isLoading"
active
:style="{
width: widthCss,
minWidth: widthCss,
height: `${height}px`,
borderRadius: `${token.borderRadiusSM}px`,
}"
/>
<div v-else :style="{ minHeight: `${height}px` }">
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
import { computed, inject, type ComputedRef, type InjectionKey, type Ref } from 'vue';
export const CRM_SKELETON_LOADING: InjectionKey<Ref<boolean> | ComputedRef<boolean>> =
Symbol('crmSkeletonLoading');
/** Resolve whether a skeleton block should show bones (local override wins). */
export function useCrmSkeletonLoading(local?: Ref<boolean | undefined> | ComputedRef<boolean | undefined>) {
const injected = inject(CRM_SKELETON_LOADING, null);
return computed(() => {
if (local?.value !== undefined) return !!local.value;
return !!injected?.value;
});
}
+11
View File
@@ -0,0 +1,11 @@
export { default as CrmSkeleton } from './CrmSkeleton.vue';
export { default as CrmSkeletonBone } from './CrmSkeletonBone.vue';
export { default as CrmSkeletonTitle } from './CrmSkeletonTitle.vue';
export { default as CrmSkeletonControl } from './CrmSkeletonControl.vue';
export { default as CrmSkeletonParagraph } from './CrmSkeletonParagraph.vue';
export { default as CrmSkeletonAvatar } from './CrmSkeletonAvatar.vue';
export { default as CrmSkeletonCard } from './CrmSkeletonCard.vue';
export { default as CrmSkeletonTable } from './CrmSkeletonTable.vue';
export { default as CrmSkeletonKpi } from './CrmSkeletonKpi.vue';
export { default as CrmSkeletonList } from './CrmSkeletonList.vue';
export { CRM_SKELETON_LOADING, useCrmSkeletonLoading } from './context';
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue'; import { Menu as AMenu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared'; import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@@ -12,56 +13,48 @@ const auth = useAuthStore();
const links = computed(() => { const links = computed(() => {
const role = auth.user?.role; const role = auth.user?.role;
const permissions = auth.user?.permissions; const permissions = auth.user?.permissions;
const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [ const all: Array<{ to: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' }, { to: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' }, { to: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' }, { to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' }, { to: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' }, { to: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' }, { to: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' }, { to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' }, { to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' }, { to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' }, { to: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
]; ];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section)); return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
}); });
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label }))); function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
return route.path === path || route.path.startsWith(`${path}/`);
}
const active = computed(() => { const selectedKeys = computed(() => {
const path = route.path; const match = links.value.find((link) => on(link.to, link.exact));
for (const link of links.value) { return match ? [match.to] : [];
if (link.exact) {
if (path === link.value) return link.value;
} else if (path === link.value || path.startsWith(`${link.value}/`)) {
return link.value;
}
}
return '/finance';
}); });
function onChange(value: string | number) { const items = computed<MenuProps['items']>(() =>
const next = String(value); links.value.map((link) => ({
if (next && next !== route.path) void router.push(next); key: link.to,
} label: link.label,
})),
);
const onClick: MenuProps['onClick'] = ({ key }) => {
void router.push(String(key));
};
</script> </script>
<template> <template>
<div <a-menu
:style="{ mode="horizontal"
width: '100%', :selected-keys="selectedKeys"
overflowX: 'auto', :items="items"
flexShrink: 0, @click="onClick"
minHeight: '36px', />
}"
>
<a-segmented
:value="active"
:options="options"
dir="rtl"
:style="{ minWidth: 'max-content' }"
@change="onChange"
/>
</div>
</template> </template>
+1 -10
View File
@@ -23,9 +23,7 @@ withDefaults(
<div class="titles"> <div class="titles">
<h1 v-if="title" class="title">{{ title }}</h1> <h1 v-if="title" class="title">{{ title }}</h1>
<slot v-else name="title" /> <slot v-else name="title" />
<slot name="lead" />
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
<slot v-else name="lead" />
</div> </div>
</div> </div>
@@ -87,13 +85,6 @@ withDefaults(
line-height: var(--lh-tight); line-height: var(--lh-tight);
} }
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: var(--lh-normal);
}
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;
+163
View File
@@ -0,0 +1,163 @@
/**
* جایگزین drop-in برای `@ant-design/icons-vue`.
* همان نام‌های *Outlined/*Filled را اکسپورت می‌کند ولی از پک vuesax می‌کشد.
*/
import { defineComponent, h } from 'vue';
import VsIcon from '@/components/VsIcon.vue';
import { ANT_ICON_TO_VUESAX } from './name-map';
function makeAntIcon(exportName: string) {
const vuesax = ANT_ICON_TO_VUESAX[exportName] ?? 'info-circle';
return defineComponent({
name: exportName,
inheritAttrs: false,
props: {
spin: { type: Boolean, default: false },
rotate: { type: Number, default: undefined },
twoToneColor: { type: String, default: undefined },
},
setup(props, { attrs }) {
return () => {
const style: Record<string, string> = {};
const attrStyle = attrs.style;
if (attrStyle && typeof attrStyle === 'object' && !Array.isArray(attrStyle)) {
Object.assign(style, attrStyle as Record<string, string>);
}
if (props.rotate != null) {
style.transform = `rotate(${props.rotate}deg)`;
}
return h(VsIcon, {
name: vuesax,
map: 'raw',
spin: props.spin,
class: ['anticon', attrs.class],
style: Object.keys(style).length ? style : undefined,
// مثل آیکون Ant با font-size والد اسکیل می‌شود
size: '1em',
});
};
},
});
}
function exportAll() {
const out: Record<string, ReturnType<typeof makeAntIcon>> = {};
for (const name of Object.keys(ANT_ICON_TO_VUESAX)) {
out[name] = makeAntIcon(name);
}
return out;
}
const icons = exportAll();
export const AccountBookOutlined = icons.AccountBookOutlined;
export const AimOutlined = icons.AimOutlined;
export const AlertOutlined = icons.AlertOutlined;
export const AppstoreOutlined = icons.AppstoreOutlined;
export const ArrowDownOutlined = icons.ArrowDownOutlined;
export const ArrowLeftOutlined = icons.ArrowLeftOutlined;
export const ArrowRightOutlined = icons.ArrowRightOutlined;
export const ArrowUpOutlined = icons.ArrowUpOutlined;
export const AudioOutlined = icons.AudioOutlined;
export const BankOutlined = icons.BankOutlined;
export const BarChartOutlined = icons.BarChartOutlined;
export const BellOutlined = icons.BellOutlined;
export const BoldOutlined = icons.BoldOutlined;
export const BoxPlotOutlined = icons.BoxPlotOutlined;
export const BulbOutlined = icons.BulbOutlined;
export const CalendarOutlined = icons.CalendarOutlined;
export const CameraOutlined = icons.CameraOutlined;
export const CarOutlined = icons.CarOutlined;
export const CheckCircleOutlined = icons.CheckCircleOutlined;
export const CheckOutlined = icons.CheckOutlined;
export const ClockCircleOutlined = icons.ClockCircleOutlined;
export const CloseCircleOutlined = icons.CloseCircleOutlined;
export const CloseOutlined = icons.CloseOutlined;
export const CommentOutlined = icons.CommentOutlined;
export const ContainerOutlined = icons.ContainerOutlined;
export const ControlOutlined = icons.ControlOutlined;
export const CopyOutlined = icons.CopyOutlined;
export const CreditCardOutlined = icons.CreditCardOutlined;
export const CrownOutlined = icons.CrownOutlined;
export const CustomerServiceOutlined = icons.CustomerServiceOutlined;
export const DeleteOutlined = icons.DeleteOutlined;
export const DollarOutlined = icons.DollarOutlined;
export const DownOutlined = icons.DownOutlined;
export const DownloadOutlined = icons.DownloadOutlined;
export const DragOutlined = icons.DragOutlined;
export const EditOutlined = icons.EditOutlined;
export const EnvironmentOutlined = icons.EnvironmentOutlined;
export const ExclamationCircleOutlined = icons.ExclamationCircleOutlined;
export const ExpandOutlined = icons.ExpandOutlined;
export const ExportOutlined = icons.ExportOutlined;
export const EyeInvisibleOutlined = icons.EyeInvisibleOutlined;
export const EyeOutlined = icons.EyeOutlined;
export const FallOutlined = icons.FallOutlined;
export const FileExcelOutlined = icons.FileExcelOutlined;
export const FileOutlined = icons.FileOutlined;
export const FileTextOutlined = icons.FileTextOutlined;
export const FilterOutlined = icons.FilterOutlined;
export const FireOutlined = icons.FireOutlined;
export const FlagFilled = icons.FlagFilled;
export const FlagOutlined = icons.FlagOutlined;
export const FullscreenExitOutlined = icons.FullscreenExitOutlined;
export const FullscreenOutlined = icons.FullscreenOutlined;
export const FundOutlined = icons.FundOutlined;
export const GiftOutlined = icons.GiftOutlined;
export const HistoryOutlined = icons.HistoryOutlined;
export const HolderOutlined = icons.HolderOutlined;
export const HomeOutlined = icons.HomeOutlined;
export const IdcardOutlined = icons.IdcardOutlined;
export const InfoCircleOutlined = icons.InfoCircleOutlined;
export const ItalicOutlined = icons.ItalicOutlined;
export const LeftOutlined = icons.LeftOutlined;
export const LinkOutlined = icons.LinkOutlined;
export const LockOutlined = icons.LockOutlined;
export const LogoutOutlined = icons.LogoutOutlined;
export const MailOutlined = icons.MailOutlined;
export const MessageOutlined = icons.MessageOutlined;
export const MinusOutlined = icons.MinusOutlined;
export const MoreOutlined = icons.MoreOutlined;
export const NotificationOutlined = icons.NotificationOutlined;
export const NumberOutlined = icons.NumberOutlined;
export const PaperClipOutlined = icons.PaperClipOutlined;
export const PauseCircleOutlined = icons.PauseCircleOutlined;
export const PhoneOutlined = icons.PhoneOutlined;
export const PictureOutlined = icons.PictureOutlined;
export const PlusOutlined = icons.PlusOutlined;
export const PrinterOutlined = icons.PrinterOutlined;
export const PushpinOutlined = icons.PushpinOutlined;
export const QuestionCircleOutlined = icons.QuestionCircleOutlined;
export const ReloadOutlined = icons.ReloadOutlined;
export const RightOutlined = icons.RightOutlined;
export const RiseOutlined = icons.RiseOutlined;
export const RobotOutlined = icons.RobotOutlined;
export const SafetyCertificateOutlined = icons.SafetyCertificateOutlined;
export const SaveOutlined = icons.SaveOutlined;
export const ScheduleOutlined = icons.ScheduleOutlined;
export const SearchOutlined = icons.SearchOutlined;
export const SelectOutlined = icons.SelectOutlined;
export const SendOutlined = icons.SendOutlined;
export const SettingOutlined = icons.SettingOutlined;
export const ShoppingOutlined = icons.ShoppingOutlined;
export const SlidersOutlined = icons.SlidersOutlined;
export const SmileOutlined = icons.SmileOutlined;
export const SolutionOutlined = icons.SolutionOutlined;
export const SoundOutlined = icons.SoundOutlined;
export const StarOutlined = icons.StarOutlined;
export const StopOutlined = icons.StopOutlined;
export const SyncOutlined = icons.SyncOutlined;
export const TagOutlined = icons.TagOutlined;
export const TeamOutlined = icons.TeamOutlined;
export const ThunderboltOutlined = icons.ThunderboltOutlined;
export const ToolOutlined = icons.ToolOutlined;
export const TrophyOutlined = icons.TrophyOutlined;
export const UnorderedListOutlined = icons.UnorderedListOutlined;
export const UpOutlined = icons.UpOutlined;
export const UploadOutlined = icons.UploadOutlined;
export const UserAddOutlined = icons.UserAddOutlined;
export const UserOutlined = icons.UserOutlined;
export const WalletOutlined = icons.WalletOutlined;
export const WarningOutlined = icons.WarningOutlined;
export const ZoomInOutlined = icons.ZoomInOutlined;
export const ZoomOutOutlined = icons.ZoomOutOutlined;
+6
View File
@@ -0,0 +1,6 @@
export { getVuesaxSvg, hasVuesax, VUESAX_NAMES, type VuesaxName } from './vuesax';
export {
ANT_ICON_TO_VUESAX,
UI_ICON_TO_VUESAX,
resolveVuesaxName,
} from './name-map';
+208
View File
@@ -0,0 +1,208 @@
/**
* نام‌های قدیمی پروژه → کلید کوتاه پک vuesax-linear.
* اگر آیکون دقیق نبود نزدیک‌ترین معادل انتخاب شده.
*/
/** نام‌های UiIcon → vuesax */
export const UI_ICON_TO_VUESAX: Record<string, string> = {
home: 'home',
users: 'profile-2user',
briefcase: 'briefcase',
check: 'tick-circle',
box: 'box',
chart: 'chart',
settings: 'setting-2',
search: 'search-normal',
sparkle: 'magic-star',
bell: 'notification',
calendar: 'calendar',
'calendar-plus': 'calendar-add',
mail: 'sms',
megaphone: 'volume-high',
send: 'send-2',
fire: 'flash',
warning: 'warning-2',
trend: 'trend-up',
phone: 'call',
trophy: 'cup',
chat: 'messages',
dots: 'more',
'dots-v': 'more',
document: 'document-text',
bag: 'bag',
clock: 'clock',
'user-plus': 'user-add',
plus: 'add',
chevron: 'arrow-down',
star: 'star',
pin: 'location',
filter: 'filter',
funnel: 'filter',
lightbulb: 'lamp-on',
building: 'buildings',
user: 'user',
flag: 'flag',
refresh: 'refresh',
x: 'close-circle',
'x-circle': 'close-circle',
mic: 'microphone',
trash: 'trash',
stop: 'stop',
'check-plain': 'tick-circle',
'calendar-sun': 'calendar-1',
'calendar-edit': 'calendar-edit',
minus: 'minus',
dollar: 'dollar-circle',
layers: 'layer',
target: 'other:target',
pencil: 'edit-2',
edit: 'edit',
grid: 'element-3',
list: 'row-vertical',
eye: 'eye',
'eye-off': 'eye-slash',
lock: 'lock',
truck: 'truck-tick',
grip: 'element-equal',
'apps-plus': 'additem',
crown: 'crown',
gift: 'gift',
medal: 'medal',
logout: 'logout',
pulse: 'activity',
sliders: 'setting-4',
camera: 'camera',
clip: 'paperclip',
download: 'document-download',
upload: 'document-upload',
'arrow-right': 'arrow-right',
'arrow-left': 'arrow-left',
'chevron-right': 'arrow-right-1',
'chevron-left': 'arrow-left-1',
'chevrons-right': 'arrow-right-2',
'chevrons-left': 'arrow-left-2',
};
/** کامپوننت‌های @ant-design/icons-vue → vuesax */
export const ANT_ICON_TO_VUESAX: Record<string, string> = {
AccountBookOutlined: 'book',
AimOutlined: 'other:target',
AlertOutlined: 'warning-2',
AppstoreOutlined: 'element-3',
ArrowDownOutlined: 'arrow-down',
ArrowLeftOutlined: 'arrow-left',
ArrowRightOutlined: 'arrow-right',
ArrowUpOutlined: 'arrow-up',
AudioOutlined: 'volume-high',
BankOutlined: 'bank',
BarChartOutlined: 'chart-2',
BellOutlined: 'notification',
BoldOutlined: 'text-bold',
BoxPlotOutlined: 'chart-21',
BulbOutlined: 'lamp-on',
CalendarOutlined: 'calendar',
CameraOutlined: 'camera',
CarOutlined: 'car',
CheckCircleOutlined: 'tick-circle',
CheckOutlined: 'tick-circle',
ClockCircleOutlined: 'clock',
CloseCircleOutlined: 'close-circle',
CloseOutlined: 'close-circle',
CommentOutlined: 'message-text',
ContainerOutlined: 'box',
ControlOutlined: 'setting-4',
CopyOutlined: 'copy',
CreditCardOutlined: 'card',
CrownOutlined: 'crown',
CustomerServiceOutlined: 'headphone',
DeleteOutlined: 'trash',
DollarOutlined: 'dollar-circle',
DownOutlined: 'arrow-down',
DownloadOutlined: 'document-download',
DragOutlined: 'element-equal',
EditOutlined: 'edit-2',
EnvironmentOutlined: 'location',
ExclamationCircleOutlined: 'warning-2',
ExpandOutlined: 'maximize-4',
ExportOutlined: 'export',
EyeInvisibleOutlined: 'eye-slash',
EyeOutlined: 'eye',
FallOutlined: 'trend-down',
FileExcelOutlined: 'document-text',
FileOutlined: 'document',
FileTextOutlined: 'document-text',
FilterOutlined: 'filter',
FireOutlined: 'flash',
FlagFilled: 'flag',
FlagOutlined: 'flag',
FullscreenExitOutlined: 'maximize-3',
FullscreenOutlined: 'maximize-4',
FundOutlined: 'chart',
GiftOutlined: 'gift',
HistoryOutlined: 'clock',
HolderOutlined: 'element-equal',
HomeOutlined: 'home',
IdcardOutlined: 'personalcard',
InfoCircleOutlined: 'info-circle',
ItalicOutlined: 'text-italic',
LeftOutlined: 'arrow-left-1',
LinkOutlined: 'link',
LockOutlined: 'lock',
LogoutOutlined: 'logout',
MailOutlined: 'sms',
MessageOutlined: 'message',
MinusOutlined: 'minus',
MoreOutlined: 'more',
NotificationOutlined: 'notification-bing',
NumberOutlined: 'hashtag',
PaperClipOutlined: 'paperclip',
PauseCircleOutlined: 'pause',
PhoneOutlined: 'call',
PictureOutlined: 'gallery',
PlusOutlined: 'add',
PrinterOutlined: 'printer',
PushpinOutlined: 'location',
QuestionCircleOutlined: 'message-question',
ReloadOutlined: 'refresh-2',
RightOutlined: 'arrow-right-1',
RiseOutlined: 'trend-up',
RobotOutlined: 'cpu',
SafetyCertificateOutlined: 'shield-tick',
SaveOutlined: 'save-2',
ScheduleOutlined: 'calendar-tick',
SearchOutlined: 'search-normal',
SelectOutlined: 'mouse-square',
SendOutlined: 'send-2',
SettingOutlined: 'setting-2',
ShoppingOutlined: 'shopping-bag',
SlidersOutlined: 'setting-4',
SmileOutlined: 'emoji-happy',
SolutionOutlined: 'clipboard-text',
SoundOutlined: 'volume-high',
StarOutlined: 'star',
StopOutlined: 'stop',
SyncOutlined: 'refresh',
TagOutlined: 'tag',
TeamOutlined: 'profile-2user',
ThunderboltOutlined: 'flash',
ToolOutlined: 'setting',
TrophyOutlined: 'cup',
UnorderedListOutlined: 'row-vertical',
UpOutlined: 'arrow-up',
UploadOutlined: 'document-upload',
UserAddOutlined: 'user-add',
UserOutlined: 'user',
WalletOutlined: 'wallet-2',
WarningOutlined: 'warning-2',
ZoomInOutlined: 'search-zoom-in',
ZoomOutOutlined: 'search-zoom-out',
};
export function resolveVuesaxName(
name: string,
source: 'ui' | 'ant' | 'raw' = 'raw',
): string {
if (source === 'ui') return UI_ICON_TO_VUESAX[name] ?? name;
if (source === 'ant') return ANT_ICON_TO_VUESAX[name] ?? name;
return name;
}
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
import pack from './vuesax-linear.json';
export type VuesaxName = keyof typeof pack & string;
const icons = pack as Record<string, string>;
/** Resolve short or prefixed name → SVG markup (currentColor). */
export function getVuesaxSvg(name: string): string | null {
const key = name.startsWith('vuesax-linear:')
? name.slice('vuesax-linear:'.length)
: name;
return icons[key] ?? null;
}
export function hasVuesax(name: string): boolean {
return getVuesaxSvg(name) != null;
}
export const VUESAX_NAMES = Object.keys(icons) as VuesaxName[];
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@ import { brand } from '@mento/shared';
import App from './App.vue'; import App from './App.vue';
import { router } from './router'; import { router } from './router';
import { vTableCols } from './lib/tableColumns'; import { vTableCols } from './lib/tableColumns';
import './styles/fonts.css';
import './styles/main.css'; import './styles/main.css';
document.title = `${brand.nameFa} | ${brand.fullName}`; document.title = `${brand.nameFa} | ${brand.fullName}`;
+13 -9
View File
@@ -1,4 +1,8 @@
@import "./fonts.css"; @layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@source "../";
@import "./tokens.css"; @import "./tokens.css";
:root { :root {
@@ -20,12 +24,12 @@
--line-light: #f1f5f9; --line-light: #f1f5f9;
--line-strong: #cbd5e1; --line-strong: #cbd5e1;
/* Accent / Primary (Teal) */ /* Accent / Primary (Montact brand) */
--accent: #14b8a6; --accent: #10897d;
--accent-hover: #0d9488; --accent-hover: #0d7369;
--accent-deep: #0f766e; --accent-deep: #0a5c54;
--accent-soft: #ccfbf1; --accent-soft: #d0ebe8;
--accent-subtle: #f0fdfa; --accent-subtle: #eef8f7;
--accent-contrast: #ffffff; --accent-contrast: #ffffff;
/* Semantics */ /* Semantics */
@@ -141,8 +145,9 @@ html {
html, html,
body, body,
#app { #app {
min-height: 100%; height: 100%;
max-width: 100%; max-width: 100%;
overflow: hidden;
} }
body { body {
@@ -150,7 +155,6 @@ body {
font-family: var(--font); font-family: var(--font);
color: var(--ink); color: var(--ink);
background: var(--bg); background: var(--bg);
overflow-x: clip;
} }
img, img,
+22 -2
View File
@@ -7,13 +7,14 @@ import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context';
* controlHeight: 36 is the system default (Ant derives SM/LG). * controlHeight: 36 is the system default (Ant derives SM/LG).
* Do not set controlHeight* under components — inherit the token. * Do not set controlHeight* under components — inherit the token.
*/ */
/** Tailwind gray-50 / gray-200 — layout fill + control/card borders */ /** Tailwind gray-50 / gray-100 / gray-200 — layout fill, segmented track, borders */
const GRAY_50 = '#f9fafb'; const GRAY_50 = '#f9fafb';
const GRAY_100 = '#f3f4f6';
const GRAY_200 = '#e5e7eb'; const GRAY_200 = '#e5e7eb';
export const antdTheme = { export const antdTheme = {
token: { token: {
colorPrimary: '#10b981', colorPrimary: '#10897d',
fontFamily: '"IRANSans", Tahoma, sans-serif', fontFamily: '"IRANSans", Tahoma, sans-serif',
/** Page / subtle surface fill */ /** Page / subtle surface fill */
@@ -64,6 +65,14 @@ export const antdTheme = {
contentBg: GRAY_50, contentBg: GRAY_50,
colorBorder: GRAY_200, colorBorder: GRAY_200,
}, },
/**
* Segmented track uses colorBgLayout. Page chrome is gray-50, so the
* default track disappears. Keep the track on gray-100 for every Segmented.
*/
Segmented: {
colorBgLayout: GRAY_100,
trackBg: GRAY_100,
},
Input: { Input: {
colorBorder: GRAY_200, colorBorder: GRAY_200,
}, },
@@ -73,5 +82,16 @@ export const antdTheme = {
Form: { Form: {
itemMarginBottom: 18, itemMarginBottom: 18,
}, },
/**
* Soft luxury shimmer — cool grey gradient on layout fill.
* Consumed by a-skeleton / CrmSkeleton* primitives only.
*/
Skeleton: {
colorFillContent: GRAY_100,
colorFill: GRAY_200,
borderRadius: 8,
paragraphLiHeight: 14,
titleHeight: 20,
},
}, },
} as ThemeConfig; } as ThemeConfig;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+95 -59
View File
@@ -1,27 +1,69 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import type { NoteAttachmentDto, NoteDto, PaginatedResponse } from '@mento/shared'; import type { NoteAttachmentDto, NoteDto, PaginatedResponse } from '@mento/shared';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@/icons/ant-bridge';
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
const { Title: ATypographyTitle, Link: ATypographyLink } = Typography;
type Row = NoteAttachmentDto & { type Row = NoteAttachmentDto & {
noteId: string; noteId: string;
noteTitle: string; noteTitle: string;
}; };
const ATT_COL_LABELS = ['نام فایل', 'یادداشت', 'حجم', 'تاریخ'] as const;
registerTableColumns('attachments', [...ATT_COL_LABELS]);
const tableCols = useTableColumns(() => 'attachments');
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const items = ref<Row[]>([]); const items = ref<Row[]>([]);
const listEmpty = computed(() => !loading.value && !items.value.length);
const columns = computed<TableColumnsType<Row>>(() => {
const all: TableColumnsType<Row> = [
{ title: 'نام فایل', key: 'نام فایل', dataIndex: 'fileName' },
{ title: 'یادداشت', key: 'یادداشت' },
{ title: 'حجم', key: 'حجم' },
{ title: 'تاریخ', key: 'تاریخ' },
{ title: '', key: 'عملیات', width: 110 },
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (key === 'عملیات') return true;
if (!ATT_COL_LABELS.includes(key as (typeof ATT_COL_LABELS)[number])) return true;
return tableCols.isVisible(key);
});
});
function sizeLabel(bytes: number) { function sizeLabel(bytes: number) {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
function rowKey(row: Row) {
return `${row.noteId}-${row.id}`;
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
@@ -62,62 +104,56 @@ onMounted(load);
</script> </script>
<template> <template>
<section class="page" dir="rtl"> <a-flex vertical :gap="16">
<header class="head"> <a-typography-title :level="3" :style="{ margin: 0 }">پیوست‌ها</a-typography-title>
<div>
<h1>پیوست‌ها</h1>
<p class="sub">فایل‌های پیوست‌شده به یادداشت‌ها</p>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">پیوستی ثبت نشده.</p>
<div v-else class="table-wrap">
<div class="tc-row">
<TableColumnsMenu table-key="attachments" align="end" />
</div>
<table v-table-cols="'attachments'">
<thead>
<tr>
<th>نام فایل</th>
<th>یادداشت</th>
<th>حجم</th>
<th>تاریخ</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="`${row.noteId}-${row.id}`">
<td>{{ row.fileName }}</td>
<td>
<RouterLink :to="{ name: 'notes' }">
{{ row.noteTitle }}
</RouterLink>
</td>
<td>{{ sizeLabel(row.sizeBytes) }}</td>
<td>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
<td>
<button type="button" @click="download(row)">دانلود</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped> <a-alert v-if="error" type="error" show-icon :message="error" />
.page { display: grid; gap: 1rem; }
h1 { margin: 0; font-size: 1.25rem; } <CrmSkeleton :loading="loading">
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; } <CrmSkeletonTable :rows="8" :columns="5">
.error { color: var(--danger, #dc2626); font-weight: 600; } <a-card v-if="listEmpty && !error" :bordered="false">
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); } <a-empty description="پیوستی ثبت نشده." />
table { width: 100%; border-collapse: collapse; min-width: 640px; } </a-card>
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; }
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; } <a-card v-else-if="!error && items.length" :bordered="false">
a { color: #0f766e; font-weight: 700; } <a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
button { <TableColumnsMenu table-key="attachments" align="end" />
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius); </a-flex>
padding: 0 0.75rem; background: #fff; cursor: pointer; font: inherit;
} <a-table
</style> :columns="columns"
:data-source="items"
:pagination="false"
:row-key="rowKey"
:scroll="{ x: 640 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'نام فایل'">
{{ (record as Row).fileName }}
</template>
<template v-else-if="column.key === 'یادداشت'">
<RouterLink :to="{ name: 'notes' }" custom v-slot="{ href, navigate }">
<a-typography-link :href="href" @click="navigate">
{{ (record as Row).noteTitle }}
</a-typography-link>
</RouterLink>
</template>
<template v-else-if="column.key === 'حجم'">
{{ sizeLabel((record as Row).sizeBytes) }}
</template>
<template v-else-if="column.key === 'تاریخ'">
{{ formatJalaliNumeric(new Date((record as Row).createdAt)) }}
</template>
<template v-else-if="column.key === 'عملیات'">
<a-button @click="download(record as Row)">
<template #icon><DownloadOutlined /></template>
دانلود
</a-button>
</template>
</template>
</a-table>
</a-card>
</CrmSkeletonTable>
</CrmSkeleton>
</a-flex>
</template>

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