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**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale**
App root is wrapped with `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">`. Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
7. **RTL + locale + StyleProvider**
App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme).
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,9 +38,13 @@ Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` |
| RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow
1. Confirm the **single** route/view the user named.
+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-*)` پاک‌سازی شده است؟
+262 -18
View File
@@ -1533,6 +1533,7 @@ export class ReportsService {
async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals');
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const rates = await loadOrgRates(this.prisma, orgId);
@@ -1543,6 +1544,7 @@ export class ReportsService {
const prev = addJalaliMonths(jy, jm, -1);
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
const trendWindowStart = addMonthsStart(now, -12);
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
@@ -1555,17 +1557,25 @@ export class ReportsService {
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
'issued',
'partially_paid',
'paid',
];
const [
wonDeals,
openDeals,
openDealRows,
lostDeals,
staleDeals,
peopleCur,
peoplePrev,
peopleRecent,
openActivitiesToday,
openActivitiesYesterday,
openDealsWeekAgo,
goals,
overdueCount,
invoices,
activityTypeGroups,
] = await Promise.all([
this.prisma.deal.findMany({
where: {
@@ -1573,7 +1583,7 @@ export class ReportsService {
{
organizationId: orgId,
status: 'won',
updatedAt: { gte: addMonthsStart(now, -5) },
updatedAt: { gte: trendWindowStart },
},
dealScope,
dealsPrior,
@@ -1590,7 +1600,7 @@ export class ReportsService {
person: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.deal.count({
this.prisma.deal.findMany({
where: {
AND: [
{ organizationId: orgId, status: 'open' },
@@ -1598,6 +1608,30 @@ export class ReportsService {
dealsPrior,
],
},
select: {
amount: true,
currency: true,
stageId: true,
stage: { select: { id: true, name: true, position: true } },
},
}),
this.prisma.deal.findMany({
where: {
AND: [
{
organizationId: orgId,
status: 'lost',
updatedAt: { gte: trendWindowStart },
},
dealScope,
dealsPrior,
],
},
select: {
amount: true,
currency: true,
lostReason: true,
},
}),
this.prisma.deal.findMany({
where: {
@@ -1624,19 +1658,13 @@ export class ReportsService {
orderBy: { updatedAt: 'asc' },
take: 5,
}),
this.prisma.person.count({
this.prisma.person.findMany({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: curMonthStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
createdAt: { gte: trendWindowStart, lte: curMonthEnd },
},
select: { createdAt: true, importSource: true },
}),
this.prisma.activity.count({
where: {
@@ -1656,6 +1684,7 @@ export class ReportsService {
AND: [
{
organizationId: orgId,
status: 'open',
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
},
activityScope,
@@ -1697,8 +1726,67 @@ export class ReportsService {
],
},
}),
this.prisma.invoice.findMany({
where: {
organizationId: orgId,
...invoiceScope,
status: { in: invoiceStatuses },
OR: [
{ issuedAt: { gte: trendWindowStart, lte: curMonthEnd } },
{ createdAt: { gte: trendWindowStart, lte: curMonthEnd } },
],
},
select: {
issuedAt: true,
createdAt: true,
lineItems: {
select: {
quantity: true,
unitPrice: true,
currency: true,
},
},
},
}),
this.prisma.activity.groupBy({
by: ['type'],
where: {
AND: [
{
organizationId: orgId,
status: 'open',
},
activityScope,
activitiesPrior,
],
},
_count: { _all: true },
}),
]);
const openDeals = openDealRows.length;
const invoiceInRange = (
inv: { issuedAt: Date | null; createdAt: Date },
from: Date,
to: Date,
) => {
const anchor = inv.issuedAt ?? inv.createdAt;
return anchor >= from && anchor <= to;
};
const sumInvoiceInRange = (from: Date, to: Date) => {
let amount = 0;
for (const inv of invoices) {
if (!invoiceInRange(inv, from, to)) continue;
for (const line of inv.lineItems) {
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
amount += toBaseAmount(lineTotal, line.currency, rates);
}
}
return amount;
};
const sumWonInRange = (start: Date, end: Date) =>
wonDeals
.filter((d) => d.updatedAt >= start && d.updatedAt <= end)
@@ -1707,8 +1795,21 @@ export class ReportsService {
0,
);
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
const salesInRange = (start: Date, end: Date) => {
const inv = sumInvoiceInRange(start, end);
const won = sumWonInRange(start, end);
return Math.round(inv > 0 ? inv : won);
};
const peopleCur = peopleRecent.filter(
(p) => p.createdAt >= curMonthStart && p.createdAt <= curMonthEnd,
).length;
const peoplePrev = peopleRecent.filter(
(p) => p.createdAt >= prevMonthStart && p.createdAt <= prevMonthEnd,
).length;
const salesCur = salesInRange(curMonthStart, curMonthEnd);
const salesPrev = salesInRange(prevMonthStart, prevMonthEnd);
const targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount),
0,
@@ -1716,7 +1817,7 @@ export class ReportsService {
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
[];
for (let i = 4; i >= 0; i -= 1) {
for (let i = 11; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
@@ -1729,14 +1830,28 @@ export class ReportsService {
const salesTrend = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
amount: Math.round(sumWonInRange(m.start, m.end)),
amount: salesInRange(m.start, m.end),
}));
const customerGrowth = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
value: peopleRecent.filter(
(p) => p.createdAt >= m.start && p.createdAt <= m.end,
).length,
}));
const sourceMap = new Map<string, number>();
for (const p of peopleRecent) {
const label = normalizeImportSource(p.importSource);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
if (sourceMap.size === 0) {
for (const d of wonDeals) {
const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
}
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources = [...sourceMap.entries()]
.map(([label, count]) => ({
@@ -1780,6 +1895,101 @@ export class ReportsService {
.slice(0, 5)
.map((row, index) => ({ ...row, rank: index + 1 }));
const wonAmountAll = Math.round(
wonDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const lostAmountAll = Math.round(
lostDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const openAmountAll = Math.round(
openDealRows.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const closedCount = wonDeals.length + lostDeals.length;
const winRate = closedCount
? Math.round((wonDeals.length / closedCount) * 1000) / 10
: 0;
const salesSummary: HomeDashboardDto['salesSummary'] = {
openCount: openDeals,
wonCount: wonDeals.length,
lostCount: lostDeals.length,
openAmount: openAmountAll,
wonAmount: wonAmountAll,
lostAmount: lostAmountAll,
winRate,
};
const funnelMap = new Map<
string,
{ stageId: string; stageName: string; position: number; count: number; amount: number }
>();
for (const d of openDealRows) {
const stageId = d.stage?.id ?? d.stageId ?? 'unknown';
const stageName = d.stage?.name ?? 'بدون مرحله';
const position = d.stage?.position ?? 999;
const cur = funnelMap.get(stageId) ?? {
stageId,
stageName,
position,
count: 0,
amount: 0,
};
cur.count += 1;
cur.amount += decimalToBase(d.amount, d.currency, rates);
funnelMap.set(stageId, cur);
}
const funnel = [...funnelMap.values()]
.sort((a, b) => a.position - b.position)
.slice(0, 8)
.map((row) => ({
stageId: row.stageId,
stageName: row.stageName,
count: row.count,
amount: Math.round(row.amount),
}));
const lostReasonMap = new Map<string, { count: number; amount: number }>();
for (const d of lostDeals) {
const label = normalizeLostReason(d.lostReason);
const prevRow = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
prevRow.count += 1;
prevRow.amount += decimalToBase(d.amount, d.currency, rates);
lostReasonMap.set(label, prevRow);
}
const lostReasonTotal =
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
const lostReasons = [...lostReasonMap.entries()]
.map(([label, row]) => ({
label,
count: row.count,
amount: Math.round(row.amount),
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const activityTypeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const activityByType = activityTypeGroups
.map((row) => ({
type: row.type,
label: activityTypeLabel[row.type] ?? row.type,
count: row._count._all,
}))
.sort((a, b) => b.count - a.count);
const alerts: HomeDashboardDto['alerts'] = [];
if (staleDeals.length) {
const first = staleDeals[0]!;
@@ -1857,11 +2067,30 @@ export class ReportsService {
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
changeLabel: 'نسبت به دیروز',
},
{
key: 'win-rate',
label: 'نرخ برد',
value: winRate,
changePercent: null,
changeLabel: '۱۲ ماه اخیر',
},
{
key: 'overdue',
label: 'عقب‌افتاده',
value: overdueCount,
changePercent: null,
changeLabel: 'فعالیت باز',
},
],
alerts,
salesTrend,
customerGrowth,
sources,
topCustomers,
salesSummary,
funnel,
lostReasons,
activityByType,
};
}
}
@@ -1919,6 +2148,21 @@ function normalizeSource(raw: string | null | undefined): string {
return map[value.toLowerCase()] ?? value;
}
function normalizeImportSource(raw: string | null | undefined): string {
const value = raw?.trim().toLowerCase();
if (!value) return 'سایر';
const map: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
didar: 'دیدار',
excel: 'اکسل',
phone: 'تلفن',
lead_form: 'فرم لید',
other: 'سایر',
};
return map[value] ?? normalizeSource(raw);
}
function normalizeLostReason(raw: string | null | undefined): string {
const value = raw?.trim();
if (!value) return 'بدون دلیل ثبت‌شده';
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>منتکت | Montact CRM</title>
<link rel="icon" type="image/png" href="/brand/logo.png" />
<link rel="icon" type="image/svg+xml" href="/brand/logo.svg" />
<style>
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */
.app-frame {
+2
View File
@@ -22,7 +22,9 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^5.2.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
+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 { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { ThunderboltOutlined } from '@/icons/ant-bridge';
const { Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{
personId?: string;
@@ -65,127 +77,53 @@ function insertAnswer() {
</script>
<template>
<section class="ask">
<form @submit.prevent="submit">
<UiIcon name="sparkle" :size="16" class="ico" />
<input
v-model="question"
type="search"
<a-flex vertical :gap="8" :style="{ minWidth: 0 }">
<a-flex :gap="8" align="center" :style="{ width: '100%' }">
<a-input
v-model:value="question"
allow-clear
:placeholder="placeholder || brand.askPlaceholderFa"
/>
<button type="submit" :disabled="asking || question.trim().length < 2">
{{ asking ? '…' : 'بپرس' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-else-if="result" class="answer">
<p>{{ result.answer }}</p>
<div v-if="result.sources.length" class="sources">
:style="{ flex: 1, minWidth: 0 }"
@pressEnter="submit"
>
<template #prefix>
<ThunderboltOutlined />
</template>
</a-input>
<a-button
type="primary"
:disabled="asking || question.trim().length < 2"
:loading="asking"
@click="submit"
>
بپرس
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card v-else-if="result" :bordered="false">
<a-typography-paragraph :style="{ marginBottom: 0 }">
{{ result.answer }}
</a-typography-paragraph>
<a-space v-if="result.sources.length" wrap :size="6" :style="{ marginTop: '8px' }">
<RouterLink
v-for="source in result.sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
>
{{ source.label }}
<a-tag color="cyan" :bordered="false">{{ source.label }}</a-tag>
</RouterLink>
</div>
<button
</a-space>
<a-button
v-if="allowInsert && result.answer.trim()"
type="button"
class="insert-btn"
type="primary"
block
:style="{ marginTop: '8px' }"
@click="insertAnswer"
>
درج در پیام
</button>
</div>
</section>
</a-button>
</a-card>
</a-flex>
</template>
<style scoped>
.ask {
display: grid;
gap: 0.55rem;
min-width: 0;
}
form {
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
.ico {
color: #0f766e;
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.45rem 0;
}
button {
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
padding: 0.45rem 0.8rem;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
}
.answer p {
margin: 0;
color: #134e4a;
font-size: 0.88rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.55rem;
}
.sources a {
font-size: 0.74rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.18rem 0.55rem;
}
.insert-btn {
margin-top: 0.65rem;
width: 100%;
background: #0f766e;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>
@@ -8,11 +8,11 @@ import {
InputNumber as AInputNumber,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
@@ -73,7 +73,8 @@ onMounted(load);
</script>
<template>
<a-spin v-if="loading" />
<CrmSkeleton :loading="loading">
<CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
<template v-else-if="fields.length">
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
فیلدهای سفارشی
@@ -130,4 +131,5 @@ onMounted(load);
</a-col>
</a-row>
</template>
</CrmSkeleton>
</template>
+62 -98
View File
@@ -1,6 +1,17 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import {
Alert as AAlert,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
FormItem as AFormItem,
Radio as ARadio,
RadioGroup as ARadioGroup,
Select as ASelect,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import type {
DataAccessOwnerKind,
DataAccessRule,
@@ -15,9 +26,12 @@ import {
PERSON_MOODS,
sectionHasDataAccessLimits,
} from '@mento/shared';
export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string };
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
@@ -429,53 +443,59 @@ const filterSummary = computed(() => {
</script>
<template>
<div class="dae" :class="{ compact }">
<div class="dae-mode">
<label>
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
همهٔ {{ sectionLabel }}
</label>
<label>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<a-flex vertical :gap="compact ? 8 : 12">
<a-radio-group v-model:value="mode" :disabled="disabled">
<a-flex vertical :gap="4">
<a-radio value="all">همهٔ {{ sectionLabel }}</a-radio>
<a-radio value="rules">محدود با فیلتر</a-radio>
</a-flex>
</a-radio-group>
<template v-if="mode === 'rules'">
<p class="hint">
<a-typography-text type="secondary">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
</p>
<p class="summary">{{ filterSummary }}</p>
</a-typography-text>
<a-alert
v-if="hadMultipleRules"
type="warning"
show-icon
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود."
/>
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
<fieldset :disabled="disabled">
<legend>مالک / مسئول</legend>
<p class="muted">خالی = بدون محدودیت مالک</p>
<label class="chip">
<input
type="checkbox"
<a-card size="small" title="مالک / مسئول" :bordered="true">
<a-flex vertical :gap="8">
<a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text>
<a-space wrap :size="8">
<a-checkbox
:checked="filter.owners?.includes('self')"
:disabled="disabled"
@change="toggleOwner('self')"
/>
>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
</a-checkbox>
<a-checkbox
:checked="filter.owners?.includes('team')"
:disabled="disabled"
@change="toggleOwner('team')"
/>
>
تیم‌های عضو
</label>
</fieldset>
</a-checkbox>
</a-space>
</a-flex>
</a-card>
<label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span>
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
<a-form-item
v-for="picker in pickers"
:key="picker.legend"
:label="picker.legend"
:style="{ marginBottom: 0 }"
>
<a-typography-text v-if="picker.loading" type="secondary">
در حال بارگذاری منبع‌ها…
</a-typography-text>
<a-select
v-else
:value="pickerValue(picker)"
@@ -490,70 +510,14 @@ const filterSummary = computed(() => {
style="width: 100%"
@update:value="setList(picker.key, $event)"
/>
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
</label>
<a-typography-text v-if="picker.hint" type="secondary">
{{ picker.hint }}
</a-typography-text>
</a-form-item>
</template>
<p v-else-if="!hasLimits" class="hint">
<a-typography-text v-else-if="!hasLimits" type="secondary">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p>
</div>
</a-typography-text>
</a-flex>
</template>
<style scoped>
.dae {
display: grid;
gap: 0.75rem;
}
.dae-mode {
display: grid;
gap: 0.35rem;
}
.dae-mode label,
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
margin-inline-end: 0.55rem;
margin-bottom: 0.25rem;
}
.hint,
.muted,
.summary {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.summary {
font-weight: 600;
color: #334155;
}
fieldset {
border: 1px dashed #e2e8f0;
border-radius: 8px;
padding: 0.5rem 0.65rem;
margin: 0;
}
legend {
padding-inline: 0.25rem;
font-size: 0.8rem;
color: #475569;
font-weight: 600;
}
.pick {
display: grid;
gap: 0.35rem;
}
.pick > span {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
.compact fieldset {
padding: 0.45rem 0.55rem;
}
</style>
+99 -173
View File
@@ -1,5 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import {
Tabs as ATabs,
TabPane as ATabPane,
Typography,
Radio as ARadio,
RadioGroup as ARadioGroup,
Checkbox as ACheckbox,
Space as ASpace,
Input as AInput,
} from 'ant-design-vue';
import type {
DataAccessPolicy,
DataAccessSection,
@@ -19,6 +29,8 @@ import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{
modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
@@ -115,6 +127,10 @@ function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next);
}
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
}
function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key)
@@ -229,195 +245,105 @@ onMounted(() => {
void loadOptions();
void loadTreasuryAccounts();
});
const editorSection = computed<DataAccessSection>(() =>
active.value === 'treasury' ? 'customers' : active.value,
);
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
if (active.value === 'activities') return activityStages.value;
if (active.value === 'projects') return projectStages.value;
return [];
});
const boardsForActive = computed(() => {
if (active.value === 'activities') return activityBoards.value;
if (active.value === 'projects') return projectBoards.value;
return [];
});
const typesForActive = computed(() => {
if (active.value === 'activities') return activityTypes.value;
if (active.value === 'routines') return routineCategories.value;
return [];
});
</script>
<template>
<div class="dap">
<p v-if="!visibleSections.length" class="muted">
<div>
<a-typography-text v-if="!visibleSections.length" type="secondary">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</p>
<template v-else>
<div class="tabs" role="tablist">
<button
</a-typography-text>
<a-tabs v-else v-model:activeKey="active">
<a-tab-pane
v-for="s in visibleSections"
:key="s"
type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
:tab="DATA_ACCESS_SECTION_LABELS[s]"
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
<button
v-if="showTreasury"
type="button"
class="tab"
:class="{ on: active === 'treasury' }"
role="tab"
:aria-selected="active === 'treasury'"
@click="active = 'treasury'"
>
مدیریت مالی
</button>
</div>
<div v-if="active === 'treasury'" class="treasury">
<p class="muted">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</p>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess == null"
:disabled="disabled"
@change="patchTreasury(null)"
/>
مثل نقش
</label>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess != null"
:disabled="disabled"
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
/>
سفارشی برای این کاربر
</label>
<template v-if="treasuryAccess">
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
/>
دسترسی به مدیریت مالی
</label>
<template v-if="treasuryAccess.enabled">
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
<input
type="checkbox"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
/>
{{ perm.label }}
</label>
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="muted">حساب‌های دیگر</p>
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
<input
type="checkbox"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
</template>
</template>
</div>
<DataAccessEditor
v-else
:key="active"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:section="s"
:model-value="sectionValue(s)"
:groups="customerGroups"
:stages="stagesForActive"
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
:activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(editorSection, $event)"
@update:model-value="setSection(s, $event)"
/>
</a-tab-pane>
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
<a-space direction="vertical">
<a-typography-text type="secondary">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</a-typography-text>
<a-radio-group
:value="treasuryAccess == null ? 'role' : 'custom'"
:disabled="disabled"
@change="onTreasuryModeChange"
>
<a-space direction="vertical">
<a-radio value="role">مثل نقش</a-radio>
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
</a-space>
</a-radio-group>
<template v-if="treasuryAccess">
<a-checkbox
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
>
دسترسی به مدیریت مالی
</a-checkbox>
<template v-if="treasuryAccess.enabled">
<a-space direction="vertical">
<a-checkbox
v-for="perm in treasurySections"
:key="perm.key"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
>
{{ perm.label }}
</a-checkbox>
</a-space>
<a-checkbox
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
>
حساب‌هایی که خودش مسئول آن‌هاست
</a-checkbox>
<a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<a-input
v-model:value="treasuryAccountQuery"
placeholder="جستجوی حساب"
:disabled="disabled"
allow-clear
/>
<a-space direction="vertical">
<a-checkbox
v-for="account in filteredTreasuryAccounts"
:key="account.id"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</a-checkbox>
</a-space>
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
حسابی برای انتخاب نیست.
</a-typography-text>
</template>
</template>
</a-space>
</a-tab-pane>
</a-tabs>
</div>
</template>
<style scoped>
.dap {
display: grid;
gap: 0.75rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tab {
font: inherit;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: #475569;
}
.tab.on {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
.treasury {
display: grid;
gap: 0.45rem;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem;
align-items: center;
}
.choice.sub {
padding-inline-start: 1.2rem;
}
</style>
+5 -3
View File
@@ -10,11 +10,11 @@ import {
Input as AInput,
List as AList,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item;
@@ -259,7 +259,8 @@ onMounted(load);
:style="{ marginTop: '12px' }"
/>
<a-spin :spinning="loading">
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="5">
<a-list
v-if="items.length"
item-layout="horizontal"
@@ -304,6 +305,7 @@ onMounted(load);
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }"
/>
</a-spin>
</CrmSkeletonList>
</CrmSkeleton>
</a-card>
</template>
@@ -0,0 +1,568 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ExcelFieldOption,
ExcelImportPrepareStatus,
ExcelPreviewResult,
ImportContactsResult,
} from '@mento/shared';
import {
previewExcelClient,
readMappedRowsClient,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
previewPath: string;
importPath: string;
/** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */
preparePath?: string;
/**
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده می‌شود و فقط JSON کوچک به API می‌رود.
* برای فایل‌های ده‌ها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
*/
rowsImportPath?: string;
clientAliases?: Record<string, string>;
clientFields?: ExcelFieldOption[];
/** تشخیص خروجی بدون‌هدر (مثل فعالیت دیدار) */
clientReadOptions?: ClientExcelReadOptions;
disabled?: boolean;
}>();
const useClientImport = computed(
() =>
Boolean(
props.rowsImportPath &&
props.clientAliases &&
props.clientFields?.length,
),
);
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
fail: [message: string];
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
const submitting = ref(false);
const phase = ref<'idle' | 'preview' | 'import'>('idle');
const progressLabel = ref('');
/** درصد ۰–۱۰۰؛ در مرحلهٔ خواندن فایل null */
const progressPct = ref<number | null>(null);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
const busyMessage = computed(() => {
if (phase.value === 'import') {
return progressLabel.value || 'در حال ورود اطلاعات از اکسل…';
}
return 'در حال خواندن و بررسی فایل اکسل…';
});
const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
phase.value = 'idle';
progressLabel.value = '';
progressPct.value = null;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
if (submitting.value) return;
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
function mergeImportResults(
acc: ImportContactsResult,
part: ImportContactsResult,
): ImportContactsResult {
const errors = [...acc.errors, ...(part.errors ?? [])];
return {
created: acc.created + (part.created ?? 0),
skipped: acc.skipped + (part.skipped ?? 0),
errors: errors.slice(0, 200),
boardId: part.boardId ?? acc.boardId,
boards: part.boards?.length ? part.boards : acc.boards,
sessionId: part.sessionId,
total: part.total ?? acc.total,
processed: part.processed ?? acc.processed,
hasMore: part.hasMore,
nextOffset: part.nextOffset,
};
}
async function postImportChunk(opts: {
file?: File;
map?: Record<string, string>;
sessionId?: string;
offset?: number;
}) {
const form = new FormData();
if (opts.file) form.append('file', opts.file);
if (opts.map && Object.values(opts.map).some(Boolean)) {
form.append('mapping', JSON.stringify(opts.map));
}
if (opts.sessionId) form.append('sessionId', opts.sessionId);
if (opts.offset != null) form.append('offset', String(opts.offset));
form.append('limit', '250');
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
// خواندن فایل‌های خیلی بزرگ در مرحلهٔ اول ممکن است طول بکشد
timeout: opts.file ? 10 * 60 * 1000 : 3 * 60 * 1000,
});
return data;
}
function updateImportProgress(processed: number, total: number) {
if (total <= 0) {
progressPct.value = null;
progressLabel.value = 'در حال ورود اطلاعات از اکسل…';
return;
}
const pct = Math.max(0, Math.min(100, Math.round((processed / total) * 100)));
progressPct.value = pct;
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function postPrepare(file: File, map?: Record<string, string>) {
if (!props.preparePath) {
throw new Error('preparePath تنظیم نشده');
}
const form = new FormData();
form.append('file', file);
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
// فقط آپلود — پارس روی سرور ناهمگام است؛ تایم‌اوت کوتاه کافی است
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
headers: { 'Content-Type': undefined },
timeout: 3 * 60 * 1000,
});
return data;
}
async function waitPrepareReady(sessionId: string) {
if (!props.preparePath) throw new Error('preparePath تنظیم نشده');
const statusUrl = `${props.preparePath}/${sessionId}`;
const started = Date.now();
const maxMs = 15 * 60 * 1000;
while (Date.now() - started < maxMs) {
const { data } = await api.get<ExcelImportPrepareStatus>(statusUrl, {
timeout: 30_000,
});
if (data.status === 'ready') {
progressLabel.value = `فایل آماده شد — ${data.total.toLocaleString('fa-IR')} ردیف`;
return data;
}
if (data.status === 'error') {
throw new Error(data.error || 'آماده‌سازی فایل اکسل ناموفق بود');
}
const parsed = data.parsed || 0;
progressPct.value = null;
progressLabel.value = parsed
? `در حال خواندن فایل روی سرور… ${parsed.toLocaleString('fa-IR')} ردیف`
: 'در حال خواندن فایل روی سرور… لطفاً صبر کنید';
await sleep(1200);
}
throw new Error('آماده‌سازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
}
async function runClientRowsImport(file: File, map?: Record<string, string>) {
if (!props.rowsImportPath || !props.clientAliases) {
throw new Error('پیکربندی ایمپورت مرورگر ناقص است');
}
phase.value = 'import';
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
progressPct.value = 0;
const rows = await readMappedRowsClient(
file,
props.clientAliases,
map,
props.clientReadOptions,
);
const total = rows.length;
if (total > 100_000) {
return {
created: 0,
skipped: 0,
errors: [{ row: 1, message: 'فایل بیش از ۱۰۰۰۰۰ ردیف دارد. فایل را تقسیم کنید.' }],
total,
processed: 0,
hasMore: false,
};
}
updateImportProgress(0, total);
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
const chunkSize = 60;
for (let offset = 0; offset < total; offset += chunkSize) {
const slice = rows.slice(offset, offset + chunkSize);
const { data: part } = await api.post<ImportContactsResult>(
props.rowsImportPath,
{
rows: slice,
offset,
total,
sessionId,
},
{ timeout: 120_000 },
);
acc = mergeImportResults(acc, part);
updateImportProgress(part.processed ?? offset + slice.length, total);
if (part.errors?.some((e) => e.row === 1 && /حداکثر|بیش از/.test(e.message))) {
break;
}
sessionId = part.sessionId;
if (!part.hasMore) break;
}
updateImportProgress(total, total);
return acc;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
if (useClientImport.value) {
return runClientRowsImport(file, map);
}
phase.value = 'import';
progressLabel.value = 'در حال ارسال فایل…';
progressPct.value = 0;
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
let offset = 0;
let guard = 0;
const maxChunks = 2000;
if (props.preparePath) {
const prep = await postPrepare(file, map);
if (!prep.sessionId) {
throw new Error(prep.errors[0]?.message || 'آماده‌سازی فایل ناموفق بود');
}
sessionId = prep.sessionId;
progressLabel.value = 'در حال خواندن فایل روی سرور…';
const ready = await waitPrepareReady(sessionId);
if (!ready.total) {
return {
...acc,
total: 0,
processed: 0,
hasMore: false,
errors: ready.error
? [{ row: 1, message: ready.error }]
: [{ row: 1, message: 'هیچ ردیف داده‌ای در فایل نیست' }],
};
}
updateImportProgress(0, ready.total);
}
while (guard < maxChunks) {
guard += 1;
const part = await postImportChunk({
file: sessionId ? undefined : file,
map: sessionId ? undefined : map,
sessionId,
offset: sessionId ? offset : undefined,
});
acc = mergeImportResults(acc, part);
const total = part.total ?? 0;
const processed = part.processed ?? 0;
updateImportProgress(processed, total);
if (
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
) {
break;
}
if (!part.hasMore || !part.sessionId) {
if (total > 0) updateImportProgress(total, total);
break;
}
sessionId = part.sessionId;
offset = part.nextOffset ?? processed;
}
if (guard >= maxChunks && acc.hasMore) {
acc.errors = [
...acc.errors,
{
row: 1,
message: 'ورود به‌خاطر تعداد مراحل زیاد متوقف شد. از جایی که مانده ادامه دهید یا فایل را تقسیم کنید.',
},
];
}
return acc;
}
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) {
return e instanceof Error && e.message ? e.message : fallback;
}
if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message)) {
return 'مهلت درخواست تمام شد — فایل خیلی بزرگ است یا سرور دیر جواب داد. دوباره تلاش کنید.';
}
if (!e.response) {
return 'ارتباط با سرور برقرار نشد. اتصال را بررسی کنید و دوباره تلاش کنید.';
}
const status = e.response.status;
const data = e.response.data as { message?: string | string[] } | string | undefined;
let msg: string | undefined;
if (typeof data === 'string' && data.trim() && !data.trim().startsWith('<')) {
msg = data.trim().slice(0, 280);
} else if (data && typeof data === 'object') {
const m = data.message;
msg = Array.isArray(m) ? m.join('، ') : m;
}
if (msg) return msg;
if (status === 413) return 'حجم فایل بیش از حد مجاز سرور است.';
if (status === 502 || status === 504) {
return 'سرور میانی قطع شد (معمولاً به‌خاطر حجم/زمان فایل). چند دقیقه بعد دوباره تلاش کنید.';
}
return `${fallback} (کد ${status})`;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
try {
let data: ExcelPreviewResult;
if (useClientImport.value && props.clientAliases && props.clientFields) {
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
data = await previewExcelClient(
file,
props.clientAliases,
props.clientFields,
props.clientReadOptions,
);
} else {
const form = new FormData();
form.append('file', file);
const res = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
data = res.data;
}
if (!data.needsMapping) {
try {
emit('done', await runChunkedImport(file));
reset();
emit('settled');
} catch (impErr) {
emit('fail', apiMessage(impErr, 'ایمپورت ناموفق بود'));
reset();
emit('settled');
}
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'import';
try {
emit('done', await runChunkedImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
phase.value = 'idle';
submitting.value = false;
progressLabel.value = '';
progressPct.value = null;
emit('settled');
}
}
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<slot :loading="submitting" :disabled="disabled || submitting" />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
@change="onFile"
@click.stop
/>
</span>
<UiBusyOverlay
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
:disabled="submitting"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
ورود فایل
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.excel-import.isBusy {
pointer-events: none;
opacity: 0.7;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.large-hint {
margin: -0.35rem 0 1rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
color: var(--fg, inherit);
font-size: 0.88rem;
line-height: 1.6;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
@@ -8,7 +8,7 @@ import {
Space as ASpace,
Typography,
} from 'ant-design-vue';
import { PrinterOutlined } from '@ant-design/icons-vue';
import { PrinterOutlined } from '@/icons/ant-bridge';
import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money';
+60 -109
View File
@@ -6,7 +6,7 @@ import {
ClockCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
addJalaliMonths,
dateToJalali,
@@ -52,8 +52,6 @@ const hourColRef = ref<HTMLElement | null>(null);
const minuteColRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
/** بالای مودال Ant (حدود ۱۰۰۰) تا تقویم پشت پنجره نماند */
const popupZ = ref(120);
const todayJ = dateToJalali(new Date());
const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm });
const draftHour = ref(9);
@@ -320,46 +318,21 @@ function place() {
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width =
panel?.offsetWidth ||
(props.mode === 'time' ? (isMobile.value ? 340 : 280) : 328);
const height =
panel?.offsetHeight ||
(props.mode === 'time' ? (isMobile.value ? 460 : 320) : 340);
const margin = 8;
const width = panel?.offsetWidth || (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 320);
const height = panel?.offsetHeight || (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 360);
let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width;
}
nextLeft = Math.min(
Math.max(margin, nextLeft),
Math.max(margin, window.innerWidth - width - margin),
);
const spaceBelow = window.innerHeight - rect.bottom - margin * 2;
const spaceAbove = rect.top - margin * 2;
let nextTop: number;
if (height <= spaceBelow) {
nextTop = rect.bottom + margin;
} else if (height <= spaceAbove) {
nextTop = rect.top - height - margin;
} else if (spaceBelow >= spaceAbove) {
nextTop = Math.max(margin, window.innerHeight - height - margin);
} else {
nextTop = margin;
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
popupZ.value = z;
}
function onWindowChange() {
syncViewport();
if (open.value) place();
@@ -371,7 +344,6 @@ async function show() {
calView.value = 'days';
clockFace.value = 'hour';
syncViewport();
liftAboveOverlays();
open.value = true;
await nextTick();
place();
@@ -566,7 +538,7 @@ onBeforeUnmount(() => {
'is-desktop': !isMobile,
}"
role="dialog"
:style="{ top: `${top}px`, left: `${left}px`, zIndex: popupZ }"
:style="{ top: `${top}px`, left: `${left}px` }"
>
<header v-if="mode !== 'time'" class="nav">
<a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)">
@@ -754,7 +726,7 @@ onBeforeUnmount(() => {
>
پاک کردن
</a-button>
<a-button type="primary" @click="hide">تأیید</a-button>
<a-button type="primary" class="done" @click="hide">تأیید</a-button>
</footer>
</div>
</Teleport>
@@ -879,11 +851,10 @@ onBeforeUnmount(() => {
.panel {
position: fixed;
z-index: 120;
box-sizing: border-box;
width: min(20.5rem, calc(100vw - 16px));
/* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */
max-height: none;
overflow: hidden;
max-height: calc(100vh - 16px);
overflow-x: hidden;
overflow-y: auto;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
@@ -891,13 +862,6 @@ onBeforeUnmount(() => {
font-family: IRANSans, Tahoma, sans-serif;
}
@media (max-height: 420px) {
.panel {
max-height: calc(100vh - 16px);
overflow: auto;
}
}
.panel-time {
width: min(22.5rem, calc(100vw - 16px));
}
@@ -929,40 +893,45 @@ onBeforeUnmount(() => {
.pick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.25rem 0;
}
.panel :deep(.pick-cell.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 44px !important;
padding-inline: 0 !important;
.panel :deep(.ant-btn.pick-cell) {
width: 100%;
min-width: 0;
height: 44px;
margin: 0;
padding: 0 4px;
border-radius: var(--radius);
color: #0f172a;
font-size: 0.85rem;
}
.panel :deep(.pick-cell.today) {
.panel :deep(.ant-btn.pick-cell.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.pick-cell.on),
.panel :deep(.pick-cell.ant-btn:hover) {
.panel :deep(.ant-btn.pick-cell.on),
.panel :deep(.ant-btn.pick-cell:hover) {
background: #0d9488;
color: #fff;
}
.nav-btn {
.panel :deep(.ant-btn.nav-btn) {
width: 32px;
min-width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f766e;
display: grid;
place-items: center;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
@@ -975,7 +944,6 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.15rem;
width: 100%;
min-width: 0;
}
@@ -986,43 +954,40 @@ onBeforeUnmount(() => {
padding-bottom: 0.25rem;
}
.panel :deep(.day.ant-btn) {
width: 100% !important;
min-width: 0 !important;
height: 34px !important;
padding: 0 !important;
.panel :deep(.ant-btn.day) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 0;
height: 34px;
margin: 0;
padding: 0;
line-height: 1;
border: 0;
border-radius: var(--radius);
background: none;
color: #0f172a;
font: inherit;
font-size: 0.82rem;
line-height: 1;
cursor: pointer;
box-shadow: none;
}
.panel :deep(.day.ant-btn > span) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.panel :deep(.day.mute) {
.panel :deep(.ant-btn.day.mute) {
color: #cbd5e1;
}
.panel :deep(.day.today) {
.panel :deep(.ant-btn.day.today) {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.panel :deep(.day.on),
.panel :deep(.day.ant-btn:hover) {
.panel :deep(.ant-btn.day.on),
.panel :deep(.ant-btn.day:hover) {
background: #0d9488;
color: #fff;
}
.panel :deep(.day.mute:hover) {
.panel :deep(.ant-btn.day.mute:hover) {
background: #f1f5f9;
color: #64748b;
}
@@ -1052,9 +1017,11 @@ onBeforeUnmount(() => {
margin-bottom: 0.85rem;
}
.md-chip {
.panel :deep(.ant-btn.md-chip) {
min-width: 5.1rem;
height: 4.1rem;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: #f1f5f9;
@@ -1067,7 +1034,7 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.md-chip.on {
.panel :deep(.ant-btn.md-chip.on) {
background: #ccfbf1;
color: #0f766e;
box-shadow: inset 0 0 0 2px #99f6e4;
@@ -1141,10 +1108,11 @@ onBeforeUnmount(() => {
pointer-events: none;
}
.md-tick {
.panel :deep(.ant-btn.md-tick) {
position: absolute;
z-index: 2;
width: 36px;
min-width: 36px;
height: 36px;
margin: 0;
border: 0;
@@ -1161,12 +1129,12 @@ onBeforeUnmount(() => {
padding: 0;
}
.md-tick.inner {
.panel :deep(.ant-btn.md-tick.inner) {
font-size: 0.74rem;
color: #64748b;
}
.md-tick.on {
.panel :deep(.ant-btn.md-tick.on) {
background: transparent;
color: #fff;
}
@@ -1215,9 +1183,12 @@ onBeforeUnmount(() => {
overscroll-behavior: contain;
}
.col-item {
.panel :deep(.ant-btn.col-item) {
width: 100%;
min-width: 0;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--radius);
background: transparent;
@@ -1228,11 +1199,11 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.col-item:hover {
.panel :deep(.ant-btn.col-item:hover) {
background: #e2e8f0;
}
.col-item.on {
.panel :deep(.ant-btn.col-item.on) {
background: #0d9488;
color: #fff;
}
@@ -1244,28 +1215,8 @@ onBeforeUnmount(() => {
margin-top: 0.7rem;
}
.link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
.panel :deep(.ant-btn.done) {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: var(--radius);
padding: 0.4rem 0.8rem;
font-weight: 600;
font-size: 0.8rem;
}
.panel.is-mobile .time-box {
+5 -3
View File
@@ -9,10 +9,10 @@ import {
List as AList,
Modal,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
@@ -163,7 +163,8 @@ onMounted(load);
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<a-spin :spinning="loading">
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="4">
<a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده.
</a-typography-text>
@@ -201,6 +202,7 @@ onMounted(load);
</a-list-item>
</template>
</a-list>
</a-spin>
</CrmSkeletonList>
</CrmSkeleton>
</a-card>
</template>
+94 -165
View File
@@ -5,6 +5,20 @@ import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } =
Typography;
const props = defineProps<{
noteId: string;
@@ -148,193 +162,108 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
</script>
<template>
<section class="ai-panel">
<header>
<strong>پیشنهاد {{ brand.aiName }}</strong>
<button
<a-card :bordered="true" :style="{ marginTop: '12px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text>
<a-button
v-if="!isPending"
type="button"
class="link"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? '…' : 'تحلیل مجدد' }}
</button>
<button
</a-button>
<a-button
v-else-if="!suggestion"
type="button"
class="link"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</button>
</header>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
<p v-else-if="suggestion?.status === 'failed'" class="error">
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
<button type="button" class="link" @click="analyze">تلاش دوباره</button>
</p>
<p v-else-if="!suggestion" class="muted">
<button type="button" class="link" :disabled="loading" @click="analyze">
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" />
<a-typography-text v-else-if="loading && !suggestion" type="secondary">
در حال تحلیل یادداشت…
</a-typography-text>
<a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4">
<a-alert
type="error"
show-icon
:message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
/>
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
</a-flex>
<a-button
v-else-if="!suggestion"
type="link"
:disabled="loading"
@click="analyze"
>
تحلیل این یادداشت با AI
</button>
</p>
</a-button>
<template v-else-if="isPending && payload">
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
<a-typography-paragraph
v-if="formatPayloadSummary(payload)"
type="secondary"
:style="{ marginTop: '8px' }"
>
{{ formatPayloadSummary(payload) }}
</a-typography-paragraph>
<div v-if="payload.nextAction" class="block">
<label class="check">
<input v-model="applyNextAction" type="checkbox" />
کار بعدی
</label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div>
<a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }">
<a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox>
<a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" />
</a-flex>
<div v-if="memoryItems.length" class="block">
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p>
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
<label
<a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">
حافظه مشتری (با تأیید شما ذخیره می‌شود)
</a-typography-title>
<a-alert
v-if="!hasPerson"
type="warning"
show-icon
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
/>
<a-flex
v-for="(_, index) in memoryItems"
:key="index"
class="memory-row"
:gap="8"
align="flex-start"
>
<input
v-model="selectedMemory[index]"
type="checkbox"
<a-checkbox
v-model:checked="selectedMemory[index]"
:disabled="!hasPerson"
/>
<textarea v-model="memoryEdits[index]" rows="2" />
</label>
</div>
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
</a-flex>
</a-flex>
<div class="actions">
<button type="button" class="btn" :disabled="applying" @click="apply">
{{ applying ? '…' : 'اعمال انتخاب‌شده' }}
</button>
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
رد کردن
</button>
</div>
<a-space :size="8" wrap :style="{ marginTop: '12px' }">
<a-button type="primary" :disabled="applying" :loading="applying" @click="apply">
اعمال انتخاب‌شده
</a-button>
<a-button :disabled="applying" @click="dismiss">رد کردن</a-button>
</a-space>
</template>
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
</section>
<a-typography-text
v-else-if="suggestion?.status === 'applied'"
type="success"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد اعمال شد.
</a-typography-text>
<a-typography-text
v-else-if="suggestion?.status === 'dismissed'"
type="secondary"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد رد شد.
</a-typography-text>
</a-card>
</template>
<style scoped>
.ai-panel {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
min-width: 0;
overflow-wrap: anywhere;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.link {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.block {
display: grid;
gap: 0.45rem;
}
.block-title {
margin: 0;
font-weight: 600;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
.memory-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem;
align-items: start;
}
input[type='text'],
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.actions .btn {
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 520px) {
.actions {
flex-direction: column;
}
.actions .btn,
.actions .btn-ghost {
width: 100%;
}
}
.summary {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.muted {
margin: 0;
color: var(--muted);
}
.ok {
color: #067647;
}
.warn {
margin: 0;
color: #b54708;
font-size: 0.88rem;
}
.error {
margin: 0;
color: #b42318;
}
</style>
+290 -133
View File
@@ -3,35 +3,59 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import type { OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
const props = withDefaults(
defineProps<{
compact?: boolean;
variant?: 'default' | 'workspace';
}>(),
{
compact: false,
variant: 'default',
},
);
const emit = defineEmits<{
open: [];
close: [];
}>();
const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false);
const createOpen = ref(false);
const loading = ref(false);
const creating = ref(false);
const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0;
const currentName = computed(
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
() =>
auth.user?.organizationName ||
orgs.value.find((o) => o.id === auth.user?.organizationId)?.name ||
'محیط کاری',
);
const currentSubtitle = computed(() => {
const current = orgs.value.find((o) => o.id === auth.user?.organizationId);
if (current?.orgRoleName) return current.orgRoleName;
if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role];
return 'محیط کاری';
});
const currentInitial = computed(() => {
const name = currentName.value.trim();
return name ? name.charAt(0) : 'م';
});
const canSwitch = computed(() => orgs.value.length > 1);
const isWorkspace = computed(() => props.variant === 'workspace');
/** در حالت workspace منو همیشه باز می‌شود؛ در حالت پیش‌فرض فقط وقتی بیش از یکی باشد */
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
@@ -49,34 +73,62 @@ async function loadOrgs() {
}
}
function closeMenu() {
if (!open.value) return;
open.value = false;
emit('close');
}
async function toggle() {
if (!canSwitch.value) return;
if (!menuEnabled.value) return;
error.value = '';
if (open.value) {
open.value = false;
closeMenu();
return;
}
ignoreDocClickUntil = Date.now() + 400;
open.value = true;
emit('open');
await nextTick();
placeMenu();
await loadOrgs();
await nextTick();
placeMenu();
}
function placeMenu() {
const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect();
const minWidth = Math.round(Math.max(box.width, isWorkspace.value ? 240 : 200));
const sidebar = document.getElementById('app-sidebar');
const rail = sidebar?.getBoundingClientRect();
// لبهٔ راست منو = لبهٔ چپ سایدبار − ۴px
const edge = rail?.left ?? box.left;
const beside = {
right: 'auto',
left: `${Math.round(edge - 4)}px`,
transform: 'translateX(-100%)',
minWidth: `${minWidth}px`,
};
if (isWorkspace.value) {
menuStyle.value = {
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
right: `${Math.round(window.innerWidth - box.right)}px`,
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
...beside,
top: `${Math.round(box.top)}px`,
bottom: 'auto',
};
return;
}
menuStyle.value = {
...beside,
top: 'auto',
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
};
}
async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) {
open.value = false;
closeMenu();
return;
}
loading.value = true;
@@ -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) {
if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
open.value = false;
closeMenu();
}
defineExpose({ close: closeMenu });
onMounted(() => {
void loadOrgs();
document.addEventListener('click', onDocClick);
@@ -137,27 +163,46 @@ onUnmounted(() => {
</script>
<template>
<div ref="rootEl" class="org-switch">
<div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
<button
class="org-btn"
:class="{ compact, static: !canSwitch }"
:class="{
compact,
static: !menuEnabled,
'org-ws': isWorkspace,
open,
}"
type="button"
:title="currentName"
:aria-disabled="!canSwitch"
:aria-expanded="open"
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
:aria-disabled="!menuEnabled"
@click.stop="toggle"
>
<template v-if="isWorkspace">
<span class="org-mark" aria-hidden="true">{{ currentInitial }}</span>
<span class="org-copy">
<strong>{{ currentName }}</strong>
<small>{{ currentSubtitle }}</small>
</span>
<i class="org-chev" :class="{ open }" aria-hidden="true" />
</template>
<template v-else>
<UiIcon name="building" :size="18" />
<span>{{ currentName }}</span>
</template>
</button>
<Teleport to="body">
<div
v-if="open && canSwitch"
v-if="open && menuEnabled"
ref="menuEl"
class="menu"
:class="{ 'org-ws': isWorkspace }"
:style="menuStyle"
role="listbox"
aria-label="محیط‌های کاری"
>
<p class="hint">ورود به محیط</p>
<p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
<button
v-for="org in orgs"
:key="org.id"
@@ -165,41 +210,25 @@ onUnmounted(() => {
class="org-item"
:class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading"
role="option"
:aria-selected="org.id === auth.user?.organizationId"
@click="choose(org)"
>
<span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
<span class="org-item-copy">
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</span>
<UiIcon
v-if="org.id === auth.user?.organizationId"
class="org-check"
name="check"
:size="16"
/>
</button>
<button type="button" class="create" :disabled="loading" @click="openCreate">
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
<p v-if="error" class="err">{{ error }}</p>
</div>
</Teleport>
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
<form class="form" @submit.prevent="submitCreate">
<label>
نام محیط
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
</label>
<label>
زمینه فعالیت
<select v-model="newField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
</button>
</template>
</UiModal>
</div>
</template>
@@ -234,7 +263,7 @@ onUnmounted(() => {
margin-top: 0.05rem;
}
.org-btn span {
.org-btn > span {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
@@ -253,7 +282,7 @@ onUnmounted(() => {
color: #94a3b8;
}
.org-btn.compact {
.org-btn.compact:not(.org-ws) {
display: grid;
place-items: center;
width: 100%;
@@ -262,18 +291,113 @@ onUnmounted(() => {
border-radius: var(--radius);
}
.org-btn.compact span {
.org-btn.compact:not(.org-ws) > span {
display: none;
}
.org-btn.compact:hover {
.org-btn.compact:not(.org-ws):hover {
background: #f8fafc;
}
.org-btn.compact.static:hover {
.org-btn.compact.static:not(.org-ws):hover {
background: transparent;
}
.org-btn.org-ws {
align-items: center;
gap: 0.7rem;
padding: 0.45rem 0.55rem;
border-radius: 16px;
color: #0f172a;
text-align: start;
}
.org-btn.org-ws:hover,
.org-btn.org-ws.open {
background: #f8fafc;
color: #0f172a;
}
.org-btn.org-ws.static:hover {
background: #f8fafc;
color: #0f172a;
}
.org-mark {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--accent-soft) 80%, white);
color: var(--accent-deep);
font-size: 0.92rem;
font-weight: 800;
}
.org-copy {
min-width: 0;
flex: 1;
display: grid;
gap: 0.08rem;
}
.org-copy strong {
font-size: 0.92rem;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-copy small {
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-chev {
width: 0.42rem;
height: 0.42rem;
border-right: 1.6px solid #94a3b8;
border-bottom: 1.6px solid #94a3b8;
/* RTL: اشاره به بیرون (چپ) */
transform: rotate(135deg);
transition: transform 0.15s ease;
flex-shrink: 0;
margin-inline-start: 0.15rem;
}
.org-chev.open {
transform: rotate(45deg);
}
.org-btn.compact.org-ws {
display: grid;
place-items: center;
width: 100%;
min-height: var(--control-h);
padding: 0;
border-radius: 14px;
}
.org-btn.compact.org-ws .org-copy,
.org-btn.compact.org-ws .org-chev {
display: none;
}
.org-btn.compact.org-ws .org-mark {
width: 32px;
height: 32px;
font-size: 0.82rem;
}
.menu {
position: fixed;
z-index: 90;
@@ -286,11 +410,21 @@ onUnmounted(() => {
gap: 0.2rem;
}
.menu.org-ws {
padding: 0.5rem;
border-radius: 16px;
border: 1px solid #e8eef3;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
gap: 4px;
}
.hint {
margin: 0;
padding: 0.25rem 0.45rem;
font-size: 0.72rem;
color: var(--muted);
padding: 0.35rem 0.65rem 0.45rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #94a3b8;
}
.org-item,
@@ -305,29 +439,89 @@ onUnmounted(() => {
cursor: pointer;
}
.org-item strong {
font-size: 0.82rem;
.menu.org-ws .org-item {
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
border-radius: 12px;
padding: 0.55rem 0.65rem;
color: #0f172a;
}
.org-item span {
font-size: 0.72rem;
color: var(--muted);
.org-item-mark {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
background: #eef2f7;
color: #475569;
font-size: 0.85rem;
font-weight: 800;
flex-shrink: 0;
}
.org-item-copy {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.org-item strong {
font-size: 0.88rem;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-item span,
.org-item-copy span {
font-size: 0.74rem;
font-weight: 500;
color: #94a3b8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-check {
color: #0f172a;
flex-shrink: 0;
}
.org-item:hover,
.create:hover {
background: color-mix(in srgb, var(--accent-soft) 70%, white);
background: #f3f4f6;
}
.menu.org-ws .org-item:hover {
background: #f3f4f6;
}
.menu.org-ws .org-item:hover .org-item-mark {
background: #e2e8f0;
}
.org-item.on {
background: var(--accent-soft);
background: #f3f4f6;
}
.create {
margin-top: 0.15rem;
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
.menu.org-ws .org-item.on {
background: #f3f4f6;
}
.menu.org-ws .org-item.on .org-item-mark {
background: color-mix(in srgb, var(--accent-soft) 85%, white);
color: var(--accent-deep);
}
.menu.org-ws .org-item.on strong {
font-weight: 800;
color: #0f172a;
}
.err {
@@ -335,41 +529,4 @@ onUnmounted(() => {
color: #b91c1c;
font-size: 0.75rem;
}
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.form input,
.form select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
}
.ghost,
.primary {
border-radius: 10px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
.ghost {
border: 1px solid var(--line);
background: #fff;
}
.primary {
border: 0;
background: var(--accent);
color: #fff;
font-weight: 700;
}
</style>
@@ -18,7 +18,7 @@ import {
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@/icons/ant-bridge';
import {
filterPhoneContacts,
isContactPickerSupported,
+307 -33
View File
@@ -185,6 +185,13 @@ const fabStyle = computed(() => ({
bottom: 'auto',
}));
const panelPlacement = computed(() => {
const edge = 16;
const spaceAbove = fabPos.value.top - edge;
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
return spaceAbove >= spaceBelow ? 'above' : 'below';
});
const panelStyle = computed(() => {
const gap = 12;
const edge = 16;
@@ -193,28 +200,33 @@ const panelStyle = computed(() => {
left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left);
const spaceAbove = fabPos.value.top - edge;
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
const placeAbove = spaceAbove >= spaceBelow;
const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap);
const placeAbove = panelPlacement.value === 'above';
const maxHeight = Math.max(
0,
(placeAbove
? fabPos.value.top - edge
: viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge) - gap,
);
const shared = {
left: `${left}px`,
right: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
transformOrigin: placeAbove ? 'bottom left' : 'top left',
};
if (placeAbove) {
return {
left: `${left}px`,
right: 'auto',
...shared,
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
};
}
return {
left: `${left}px`,
right: 'auto',
...shared,
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
};
});
@@ -238,10 +250,6 @@ function onKeydown(ev: KeyboardEvent) {
}
}
watch(isQuickCreateOpen, (value) => {
document.body.style.overflow = value ? 'hidden' : '';
});
watch(hidden, (value) => {
if (value) close();
});
@@ -264,13 +272,13 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl">
<Transition name="qc-overlay">
<button
v-if="isQuickCreateOpen"
class="overlay"
@@ -278,20 +286,24 @@ onBeforeUnmount(() => {
aria-label="بستن ایجاد سریع"
@click="close"
/>
</Transition>
<Transition name="qc-panel">
<section
v-if="isQuickCreateOpen"
class="panel"
:data-place="panelPlacement"
aria-labelledby="qc-title"
:style="panelStyle"
>
<h2 id="qc-title">ایجاد سریع</h2>
<div class="items">
<button
v-for="item in items"
v-for="(item, index) in items"
:key="item.key"
class="item"
type="button"
:style="{ '--qc-i': index }"
@click="go(item.to)"
>
<span class="item-ico" :class="item.tone">
@@ -304,6 +316,7 @@ onBeforeUnmount(() => {
</button>
</div>
</section>
</Transition>
<button
v-if="ready"
@@ -319,6 +332,8 @@ onBeforeUnmount(() => {
@pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp"
>
<span class="fab-pulse" aria-hidden="true" />
<span class="fab-shine" aria-hidden="true" />
<span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span>
@@ -338,11 +353,12 @@ onBeforeUnmount(() => {
z-index: 90;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.68);
background: rgba(15, 23, 42, 0.42);
cursor: pointer;
}
.panel {
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
z-index: 92;
width: 300px;
@@ -352,10 +368,90 @@ onBeforeUnmount(() => {
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: 14px;
padding: 12px 12px 10px;
box-shadow: var(--shadow-lg);
box-shadow:
0 18px 40px -18px rgba(15, 23, 42, 0.35),
0 8px 18px -10px rgba(13, 148, 136, 0.18),
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
overflow: hidden;
will-change: transform, opacity;
}
.qc-overlay-enter-active,
.qc-overlay-leave-active {
transition: opacity 0.16s ease;
}
.qc-overlay-enter-from,
.qc-overlay-leave-to {
opacity: 0;
}
.qc-panel-enter-active {
transition:
opacity 0.18s var(--qc-ease),
transform 0.2s var(--qc-ease);
}
.qc-panel-leave-active {
transition:
opacity 0.14s ease,
transform 0.16s ease;
}
.qc-panel-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
.panel[data-place='above'].qc-panel-enter-from {
transform: translateY(-8px) scale(0.97);
}
.qc-panel-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
.panel[data-place='above'].qc-panel-leave-to {
transform: translateY(-4px) scale(0.98);
}
.qc-panel-enter-active .items {
overflow: hidden;
}
.qc-panel-enter-active .item {
animation: qc-item-in 0.2s var(--qc-ease) both;
animation-delay: calc(0.02s + var(--qc-i, 0) * 0.02s);
}
.qc-panel-enter-active h2 {
animation: qc-item-in 0.16s var(--qc-ease) both;
}
@keyframes qc-item-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.qc-overlay-enter-active,
.qc-overlay-leave-active,
.qc-panel-enter-active,
.qc-panel-leave-active,
.qc-panel-enter-active .item,
.qc-panel-enter-active h2 {
transition: none !important;
animation: none !important;
}
}
.panel h2 {
@@ -369,12 +465,20 @@ onBeforeUnmount(() => {
.items {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-left: 2px;
overscroll-behavior: contain;
scrollbar-width: none;
}
.items::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.item {
@@ -450,6 +554,7 @@ onBeforeUnmount(() => {
}
.fab {
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
z-index: 93;
box-sizing: border-box;
@@ -459,57 +564,226 @@ onBeforeUnmount(() => {
margin: 0;
border: 0;
border-radius: var(--radius);
overflow: hidden;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
overflow: visible;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
background-size: 140% 140%;
color: #fff;
display: grid;
place-items: center;
line-height: 0;
direction: ltr;
isolation: isolate;
box-shadow:
0 4px 14px rgba(13, 148, 136, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
0 0 0 0 rgba(45, 212, 191, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
cursor: grab;
touch-action: none;
user-select: none;
transition:
filter 0.15s ease,
box-shadow 0.15s ease,
transform 0.12s ease;
filter 0.35s var(--fab-ease),
box-shadow 0.35s var(--fab-ease),
transform 0.35s var(--fab-ease),
background 0.4s var(--fab-ease);
animation:
fab-glow 3.4s ease-in-out infinite,
fab-gradient 7s ease-in-out infinite;
}
.fab-pulse,
.fab-shine,
.fab-face {
grid-area: 1 / 1;
}
.fab-pulse {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.42);
animation: fab-ring 2.8s var(--fab-ease) infinite;
}
.fab-shine {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.fab-shine::after {
content: '';
position: absolute;
top: -20%;
bottom: -20%;
left: -40%;
width: 42%;
background: linear-gradient(
105deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 35%,
rgba(255, 255, 255, 0.42) 50%,
rgba(255, 255, 255, 0.08) 65%,
transparent 100%
);
transform: translateX(-160%) skewX(-18deg);
animation: fab-sheen 4.8s var(--fab-ease) infinite;
}
.fab-face {
position: relative;
z-index: 2;
display: grid;
place-items: center;
place-content: center;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
pointer-events: none;
transition: transform 0.45s var(--fab-ease);
animation: fab-breathe 3.4s ease-in-out infinite;
}
.fab :deep(.ico) {
.fab :deep(.ico),
.fab :deep(svg) {
display: block;
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
margin: 0;
stroke-width: 1.7;
filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.18));
transition: transform 0.45s var(--fab-ease), opacity 0.25s ease;
}
.fab:hover {
filter: brightness(1.06);
filter: brightness(1.07) saturate(1.05);
transform: translateY(-1px) scale(1.04);
box-shadow:
0 6px 18px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
0 10px 24px rgba(13, 148, 136, 0.42),
0 0 0 5px rgba(45, 212, 191, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.32);
}
.fab:hover .fab-face {
animation-play-state: paused;
transform: scale(1.06);
}
.fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
box-shadow:
0 8px 22px rgba(15, 23, 42, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
animation: none;
}
.fab.open .fab-pulse,
.fab.open .fab-shine::after {
animation: none;
opacity: 0;
}
.fab.open .fab-face {
animation: none;
transform: rotate(90deg) scale(1.02);
}
.fab.dragging {
cursor: grabbing;
transform: scale(1.08);
box-shadow:
0 12px 28px rgba(13, 148, 136, 0.48),
0 0 0 6px rgba(45, 212, 191, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
animation: none;
transition-duration: 0.12s;
}
.fab.dragging .fab-pulse,
.fab.dragging .fab-shine::after,
.fab.dragging .fab-face {
animation: none;
}
@keyframes fab-glow {
0%,
100% {
box-shadow:
0 4px 14px rgba(13, 148, 136, 0.32),
0 0 0 0 rgba(45, 212, 191, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
50% {
box-shadow:
0 8px 22px rgba(13, 148, 136, 0.48),
0 0 18px 2px rgba(45, 212, 191, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
}
@keyframes fab-gradient {
0%,
100% {
background-position: 0% 40%;
}
50% {
background-position: 100% 60%;
}
}
@keyframes fab-ring {
0% {
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.4);
opacity: 1;
}
70% {
box-shadow: 0 0 0 12px rgba(45, 212, 191, 0);
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fab-sheen {
0%,
55% {
transform: translateX(-160%) skewX(-18deg);
}
85%,
100% {
transform: translateX(320%) skewX(-18deg);
}
}
@keyframes fab-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.fab-pulse,
.fab-shine::after,
.fab-face {
animation: none !important;
}
.fab:hover,
.fab.open .fab-face {
transform: none;
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { OrgRoleDto, RolePermissionKey } from '@mento/shared';
import {
Avatar as AAvatar,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Empty as AEmpty,
Flex as AFlex,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Space as ASpace,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { UserOutlined } from '@/icons/ant-bridge';
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
const props = defineProps<{
role: OrgRoleDto;
childRoles: OrgRoleDto[];
permissionItems: Array<{ key: RolePermissionKey; label: string }>;
avatarOf: (id: string) => string;
tagColor: (color: string | null | undefined) => string;
}>();
const emit = defineEmits<{
'pick-role': [id: string];
}>();
const innerTab = ref('summary');
watch(
() => props.role.id,
() => {
innerTab.value = 'summary';
},
);
const grantedPermissions = computed(() =>
props.permissionItems.filter((item) => props.role.permissions.includes(item.key)),
);
const permSummary = computed(
() =>
`${toFaDigits(grantedPermissions.value.length)} از ${toFaDigits(props.permissionItems.length)}`,
);
</script>
<template>
<a-flex vertical :gap="16">
<a-space direction="vertical" :size="4">
<a-space wrap>
<a-tag v-if="role.systemKey" :bordered="false">سیستمی</a-tag>
<a-typography-text v-if="role.parentName" type="secondary">
زیرمجموعهٔ
<a-typography-link @click="emit('pick-role', role.parentId!)">
{{ role.parentName }}
</a-typography-link>
</a-typography-text>
</a-space>
<a-typography-text v-if="role.description" type="secondary">
{{ role.description }}
</a-typography-text>
</a-space>
<a-tabs v-model:activeKey="innerTab">
<a-tab-pane key="summary" tab="خلاصه">
<a-flex vertical :gap="16">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="کاربران">
{{ toFaDigits(role.userCount) }} نفر
</a-descriptions-item>
<a-descriptions-item label="مجوزها">
{{ permSummary }}
</a-descriptions-item>
<a-descriptions-item label="زیرنقش‌ها">
{{ toFaDigits(childRoles.length) }} مورد
</a-descriptions-item>
</a-descriptions>
<a-flex vertical :gap="8">
<a-typography-text strong>زیرنقش‌ها</a-typography-text>
<a-space v-if="childRoles.length" wrap>
<a-tag
v-for="child in childRoles"
:key="child.id"
:color="tagColor(child.color)"
:bordered="false"
style="cursor: pointer"
@click="emit('pick-role', child.id)"
>
{{ child.name }} · {{ toFaDigits(child.userCount) }}
</a-tag>
</a-space>
<a-empty v-else description="زیرنقشی تعریف نشده" :image="emptySimple" />
</a-flex>
</a-flex>
</a-tab-pane>
<a-tab-pane key="perms" :tab="`مجوزها (${permSummary})`">
<a-flex vertical :gap="12">
<a-typography-text type="secondary">
فقط مجوزهای فعال نمایش داده می‌شود. برای تغییر، ویرایش نقش را بزنید.
</a-typography-text>
<a-space v-if="grantedPermissions.length" wrap>
<a-tag
v-for="perm in grantedPermissions"
:key="perm.key"
color="success"
:bordered="false"
>
{{ perm.label }}
</a-tag>
</a-space>
<a-empty v-else description="هیچ مجوزی فعال نیست" :image="emptySimple" />
</a-flex>
</a-tab-pane>
<a-tab-pane key="members" :tab="`کاربران (${toFaDigits(role.userCount)})`">
<a-list v-if="role.members.length" :data-source="role.members">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.fullName">
<template #avatar>
<a-avatar :src="avatarOf(item.id)">
<template #icon><UserOutlined /></template>
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-empty v-else description="کاربری با این نقش نیست" :image="emptySimple" />
</a-tab-pane>
</a-tabs>
</a-flex>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Checkbox as ACheckbox,
+26 -39
View File
@@ -118,12 +118,17 @@ defineExpose({ refresh: load });
<div v-if="visible" class="sms-wallet">
<div
class="chip"
:class="toneClass"
:class="[toneClass, (summary?.balanceRials ?? 0) < 0 ? 'is-debt' : '']"
role="button"
tabindex="0"
:title="loading ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`"
aria-label="شارژ اعتبار پیامک"
@click="chargeOpen = true"
@keydown.enter.prevent="chargeOpen = true"
@keydown.space.prevent="chargeOpen = true"
>
<span class="label">پیامک</span>
<span class="amount">{{ balanceLabel }}</span>
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div>
<Teleport to="body">
@@ -200,15 +205,15 @@ defineExpose({ refresh: load });
flex-shrink: 0;
}
/* چیپ جمع‌وجور؛ دکمه شارژ سمت چپ، برچسب سمت راست */
/* چیپ جمع‌وجور؛ کلیک کل سطح، پنجرهٔ شارژ را باز می‌کند */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 31px;
height: var(--control-h);
box-sizing: border-box;
margin: 0;
padding: 0 4px 0 2px;
padding: 0 8px;
border: 1px solid #c3ead6;
border-radius: 10px;
background: #f2fbf6;
@@ -217,6 +222,7 @@ defineExpose({ refresh: load });
line-height: 1;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
}
.chip.warning {
@@ -232,6 +238,17 @@ defineExpose({ refresh: load });
color: #991b1b;
}
.chip.is-debt {
border-color: var(--danger);
background: var(--danger);
color: #fff;
}
.chip.is-debt .label {
color: inherit;
opacity: 0.92;
}
.label {
font-weight: 600;
font-size: 0.7rem;
@@ -239,41 +256,9 @@ defineExpose({ refresh: load });
}
.amount {
font-weight: 800;
letter-spacing: -0.01em;
}
.charge {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
height: 23px;
margin: 0;
padding: 0 6px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
color: #334155;
font: inherit;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.charge:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.charge:active {
background: #f1f5f9;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.layer {
@@ -285,6 +270,7 @@ defineExpose({ refresh: load });
justify-content: center;
padding: 1rem;
overflow: auto;
box-sizing: border-box;
}
.backdrop {
@@ -305,6 +291,7 @@ defineExpose({ refresh: load });
display: grid;
gap: 0.75rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
box-sizing: border-box;
}
.modal header {
+1 -1
View File
@@ -7,7 +7,7 @@ import {
Popover as APopover,
Typography,
} from 'ant-design-vue';
import { SettingOutlined } from '@ant-design/icons-vue';
import { SettingOutlined } from '@/icons/ant-bridge';
import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali';
+256 -337
View File
@@ -19,9 +19,36 @@ import {
emptySectionDataAccess,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Radio as ARadio,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
const { Text: ATypographyText } = Typography;
const props = defineProps<{
users: OrgUserDto[];
@@ -37,7 +64,7 @@ const showPriorArchive = computed(
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
const priorSearch = ref('');
const priorPick = ref('');
const priorPick = ref<string | undefined>(undefined);
const priorSavingId = ref('');
const cutoffLocal = ref('');
const cutoffSaving = ref(false);
@@ -48,10 +75,16 @@ const availablePriorUsers = computed(() => {
return priorUsers.value.filter((u) => {
if (u.priorArchiveEnabled) return false;
if (!q) return true;
return [u.fullName, u.email, u.mobile].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
return [u.fullName, u.email, u.mobile]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q));
});
});
const availablePriorOptions = computed(() =>
availablePriorUsers.value.map((u) => ({ value: u.id, label: u.fullName })),
);
const emit = defineEmits<{
refreshUsers: [];
}>();
@@ -74,9 +107,14 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
const memberRoleOptions = [
{ value: 'member', label: 'عضو' },
{ value: 'manager', label: 'سرپرست' },
];
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
priorPick.value = '';
priorPick.value = undefined;
error.value = '';
ok.value = '';
try {
@@ -114,7 +152,10 @@ async function loadPriorArchiveCutoff() {
applyCutoffFromOrg(data);
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.')
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تاریخ بایگانی ناموفق بود.',
)
: 'بارگذاری تاریخ بایگانی ناموفق بود.';
} finally {
cutoffLoading.value = false;
@@ -136,7 +177,10 @@ async function onCutoffChange(value: string) {
await auth.hydrate();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.')
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره تاریخ بایگانی ناموفق بود.',
)
: 'ذخیره تاریخ بایگانی ناموفق بود.';
await loadPriorArchiveCutoff();
} finally {
@@ -144,9 +188,10 @@ async function onCutoffChange(value: string) {
}
}
function pickPriorUser(userId: string) {
if (!userId) return;
void setPriorArchive(userId, true);
function pickPriorUser(userId: unknown) {
const id = userId == null ? '' : String(userId);
if (!id) return;
void setPriorArchive(id, true);
}
async function load() {
@@ -243,9 +288,19 @@ function toggleMember(userId: string) {
}
}
function setRole(userId: string, role: TeamMemberRole) {
function setRole(userId: string, role: unknown) {
if (!memberDraft.value[userId]) return;
memberDraft.value = { ...memberDraft.value, [userId]: role };
memberDraft.value = {
...memberDraft.value,
[userId]: String(role) as TeamMemberRole,
};
}
function teamMembersLabel(team: TeamDto) {
if (!team.members.length) return 'بدون عضو';
return team.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ');
}
async function saveMembers() {
@@ -365,101 +420,129 @@ onMounted(() => {
</script>
<template>
<div class="teams-panel">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<a-flex vertical :gap="12">
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<section v-if="canEdit" class="card">
<h3>تیم جدید</h3>
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="newDesc" placeholder="اختیاری" />
</UiField>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
</div>
</section>
<a-card :bordered="false">
<a-form v-if="canEdit" layout="vertical" @finish="createTeam">
<a-divider orientation="right">تیم جدید</a-divider>
<a-row :gutter="[16, 0]" align="bottom">
<a-col :xs="24" :md="8">
<a-form-item label="نام تیم">
<a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="10">
<a-form-item label="توضیح">
<a-input v-model:value="newDesc" placeholder="اختیاری" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" :loading="saving" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<section class="card">
<h3>تیم‌ها</h3>
<UiEmptyState
<a-divider orientation="right">تیم‌ها</a-divider>
<CrmSkeleton :loading="loading">
<CrmSkeletonCard :rows="6">
<a-empty
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
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>
<a-list v-else item-layout="vertical" :data-source="teams">
<template #renderItem="{ item: t }">
<a-list-item>
<template v-if="canEdit" #actions>
<a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button>
<a-button type="link" @click="startEdit(t)">اعضا</a-button>
<a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button>
</template>
<a-list-item-meta>
<template #title>
<a-space wrap>
<a-typography-text strong>{{ t.name }}</a-typography-text>
<a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag>
</a-space>
</template>
<template #description>
<a-typography-text type="secondary">
{{ t.description || 'بدون توضیح' }} · {{ teamMembersLabel(t) }}
</a-typography-text>
</template>
</a-list-item-meta>
<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>
<a-form
v-if="editingMetaId === t.id"
layout="vertical"
style="width: 100%"
@finish="saveMeta"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام تیم">
<a-input v-model:value="editName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="توضیح">
<a-input v-model:value="editDesc" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button @click="editingMetaId = null">انصراف</a-button>
</a-space>
</a-form>
<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"
<a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%">
<a-typography-text type="secondary">
اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.
</a-typography-text>
<a-row :gutter="[12, 8]">
<a-col
v-for="u in users.filter((x) => x.isActive !== false)"
:key="u.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-space>
<a-checkbox
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
/>
<span>{{ u.fullName }}</span>
<select
>
{{ u.fullName }}
</a-checkbox>
<a-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>
: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>
<section v-if="showPriorArchive" class="card">
<header class="prior-head">
<h3>بایگانی اطلاعات قبل</h3>
<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"
@@ -467,73 +550,76 @@ onMounted(() => {
:disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange"
/>
</header>
<p class="hint">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
</p>
<div class="prior-picker">
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
<select
v-model="priorPick"
</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)"
@change="pickPriorUser(($event.target as HTMLSelectElement).value)"
>
<option value="">
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعال‌سازی' : 'کاربر دیگری پیدا نشد' }}
</option>
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div v-if="selectedPriorUsers.length" class="prior-chips">
<span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
{{ u.fullName }}
<button
type="button"
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"
:aria-label="`حذف ${u.fullName}`"
@click="setPriorArchive(u.id, false)"
@close.prevent="setPriorArchive(u.id, false)"
>
×
</button>
</span>
</div>
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
</section>
{{ u.fullName }}
</a-tag>
</a-space>
<a-typography-text v-else type="secondary">
هنوز کاربری انتخاب نشده است.
</a-typography-text>
</a-flex>
</a-collapse-panel>
<section class="card">
<h3>محدوده داده</h3>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
</p>
<UiEmptyState
<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"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
<small class="vis-badge">{{ accessSummary(u) }}</small>
</span>
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
</UiButton>
</div>
<div v-if="expandedAccess === u.id" class="vis-scopes">
<label class="chip-row">
<input v-model="accessMode" type="radio" value="inherit" />
مثل نقش (بدون override)
</label>
<label class="chip-row">
<input v-model="accessMode" type="radio" value="custom" />
سفارشی برای این کاربر
</label>
<a-list v-else item-layout="vertical" :data-source="editableUsers">
<template #renderItem="{ item: u }">
<a-list-item>
<template v-if="canEdit" #actions>
<a-button type="link" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
</a-button>
</template>
<a-list-item-meta :description="accessSummary(u)">
<template #title>
<a-typography-text strong>{{ u.fullName }}</a-typography-text>
</template>
</a-list-item-meta>
<a-flex v-if="expandedAccess === u.id" vertical :gap="8" style="width: 100%">
<a-radio-group v-model:value="accessMode">
<a-space wrap>
<a-radio value="inherit">مثل نقش</a-radio>
<a-radio value="custom">سفارشی</a-radio>
</a-space>
</a-radio-group>
<DataAccessPolicyPanel
v-model="draftAccess"
v-model:treasury-access="draftTreasury"
@@ -541,186 +627,19 @@ onMounted(() => {
:data-locked="accessMode === 'inherit'"
compact
/>
<div class="actions">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
</div>
</div>
</div>
</div>
</section>
</div>
<a-space>
<a-button type="primary" :loading="accessSaving" @click="saveUserAccess(u.id)">
ذخیره
</a-button>
<a-button @click="expandedAccess = null">انصراف</a-button>
</a-space>
</a-flex>
</a-list-item>
</template>
</a-list>
</a-flex>
</a-collapse-panel>
</a-collapse>
</a-card>
</a-flex>
</template>
<style scoped>
.teams-panel {
display: grid;
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 1rem;
}
.card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.prior-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.prior-head h3 {
margin: 0;
}
.prior-head :deep(.trigger) {
min-width: 12rem;
}
.prior-picker {
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(13rem, 1fr);
gap: 0.6rem;
margin-top: 0.75rem;
}
.prior-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.75rem;
}
.prior-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #ccfbf1;
border-radius: 999px;
background: #f0fdfa;
color: #0f766e;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
font-weight: 600;
}
.prior-chip button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0;
}
.prior-chip button:disabled {
opacity: 0.45;
cursor: wait;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.team-list {
display: grid;
gap: 0.75rem;
}
.team {
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 0.75rem;
}
.team header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted,
.hint,
.members {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.editor {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.45rem;
}
.person {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
}
.vis-table {
display: grid;
gap: 0.5rem;
}
.vis-user {
border-bottom: 1px solid #f1f5f9;
padding-bottom: 0.45rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
}
.vis-badge {
margin-inline-start: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #eef2ff;
color: #4338ca;
font-size: 0.72rem;
}
.vis-scopes {
display: grid;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.chip-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
}
select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.row,
.vis-row,
.prior-picker {
grid-template-columns: 1fr;
}
}
</style>
+7 -346
View File
@@ -1,4 +1,10 @@
<script setup lang="ts">
/**
* آیکون‌های legacy — حالا از پک vuesax-linear رندر می‌شوند.
* API نام‌ها برای سازگاری با صفحات قدیمی حفظ شده.
*/
import VsIcon from '@/components/VsIcon.vue';
defineProps<{
name:
| 'home'
@@ -82,352 +88,7 @@ defineProps<{
</script>
<template>
<svg
class="ico"
:width="size ?? 20"
:height="size ?? 20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'home'">
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V21h14V9.5" />
<path d="M10 21v-6h4v6" />
</template>
<template v-else-if="name === 'users'">
<circle cx="9" cy="8" r="3.2" />
<path d="M2.5 19c.8-3 2.9-4.5 6.5-4.5s5.7 1.5 6.5 4.5" />
<circle cx="17" cy="9" r="2.4" />
<path d="M15.2 14.2c2.3.3 3.9 1.5 4.8 3.8" />
</template>
<template v-else-if="name === 'briefcase'">
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5V7" />
<path d="M3 12h18" />
</template>
<template v-else-if="name === 'check'">
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.4 2.4 4.6-5" />
</template>
<template v-else-if="name === 'box'">
<path d="M12 3 3.5 7.5v9L12 21l8.5-4.5v-9L12 3Z" />
<path d="M12 12 3.5 7.5M12 12l8.5-4.5M12 12v9" />
</template>
<template v-else-if="name === 'chart'">
<path d="M4 19h16" />
<path d="M7 16V10" />
<path d="M12 16V6" />
<path d="M17 16v-4" />
</template>
<template v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</template>
<template v-else-if="name === 'search'">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</template>
<template v-else-if="name === 'sparkle'">
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
<path d="m6.5 6.5 2.5 2.5M15 15l2.5 2.5M17.5 6.5 15 9M9 15l-2.5 2.5" />
<circle cx="12" cy="12" r="2.2" />
</template>
<template v-else-if="name === 'bell'">
<path d="M6 9a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5H4.5S6 13 6 9Z" />
<path d="M10 19a2 2 0 0 0 4 0" />
</template>
<template v-else-if="name === 'calendar'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
</template>
<template v-else-if="name === 'calendar-plus'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M12 13v5M9.5 15.5h5" />
</template>
<template v-else-if="name === 'calendar-sun'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<circle cx="12" cy="15.2" r="2.1" />
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
</template>
<template v-else-if="name === 'calendar-edit'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
</template>
<template v-else-if="name === 'mail'">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" />
</template>
<template v-else-if="name === 'megaphone'">
<path d="M3 11v2a2 2 0 0 0 2 2h1l4 4V5L6 9H5a2 2 0 0 0-2 2Z" />
<path d="M14 8.5c2 .8 3.5 2.3 3.5 3.5s-1.5 2.7-3.5 3.5" />
<path d="M10 15v3a2 2 0 0 1-2 2" />
</template>
<template v-else-if="name === 'send'">
<path d="m4 12 16-8-6 16-2.5-6.5L4 12Z" />
</template>
<template v-else-if="name === 'fire'">
<path d="M12 3c2 3-1 4 1 7 1.5 2.2 4 2.5 4 6a5 5 0 1 1-10 0c0-3.5 2-5 3-7 1-2-1-4 2-6Z" />
</template>
<template v-else-if="name === 'warning'">
<path d="m12 4 9 16H3L12 4Z" />
<path d="M12 10v4" />
<path d="M12 17h.01" />
</template>
<template v-else-if="name === 'trend'">
<path d="M4 17 10 11l4 4 6-8" />
<path d="M15 7h5v5" />
</template>
<template v-else-if="name === 'phone'">
<path d="M8 3.5h2.5l1 4-2 1.2a12 12 0 0 0 5.8 5.8L16.5 12.5l4 1V16a2 2 0 0 1-2.2 2A16 16 0 0 1 4 6.2 2 2 0 0 1 6 4Z" />
</template>
<template v-else-if="name === 'trophy'">
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" />
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
</template>
<template v-else-if="name === 'chat'">
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
<path d="M8.5 11h7M8.5 14h4.5" />
</template>
<template v-else-if="name === 'dots'">
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'dots-v'">
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'document'">
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'download'">
<path d="M12 4.5v9" />
<path d="M8.5 10.5 12 14l3.5-3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'upload'">
<path d="M12 14.5v-9" />
<path d="M8.5 8.5 12 5l3.5 3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
</template>
<template v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4.2l2.8 1.8" />
</template>
<template v-else-if="name === 'user-plus'">
<circle cx="10" cy="8" r="3" />
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
<path d="M17 8v5M14.5 10.5h5" />
</template>
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'chevron-left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'chevrons-right'">
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
</template>
<template v-else-if="name === 'chevrons-left'">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'arrow-left'">
<path d="M19 12H5M12 19l-7-7 7-7" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
<template v-else-if="name === 'pin'">
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
<path d="M12 20v-5" />
</template>
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
<template v-else-if="name === 'funnel'">
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M9 18h6" />
<path d="M10 21h4" />
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
</template>
<template v-else-if="name === 'building'">
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
<path d="M4 21h16" />
</template>
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
</template>
<template v-else-if="name === 'flag'">
<path d="M6 21V4" />
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
</template>
<template v-else-if="name === 'refresh'">
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
<path d="M20 4v5h-5" />
</template>
<template v-else-if="name === 'x'">
<path d="M6 6 18 18M18 6 6 18" />
</template>
<template v-else-if="name === 'x-circle'">
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6M15 9l-6 6" />
</template>
<template v-else-if="name === 'mic'">
<rect x="9" y="3.5" width="6" height="10" rx="3" />
<path d="M7 11.5a5 5 0 0 0 10 0" />
<path d="M12 16.5V20M9 20h6" />
</template>
<template v-else-if="name === 'trash'">
<path d="M5 7h14" />
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
</template>
<template v-else-if="name === 'stop'">
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'dollar'">
<circle cx="12" cy="12" r="8.4" />
<path d="M12 6.8v10.4" />
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
<template v-else-if="name === 'layers'">
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
<path d="M4 12.2 12 16.4 20 12.2" />
<path d="M4 16.2 12 20.4 20 16.2" />
</template>
<template v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
<template v-else-if="name === 'edit'">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
</template>
<template v-else-if="name === 'grid'">
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
</template>
<template v-else-if="name === 'list'">
<path d="M8 6h12M8 12h12M8 18h12" />
<circle cx="4.5" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="18" r="1" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'eye'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
<path d="M4 20 20 4" />
</template>
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</template>
<template v-else-if="name === 'truck'">
<path d="M3 7.5h11V16H3z" />
<path d="M14 10h4.2l2.3 3.2V16H14z" />
<circle cx="7" cy="17.5" r="1.6" />
<circle cx="17" cy="17.5" r="1.6" />
<path d="M3 16h1.2M8.7 16H15" />
</template>
<template v-else-if="name === 'grip'">
<circle cx="9" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="17" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="17" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'apps-plus'">
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
</template>
<template v-else-if="name === 'crown'">
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
<path d="M5.2 18.6h13.6" />
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'gift'">
<rect x="4" y="11" width="16" height="9" rx="1.4" />
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
<path d="M12 6.8v13.2" />
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
</template>
<template v-else-if="name === 'medal'">
<circle cx="12" cy="9" r="5.2" />
<circle cx="12" cy="9" r="2.3" />
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
</template>
<template v-else-if="name === 'logout'">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</template>
<template v-else-if="name === 'pulse'">
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
</template>
<template v-else-if="name === 'sliders'">
<path d="M4 7h16M4 17h16" />
<path d="M8 4.5v5M16 14.5v5" />
</template>
<template v-else-if="name === 'camera'">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</template>
<template v-else-if="name === 'clip'">
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
</template>
</svg>
<VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
</template>
<style scoped>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getVuesaxSvg } from '@/icons/vuesax';
import { resolveVuesaxName } from '@/icons/name-map';
const props = withDefaults(
defineProps<{
/** کلید کوتاه vuesax (مثل add) یا نام UiIcon / Ant با map */
name: string;
size?: number | string;
/** منبع نگاشت نام؛ پیش‌فرض raw = کلید مستقیم پک */
map?: 'raw' | 'ui' | 'ant';
spin?: boolean;
title?: string;
}>(),
{
size: 20,
map: 'raw',
spin: false,
},
);
const resolved = computed(() => resolveVuesaxName(props.name, props.map));
const markup = computed(() => {
const svg = getVuesaxSvg(resolved.value);
if (!svg) return '';
// Strip fixed width/height so CSS size controls the box; keep viewBox.
return svg
.replace(/\swidth="[^"]*"/, '')
.replace(/\sheight="[^"]*"/, '')
.replace(
'<svg ',
`<svg width="${props.size}" height="${props.size}" style="width:${typeof props.size === 'number' ? props.size + 'px' : props.size};height:${typeof props.size === 'number' ? props.size + 'px' : props.size}" `,
);
});
</script>
<template>
<span
class="vs-icon"
:class="{ 'vs-icon--spin': spin }"
:style="{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}"
:title="title"
role="img"
aria-hidden="true"
v-html="markup"
/>
</template>
<style scoped>
.vs-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
flex-shrink: 0;
color: inherit;
}
.vs-icon :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.vs-icon--spin {
animation: vs-icon-spin 1s linear infinite;
}
@keyframes vs-icon-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -10,8 +10,10 @@ import type {
import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import { Flex as AFlex } from 'ant-design-vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
@@ -429,8 +431,31 @@ function openActivity(id: string) {
</script>
<template>
<div class="kanban-wrap">
<p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p>
<div
class="kanban-wrap"
:style="{
flex: '1 1 auto',
minHeight: 0,
height: '100%',
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
<a-flex
v-if="initialLoading && !Object.keys(columns).length"
:gap="12"
:style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
>
<div
v-for="i in 4"
:key="`sk-col-${i}`"
:style="{ width: '280px', flex: '0 0 280px' }"
>
<CrmSkeletonCard :rows="6" :min-height="360" />
</div>
</a-flex>
<PipelineBoard
v-else
:stages="boardStages"
@@ -457,6 +482,7 @@ function openActivity(id: string) {
@add-stage="onAddStage"
@load-more="loadMore"
/>
</CrmSkeleton>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -480,9 +506,4 @@ function openActivity(id: string) {
display: flex;
flex-direction: column;
}
.muted {
margin: 0;
color: #64748b;
}
</style>
@@ -11,12 +11,34 @@ import type {
PersonDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Col as ACol,
Flex as AFlex,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Row as ARow,
Select as ASelect,
Space as ASpace,
Switch as ASwitch,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import {
CloseOutlined,
DownOutlined,
PlusOutlined,
UpOutlined,
UserOutlined,
} from '@/icons/ant-bridge';
const { Text: ATypographyText } = Typography;
const props = defineProps<{
fields: ActivityFieldDef[];
@@ -24,8 +46,6 @@ const props = defineProps<{
}>();
const model = defineModel<ActivityFieldValues>({ required: true });
const auth = useAuthStore();
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
const v = model.value[key];
@@ -46,9 +66,20 @@ function multi(key: string): string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
}
function toggleMulti(key: string, opt: string) {
const list = multi(key);
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
function fieldSpan(type: ActivityFieldDef['type']): number {
return ['textarea', 'checklist', 'participants', 'multiselect'].includes(type) ? 24 : 12;
}
function selectOptions(f: ActivityFieldDef) {
return (f.options ?? []).map((opt) => ({ value: opt, label: opt }));
}
function onNumberUpdate(key: string, value: number | string | null) {
if (value == null || value === '') {
set(key, null);
return;
}
set(key, Number(value));
}
/* ---------- چک‌لیست ---------- */
@@ -56,7 +87,9 @@ const checklistDraft = ref<Record<string, string>>({});
function checklist(key: string): ActivityChecklistItem[] {
const v = model.value[key];
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
return Array.isArray(v)
? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object')
: [];
}
function addChecklistItem(key: string) {
@@ -115,39 +148,82 @@ function addGuest(key: string) {
const personQuery = ref<Record<string, string>>({});
const personResults = ref<Record<string, PersonDto[]>>({});
const personOpenKey = ref('');
const personSearching = ref<Record<string, boolean>>({});
let personTimer: ReturnType<typeof setTimeout> | undefined;
let personSeq = 0;
function searchPeople(key: string) {
personOpenKey.value = key;
function searchPeople(key: string, raw: string) {
personQuery.value = { ...personQuery.value, [key]: raw };
if (personTimer) clearTimeout(personTimer);
const q = (personQuery.value[key] ?? '').trim();
const q = raw.trim();
if (!q) {
personResults.value = { ...personResults.value, [key]: [] };
personSearching.value = { ...personSearching.value, [key]: false };
return;
}
personSearching.value = { ...personSearching.value, [key]: true };
const seq = ++personSeq;
personTimer = setTimeout(async () => {
try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 8 },
});
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
if (seq === personSeq) {
personResults.value = { ...personResults.value, [key]: data.items };
}
} catch {
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
if (seq === personSeq) {
personResults.value = { ...personResults.value, [key]: [] };
}
} finally {
if (seq === personSeq) {
personSearching.value = { ...personSearching.value, [key]: false };
}
}
}, 280);
}
function addPerson(key: string, p: PersonDto) {
const personSelectOptions = computed(() => {
const map: Record<string, Array<{ value: string; label: string; company: string }>> = {};
for (const f of props.fields) {
if (f.type !== 'participants') continue;
const selectedIds = new Set(participants(f.key).people.map((p) => p.id));
map[f.key] = (personResults.value[f.key] ?? [])
.filter((p) => !selectedIds.has(p.id))
.map((p) => ({
value: p.id,
label: p.fullName,
company: p.company?.name ?? '',
}));
}
return map;
});
function onPersonPick(key: string, value: unknown) {
const id = value == null ? '' : String(value);
if (!id) return;
const hit =
(personResults.value[key] ?? []).find((p) => p.id === id) ??
null;
if (!hit) return;
const cur = participants(key);
if (!cur.people.some((x) => x.id === p.id)) {
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
if (!cur.people.some((x) => x.id === hit.id)) {
patchParticipants(key, { people: [...cur.people, { id: hit.id, name: hit.fullName }] });
}
personQuery.value = { ...personQuery.value, [key]: '' };
personResults.value = { ...personResults.value, [key]: [] };
personOpenKey.value = '';
}
function removePerson(key: string, id: string) {
patchParticipants(key, {
people: participants(key).people.filter((x) => x.id !== id),
});
}
function removeGuest(key: string, name: string) {
patchParticipants(key, {
guests: participants(key).guests.filter((x) => x !== name),
});
}
watch(
@@ -157,523 +233,237 @@ watch(
guestDraft.value = {};
personQuery.value = {};
personResults.value = {};
personSearching.value = {};
},
);
</script>
<template>
<div class="fields-grid">
<div
<a-row :gutter="[16, 0]">
<a-col
v-for="f in fields"
:key="f.key"
class="cf"
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
:xs="24"
:md="fieldSpan(f.type)"
>
<label :for="`cf-${f.key}`">
<a-form-item :required="f.required">
<template #label>
{{ f.label }}
<i v-if="f.required">*</i>
</label>
</template>
<input
<a-input
v-if="f.type === 'text' || f.type === 'url'"
:id="`cf-${f.key}`"
:value="str(f.key)"
:type="f.type === 'url' ? 'url' : 'text'"
:dir="f.type === 'url' ? 'ltr' : undefined"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
maxlength="1000"
@input="set(f.key, ($event.target as HTMLInputElement).value)"
:maxlength="1000"
style="width: 100%"
@update:value="(v: string) => set(f.key, v)"
/>
<textarea
<a-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)"
:rows="3"
style="width: 100%"
@update:value="(v: string) => set(f.key, v)"
/>
<input
<a-input-number
v-else-if="f.type === 'number'"
:id="`cf-${f.key}`"
type="number"
inputmode="decimal"
dir="ltr"
:value="str(f.key)"
:value="str(f.key) === '' ? undefined : Number(str(f.key))"
:placeholder="f.placeholder || ''"
@input="
set(
f.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
style="width: 100%"
@update:value="(v: number | string | null) => onNumberUpdate(f.key, v)"
/>
<JalaliDateInput
v-else-if="f.type === 'date'"
:id="`cf-${f.key}`"
:model-value="str(f.key)"
style="width: 100%"
@update:model-value="(v: string) => set(f.key, v || null)"
/>
<select
<a-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)"
: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))"
/>
<i />
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
</label>
<div v-else-if="f.type === 'checklist'" class="checklist">
<p v-if="checklist(f.key).length" class="progress">
<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) }} انجام شده
</p>
<ul>
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
<input
type="checkbox"
</a-typography-text>
<a-flex
v-for="(it, idx) in checklist(f.key)"
:key="it.id"
align="center"
:gap="8"
>
<a-checkbox
:checked="it.done"
aria-label="انجام شد"
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
@update:checked="(v: boolean) => updateChecklistItem(f.key, it.id, { done: v })"
/>
<input
class="item-text"
<a-input
:value="it.text"
maxlength="300"
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
:maxlength="300"
:style="{
flex: 1,
textDecoration: it.done ? 'line-through' : undefined,
}"
@update:value="(v: string) => updateChecklistItem(f.key, it.id, { text: v })"
/>
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
<button
type="button"
class="mini"
<a-button
:disabled="idx === 0"
aria-label="بالا"
@click="moveChecklistItem(f.key, idx, -1)"
>
<template #icon><UpOutlined /></template>
</a-button>
<a-button
:disabled="idx === checklist(f.key).length - 1"
aria-label="پایین"
@click="moveChecklistItem(f.key, idx, 1)"
>
▼
</button>
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
<UiIcon name="x" :size="13" />
</button>
</li>
</ul>
<div class="add-row">
<input
v-model="checklistDraft[f.key]"
maxlength="300"
<template #icon><DownOutlined /></template>
</a-button>
<a-button
danger
aria-label="حذف"
@click="removeChecklistItem(f.key, it.id)"
>
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<a-space :size="8" style="width: 100%">
<a-input
:value="checklistDraft[f.key] ?? ''"
:maxlength="300"
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
@keydown.enter.prevent="addChecklistItem(f.key)"
style="width: 100%"
@update:value="(v: string) => (checklistDraft[f.key] = v)"
@pressEnter="addChecklistItem(f.key)"
/>
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
<UiIcon name="plus" :size="14" />
<a-button type="dashed" @click="addChecklistItem(f.key)">
<template #icon><PlusOutlined /></template>
افزودن
</button>
</div>
</div>
</a-button>
</a-space>
</a-flex>
<div v-else-if="f.type === 'participants'" class="participants">
<div class="p-block">
<small>همکاران (فعالیت در لیست آن‌ها هم نمایش داده می‌شود)</small>
<a-flex v-else-if="f.type === 'participants'" vertical :gap="16">
<a-flex vertical :gap="4">
<a-typography-text type="secondary">
همکاران (فعالیت در لیست آن‌ها هم نمایش داده می‌شود)
</a-typography-text>
<UserMultiSelect
:model-value="participants(f.key).userIds"
:users="users"
: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" />
</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 }}
<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"
</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="جستجوی نام یا موبایل شخص…"
@input="searchPeople(f.key)"
@focus="personOpenKey = f.key"
/>
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
<li v-for="p in personResults[f.key]" :key="p.id">
<button type="button" @click="addPerson(f.key, p)">
<b>{{ p.fullName }}</b>
<small v-if="p.company?.name">{{ p.company.name }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="p-block">
<small>مهمانان دیگر (نام آزاد)</small>
<div class="tags">
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
{{ g }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
style="width: 100%"
@search="(q: string) => searchPeople(f.key, q)"
@update:value="(v: unknown) => onPersonPick(f.key, v)"
>
×
</button>
</span>
</div>
<div class="add-row">
<input
v-model="guestDraft[f.key]"
maxlength="120"
placeholder="نام مهمان و Enter"
@keydown.enter.prevent="addGuest(f.key)"
/>
<button type="button" class="add-btn" @click="addGuest(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
</div>
</div>
</div>
<template #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>
<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>
<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>
@@ -12,10 +12,25 @@ import {
ACTIVITY_STAGE_COLORS,
ACTIVITY_STAGE_KIND_LABELS,
} from '@mento/shared';
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
} from '@/icons/ant-bridge';
import {
Alert as AAlert,
Button as AButton,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
} from 'ant-design-vue';
import { Modal } from 'ant-design-vue';
import { api } from '@/lib/api';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
open: boolean;
@@ -62,6 +77,19 @@ function apiMessage(e: unknown, fallback: string) {
return fallback;
}
function confirmAsync(content: string, title = 'تأیید'): Promise<boolean> {
return new Promise((resolve) => {
Modal.confirm({
title,
content,
okText: 'ادامه',
cancelText: 'انصراف',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
}
function isDirty(stage: ActivityStageDto) {
const d = drafts.value[stage.id];
return Boolean(
@@ -78,9 +106,9 @@ async function save(stage: ActivityStageDto) {
}
if (
d.kind !== stage.kind &&
!window.confirm(
!(await confirmAsync(
'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟',
)
))
) {
return;
}
@@ -119,13 +147,13 @@ async function move(index: number, dir: -1 | 1) {
}
async function remove(stage: ActivityStageDto) {
if (
!window.confirm(
`ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
)
) {
return;
}
Modal.confirm({
title: 'حذف ستون',
content: `ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busyId.value = stage.id;
error.value = '';
try {
@@ -136,6 +164,8 @@ async function remove(stage: ActivityStageDto) {
} finally {
busyId.value = '';
}
},
});
}
async function create() {
@@ -156,226 +186,163 @@ async function create() {
busyId.value = '';
}
}
function dotColor(stage: ActivityStageDto) {
const color = drafts.value[stage.id]?.color ?? stage.color;
return PIPELINE_ACCENT[color].solid;
}
</script>
<template>
<UiModal
<a-modal
:open="open"
:title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'"
@close="emit('close')"
:footer="null"
@cancel="emit('close')"
>
<div class="manager">
<p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <b>لغو شده</b> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</p>
<a-flex vertical gap="middle">
<a-typography-text type="secondary">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <strong>انجام‌شده</strong> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <strong>لغو شده</strong> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</a-typography-text>
<p v-if="error" class="error">{{ error }}</p>
<a-alert v-if="error" type="error" :message="error" show-icon />
<div class="rows">
<div
<a-flex vertical gap="small">
<a-row
v-for="(stage, index) in stages"
:key="stage.id"
class="row"
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }"
:gutter="[8, 8]"
align="middle"
>
<span class="dot" aria-hidden="true" />
<input
<a-col flex="none">
<span
aria-hidden="true"
:style="{
display: 'inline-block',
width: '0.65rem',
height: '0.65rem',
borderRadius: '999px',
background: dotColor(stage),
}"
/>
</a-col>
<a-col :xs="24" :sm="8">
<a-input
v-if="drafts[stage.id]"
v-model="drafts[stage.id]!.name"
class="name"
maxlength="80"
v-model:value="drafts[stage.id]!.name"
:maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="save(stage)"
/>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.kind" aria-label="نوع ستون">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.color" aria-label="رنگ ستون">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
</a-col>
<a-col :xs="12" :sm="6">
<a-select
v-if="drafts[stage.id]"
v-model:value="drafts[stage.id]!.kind"
aria-label="نوع ستون"
style="width: 100%"
>
<a-select-option v-for="k in KINDS" :key="k" :value="k">
{{ ACTIVITY_STAGE_KIND_LABELS[k] }}
</a-select-option>
</a-select>
</a-col>
<a-col :xs="12" :sm="5">
<a-select
v-if="drafts[stage.id]"
v-model:value="drafts[stage.id]!.color"
aria-label="رنگ ستون"
style="width: 100%"
>
<a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<div class="row-actions">
<button
type="button"
class="icon"
</a-select-option>
</a-select>
</a-col>
<a-col :xs="24" :sm="5">
<a-flex align="center" gap="small" wrap="wrap" justify="flex-end">
<a-button
type="text"
:disabled="index === 0 || Boolean(busyId)"
title="انتقال به قبل"
aria-label="انتقال به قبل"
@click="move(index, -1)"
>
▲
</button>
<button
type="button"
class="icon"
<template #icon><ArrowUpOutlined /></template>
</a-button>
<a-button
type="text"
:disabled="index === stages.length - 1 || Boolean(busyId)"
title="انتقال به بعد"
aria-label="انتقال به بعد"
@click="move(index, 1)"
>
▼
</button>
<UiButton
size="sm"
type="button"
<template #icon><ArrowDownOutlined /></template>
</a-button>
<a-button
type="primary"
:disabled="!isDirty(stage) || Boolean(busyId)"
:loading="busyId === stage.id"
@click="save(stage)"
>
ذخیره
</UiButton>
<button
type="button"
class="icon danger"
</a-button>
<a-button
type="text"
danger
:disabled="Boolean(busyId)"
title="حذف ستون"
aria-label="حذف ستون"
@click="remove(stage)"
>
<UiIcon name="trash" :size="15" />
</button>
</div>
</div>
</div>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-flex>
</a-col>
</a-row>
</a-flex>
<form class="create" @submit.prevent="create">
<strong>ستون جدید</strong>
<div class="create-fields">
<input v-model="createForm.name" maxlength="80" placeholder="مثلاً: منتظر پاسخ مشتری" />
<select v-model="createForm.kind" aria-label="نوع ستون جدید">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-model="createForm.color" aria-label="رنگ ستون جدید">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
<a-divider />
<a-flex vertical gap="small" @submit.prevent="create">
<a-typography-text strong>ستون جدید</a-typography-text>
<a-row :gutter="[8, 8]" align="middle">
<a-col :xs="24" :sm="10">
<a-input
v-model:value="createForm.name"
:maxlength="80"
placeholder="مثلاً: منتظر پاسخ مشتری"
/>
</a-col>
<a-col :xs="12" :sm="5">
<a-select v-model:value="createForm.kind" aria-label="نوع ستون جدید" style="width: 100%">
<a-select-option v-for="k in KINDS" :key="k" :value="k">
{{ ACTIVITY_STAGE_KIND_LABELS[k] }}
</a-select-option>
</a-select>
</a-col>
<a-col :xs="12" :sm="5">
<a-select v-model:value="createForm.color" aria-label="رنگ ستون جدید" style="width: 100%">
<a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<UiButton
variant="primary"
type="submit"
</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"
>
افزودن
</UiButton>
</div>
</form>
</div>
</UiModal>
</a-button>
</a-col>
</a-row>
</a-flex>
</a-flex>
</a-modal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.9rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.84rem;
line-height: 1.8;
}
.error {
margin: 0;
color: #b91c1c;
}
.rows {
display: grid;
gap: 0.5rem;
}
.row {
display: grid;
grid-template-columns: 0.7rem minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.dot {
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
background: var(--dot);
}
input,
select {
min-height: var(--control-h);
padding: 0 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-size: 0.86rem;
min-width: 0;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.icon {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: #475569;
font-size: 0.7rem;
cursor: pointer;
}
.icon:disabled {
opacity: 0.4;
cursor: default;
}
.icon.danger {
color: #dc2626;
}
.create {
display: grid;
gap: 0.5rem;
padding-top: 0.8rem;
border-top: 1px solid var(--line);
}
.create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 0.7rem minmax(0, 1fr) auto;
}
.row select {
grid-column: 2 / -1;
}
.row-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.create-fields {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import AutomationNode from '@/components/automations/AutomationNode.vue';
import {
AUTOMATION_ACTION_LABELS,
@@ -22,6 +21,23 @@ import {
type TeamDto,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { Button as AButton, Divider as ADivider, Space as ASpace, Tooltip as ATooltip } from 'ant-design-vue';
import {
BellOutlined,
DeleteOutlined,
DragOutlined,
ExpandOutlined,
FilterOutlined,
FlagOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
GiftOutlined,
PlusOutlined,
SelectOutlined,
ThunderboltOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@/icons/ant-bridge';
export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction }
@@ -42,8 +58,15 @@ const props = withDefaults(
form?: LeadFormDto | null;
users?: OrgUserDto[];
teams?: TeamDto[];
fullscreen?: boolean;
}>(),
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
{
chainMode: 'stop',
form: null,
users: () => [],
teams: () => [],
fullscreen: false,
},
);
const emit = defineEmits<{
@@ -52,6 +75,7 @@ const emit = defineEmits<{
remove: [id: string];
removeRange: [start: number, end: number];
addBranch: [];
'update:fullscreen': [value: boolean];
}>();
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
@@ -60,6 +84,116 @@ const actionCount = computed(
() => props.steps.filter((s) => !isAutomationCondition(s)).length,
);
type CanvasTool = 'select' | 'hand';
const Z_MIN = 0.4;
const Z_MAX = 2;
const Z_STEP = 0.1;
const tool = ref<CanvasTool>('select');
const zoom = ref(1);
const pan = ref({ x: 0, y: 0 });
const viewportRef = ref<HTMLElement | null>(null);
const panning = ref(false);
const panOrigin = ref({ x: 0, y: 0, panX: 0, panY: 0 });
const didPan = ref(false);
const zoomLabel = computed(() => `${toFaDigits(Math.round(zoom.value * 100))}٪`);
const worldStyle = computed(() => ({
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
}));
function clampZoom(v: number) {
return Math.min(Z_MAX, Math.max(Z_MIN, Math.round(v * 100) / 100));
}
function setTool(next: CanvasTool) {
tool.value = next;
addAt.value = null;
}
function zoomBy(delta: number) {
zoom.value = clampZoom(zoom.value + delta);
}
function resetView() {
zoom.value = 1;
pan.value = { x: 0, y: 0 };
}
function onWheel(e: WheelEvent) {
const el = viewportRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const prev = zoom.value;
const next = clampZoom(prev * (e.deltaY > 0 ? 0.9 : 1.1));
if (next === prev) return;
const wx = (mx - pan.value.x) / prev;
const wy = (my - pan.value.y) / prev;
pan.value = { x: mx - wx * next, y: my - wy * next };
zoom.value = next;
}
function onPointerDown(e: PointerEvent) {
const hand = tool.value === 'hand' || e.button === 1 || e.altKey;
if (!hand || e.button === 2) return;
e.preventDefault();
panning.value = true;
didPan.value = false;
panOrigin.value = {
x: e.clientX,
y: e.clientY,
panX: pan.value.x,
panY: pan.value.y,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!panning.value) return;
const dx = e.clientX - panOrigin.value.x;
const dy = e.clientY - panOrigin.value.y;
if (Math.abs(dx) + Math.abs(dy) > 3) didPan.value = true;
pan.value = {
x: panOrigin.value.panX + dx,
y: panOrigin.value.panY + dy,
};
}
function onPointerUp(e: PointerEvent) {
if (!panning.value) return;
panning.value = false;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
function onViewportClick() {
if (didPan.value) {
didPan.value = false;
return;
}
addAt.value = null;
}
function onDocPointerDown(e: PointerEvent) {
if (!addAt.value) return;
const target = e.target;
if (!(target instanceof Element)) return;
if (target.closest('.picker') || target.closest('.plus')) return;
addAt.value = null;
}
onMounted(() => {
document.addEventListener('pointerdown', onDocPointerDown, true);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown, true);
});
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
const steps = props.steps;
const { prefixEnd, branches } = splitAutomationBranches(steps);
@@ -151,10 +285,10 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده';
}
function actionIcon(action: AutomationAction) {
if (action === 'create_notification') return 'bell';
if (action === 'add_loyalty_points') return 'gift';
return 'flag';
function actionIcon(action: AutomationAction): Component {
if (action === 'create_notification') return BellOutlined;
if (action === 'add_loyalty_points') return GiftOutlined;
return FlagOutlined;
}
function stepTitle(step: AutomationStep) {
@@ -208,13 +342,20 @@ function stepHint(step: AutomationStep) {
}
function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value) return;
if (!canAdd.value || tool.value === 'hand') return;
addAt.value =
addAt.value?.key === item.key
? null
: { key: item.key, index: item.index, allow: item.allow };
}
function actionShort(action: AutomationAction) {
if (action === 'create_activity') return 'فعالیت';
if (action === 'create_notification') return 'اعلان / پیامک';
if (action === 'add_loyalty_points') return 'امتیاز باشگاه';
return AUTOMATION_ACTION_LABELS[action];
}
function pickAction(action: AutomationAction) {
if (!addAt.value) return;
emit('add', { kind: 'action', action }, addAt.value.index);
@@ -234,7 +375,19 @@ function removeBranch(lane: BranchLane) {
</script>
<template>
<div class="canvas" @click.self="addAt = null">
<div class="canvas-shell">
<div
ref="viewportRef"
class="canvas"
:class="{ hand: tool === 'hand', panning }"
@wheel.prevent="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@click.self="onViewportClick"
>
<div class="world" :style="worldStyle" :class="{ locked: tool === 'hand' }">
<div class="flow">
<button
class="node trigger"
@@ -243,7 +396,7 @@ function removeBranch(lane: BranchLane) {
@click="emit('select', 'trigger')"
>
<span class="icon">
<UiIcon name="sparkle" :size="18" />
<ThunderboltOutlined />
</span>
<span class="copy">
<span class="kind">اگر</span>
@@ -265,8 +418,46 @@ function removeBranch(lane: BranchLane) {
title="افزودن گره"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="item.end ? 16 : 14" />
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
</button>
<Transition name="auto-picker">
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
</span>
</button>
</template>
</div>
</Transition>
</div>
<AutomationNode
v-else
@@ -292,7 +483,7 @@ function removeBranch(lane: BranchLane) {
title="حذف این شاخه"
@click.stop="removeBranch(lane)"
>
<UiIcon name="trash" :size="12" />
<DeleteOutlined :style="{ fontSize: '12px' }" />
</button>
</div>
<template v-for="item in lane.items" :key="item.key">
@@ -308,8 +499,46 @@ function removeBranch(lane: BranchLane) {
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="14" />
<PlusOutlined :style="{ fontSize: '14px' }" />
</button>
<Transition name="auto-picker">
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
</span>
</button>
</template>
</div>
</Transition>
</div>
<AutomationNode
v-else
@@ -330,65 +559,146 @@ function removeBranch(lane: BranchLane) {
:title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')"
>
<UiIcon name="plus" :size="14" />
<PlusOutlined :style="{ fontSize: '14px' }" />
شاخهٔ جدید
</button>
</div>
</template>
</div>
<div v-if="addAt" class="picker" @click.stop>
<p>
{{
addAt.allow === 'condition'
? 'شرط جدید با «و» به شرط‌های این شاخه اضافه می‌شود'
: 'گره بعدی را انتخاب کنید'
}}
</p>
<div class="picks">
<button
v-if="addAt.allow !== 'action'"
type="button"
class="condition"
@click="pickCondition"
>
<UiIcon name="filter" :size="16" />
شرط
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
:class="action"
@click="pickAction(action)"
>
<UiIcon :name="actionIcon(action)" :size="16" />
{{ AUTOMATION_ACTION_LABELS[action] }}
</button>
</template>
</div>
<button class="cancel" type="button" @click="addAt = null">انصراف</button>
</div>
<p v-if="!canAdd" class="cap">
حداکثر {{ toFaDigits(MAX_AUTOMATION_STEPS) }} گره در یک قانون
</p>
</div>
</div>
<div class="island" @pointerdown.stop @click.stop>
<a-space :size="4">
<a-tooltip title="انتخاب">
<a-button
type="text"
:class="{ on: tool === 'select' }"
@click="setTool('select')"
>
<template #icon><SelectOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="جابه‌جایی (دست)">
<a-button
type="text"
:class="{ on: tool === 'hand' }"
@click="setTool('hand')"
>
<template #icon><DragOutlined /></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" :style="{ height: '20px', margin: 0 }" />
<a-tooltip title="کوچک‌نمایی">
<a-button type="text" :disabled="zoom <= Z_MIN" @click="zoomBy(-Z_STEP)">
<template #icon><ZoomOutOutlined /></template>
</a-button>
</a-tooltip>
<a-button type="text" class="zoom-label" @click="resetView">
{{ zoomLabel }}
</a-button>
<a-tooltip title="بزرگ‌نمایی">
<a-button type="text" :disabled="zoom >= Z_MAX" @click="zoomBy(Z_STEP)">
<template #icon><ZoomInOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="بازنشانی نما">
<a-button type="text" @click="resetView">
<template #icon><ExpandOutlined /></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" :style="{ height: '20px', margin: 0 }" />
<a-tooltip :title="fullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<a-button
type="text"
:class="{ on: fullscreen }"
@click="emit('update:fullscreen', !fullscreen)"
>
<template #icon>
<FullscreenExitOutlined v-if="fullscreen" />
<FullscreenOutlined v-else />
</template>
</a-button>
</a-tooltip>
</a-space>
</div>
</div>
</template>
<style scoped>
.canvas-shell {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 14rem;
flex: 1 1 auto;
}
.canvas {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 22rem;
border-radius: var(--radius);
overflow: auto;
overflow: hidden;
background-color: #f3f6f5;
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
background-size: 16px 16px;
padding: 1.6rem 1.1rem 1.25rem;
touch-action: none;
}
.canvas.hand {
cursor: grab;
}
.canvas.hand.panning {
cursor: grabbing;
}
.world {
transform-origin: 0 0;
width: max-content;
min-height: 100%;
padding: 1.1rem 1rem 4rem;
will-change: transform;
}
.world.locked {
pointer-events: none;
}
.island {
position: absolute;
z-index: 6;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: #fff;
border: 1px solid #d5ddd8;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
.island :deep(.ant-btn.on) {
color: #0f766e;
background: #ecfdf8;
}
.island :deep(.zoom-label) {
min-width: 3.2rem;
padding-inline: 4px;
font-variant-numeric: tabular-nums;
font-size: 12px;
font-weight: 700;
color: #334155;
}
.flow {
@@ -396,7 +706,7 @@ function removeBranch(lane: BranchLane) {
flex-direction: row;
align-items: center;
min-width: max-content;
min-height: 16rem;
min-height: calc(100% - 2rem);
}
.node.trigger {
@@ -433,6 +743,7 @@ function removeBranch(lane: BranchLane) {
flex-shrink: 0;
color: #c2410c;
background: #ffedd5;
font-size: 18px;
}
.copy {
@@ -535,7 +846,7 @@ strong {
position: relative;
display: flex;
align-items: center;
padding-block: 1.3rem;
padding-block: 0.7rem;
}
.branch::before {
@@ -608,66 +919,114 @@ strong {
}
.picker {
margin-top: 1rem;
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
z-index: 30;
width: 220px;
padding: 6px;
display: grid;
gap: 2px;
background: #fff;
border: 1px solid #d5ddd8;
border-radius: var(--radius);
padding: 0.85rem;
border-radius: 12px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.auto-picker-enter-active,
.auto-picker-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: top center;
will-change: opacity, transform;
pointer-events: none;
}
.auto-picker-enter-from,
.auto-picker-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(6px) scale(0.97);
}
.auto-picker-enter-to,
.auto-picker-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
.picker-head {
padding: 6px 8px 8px;
font-size: 11px;
font-weight: 700;
color: #94a3b8;
}
.pick {
display: grid;
gap: 0.65rem;
max-width: 40rem;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
}
.picker p {
margin: 0;
font-size: 0.82rem;
font-weight: 700;
color: #334155;
}
.picks {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.picks button {
display: inline-flex;
grid-template-columns: 32px 1fr;
align-items: center;
gap: 0.4rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.45rem 0.75rem;
font: inherit;
font-weight: 700;
font-size: 0.8rem;
gap: 8px;
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
padding: 6px 8px;
text-align: start;
cursor: pointer;
color: #0f172a;
font: inherit;
color: inherit;
}
.picks .create_activity {
.pick:hover {
background: #f1f5f9;
}
.pick-ico {
width: 32px;
height: 32px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 15px;
background: #ecfdf8;
color: #0f766e;
}
.picks .create_notification {
color: #1d4ed8;
}
.picks .add_loyalty_points {
color: #7c3aed;
}
.picks .condition {
.pick.condition .pick-ico {
background: #fff7ed;
color: #b45309;
}
.cancel {
justify-self: start;
border: 0;
background: none;
color: #64748b;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
.pick.create_notification .pick-ico {
background: #eff6ff;
color: #1d4ed8;
}
.pick.add_loyalty_points .pick-ico {
background: #f5f3ff;
color: #7c3aed;
}
.pick-copy {
display: grid;
gap: 1px;
min-width: 0;
}
.pick-copy strong {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.pick-copy small {
font-size: 11px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cap {
@@ -680,7 +1039,10 @@ strong {
@media (max-width: 700px) {
.canvas {
min-height: 0;
padding: 1rem 0.75rem;
}
.world {
padding: 1rem 0.75rem 4rem;
}
.flow,
@@ -749,7 +1111,7 @@ strong {
}
.picker {
max-width: none;
width: min(220px, 70vw);
}
}
</style>
@@ -31,6 +31,24 @@ import {
formConditionValues,
isAutomationCondition,
} from '@mento/shared';
import {
Alert as AAlert,
Checkbox as ACheckbox,
CheckboxGroup as ACheckboxGroup,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
RadioGroup as ARadioGroup,
Select as ASelect,
Textarea as ATextarea,
Typography,
theme,
} from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = defineProps<{
selected: 'trigger' | string;
@@ -124,6 +142,87 @@ function toggleUser(step: AutomationStep, id: string, on: boolean) {
step.assigneeUserIds = [...cur];
}
function onUserCheck(step: AutomationStep, id: string, e: { target: { checked: boolean } }) {
toggleUser(step, id, e.target.checked);
}
function onConditionFieldChange(v: unknown) {
if (typeof v === 'string' && selectedStep.value) {
setConditionField(selectedStep.value, v as AutomationConditionField);
}
}
function onConditionKeyChange(v: unknown) {
if (typeof v === 'string' && selectedStep.value) setConditionKey(selectedStep.value, v);
}
function onActionChange(v: unknown) {
if (typeof v === 'string' && selectedStep.value) {
setAction(selectedStep.value, v as AutomationAction);
}
}
function onDueUnitChange(v: unknown) {
if (!selectedStep.value || typeof v !== 'string') return;
setDue(selectedStep.value, v as DueUnit, dueAmountOf(selectedStep.value) || 1);
}
function onDueAmountChange(v: string | number | null) {
if (!selectedStep.value) return;
setDue(selectedStep.value, dueUnitOf(selectedStep.value), Number(v ?? 1));
}
function onFormOptionValuesChange(vals: Array<string | number | boolean>) {
if (!selectedStep.value) return;
const next = vals.map(String);
const order = conditionFormField.value?.options ?? [];
next.sort((a, b) => order.indexOf(a) - order.indexOf(b));
selectedStep.value.conditionValues = next;
selectedStep.value.conditionValue = next[0] ?? '';
}
const dealAmountValue = computed({
get: () =>
typeof selectedStep.value?.conditionValue === 'number'
? selectedStep.value.conditionValue
: Number(selectedStep.value?.conditionValue ?? 0),
set: (v: number | null) => {
if (selectedStep.value) selectedStep.value.conditionValue = Number(v ?? 0);
},
});
const formTextValue = computed({
get: () => String(selectedStep.value?.conditionValue ?? ''),
set: (v: string) => {
if (selectedStep.value) selectedStep.value.conditionValue = v;
},
});
function nullableId(value: string | null | undefined) {
return value ?? '';
}
function setNullableId(
key: 'activityTypeDefId' | 'assigneeId' | 'assigneeTeamId' | 'notifyUserId',
v: unknown,
) {
if (!selectedStep.value) return;
selectedStep.value[key] = typeof v === 'string' && v ? v : null;
}
function onActivityTypeDefChange(v: unknown) {
setNullableId('activityTypeDefId', v);
}
function onAssigneeIdChange(v: unknown) {
setNullableId('assigneeId', v);
}
function onAssigneeTeamIdChange(v: unknown) {
setNullableId('assigneeTeamId', v);
}
function onNotifyUserIdChange(v: unknown) {
setNullableId('notifyUserId', v);
}
function setConditionKey(step: AutomationStep, key: string) {
step.conditionKey = key || null;
const field = formFields.value.find((f) => f.key === key);
@@ -131,15 +230,6 @@ function setConditionKey(step: AutomationStep, key: string) {
step.conditionValues = step.conditionValue ? [step.conditionValue] : [];
}
function toggleConditionValue(step: AutomationStep, option: string, on: boolean) {
const cur = formConditionValues(step).filter((v) => v !== option);
if (on) cur.push(option);
const order = conditionFormField.value?.options ?? [];
cur.sort((a, b) => order.indexOf(a) - order.indexOf(b));
step.conditionValues = cur;
step.conditionValue = cur[0] ?? '';
}
function multiValueHint(op: AutomationConditionOp | undefined) {
if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینه‌ها انتخاب شده باشد.';
if (op === 'not_contains') return 'هیچ‌کدام از این گزینه‌ها نباید انتخاب شده باشد.';
@@ -162,6 +252,94 @@ const ticketPriorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'urgent', label: 'فوری' },
];
const triggerOptions = AUTOMATION_TRIGGER_GROUPS.map((group) => ({
label: group.label,
options: group.items.map((t) => ({
value: t,
label: AUTOMATION_TRIGGER_LABELS[t],
})),
}));
const formOptions = computed(() => [
{ value: '', label: 'همه فرم‌ها' },
...props.forms.map((f) => ({ value: f.id, label: f.name })),
]);
const sourceIdSelect = computed({
get: () => sourceId.value ?? '',
set: (v: string) => {
sourceId.value = v || null;
},
});
const chainModeOptions = [
{ value: 'branches' as const, label: AUTOMATION_CHAIN_MODE_LABELS.branches },
{ value: 'stop' as const, label: AUTOMATION_CHAIN_MODE_LABELS.stop },
];
const conditionFieldOptions = computed(() =>
conditionFields.value.map((f) => ({
value: f,
label: AUTOMATION_CONDITION_FIELD_LABELS[f],
})),
);
const conditionOpOptions = computed(() =>
conditionOps.value.map((op) => ({
value: op,
label: AUTOMATION_CONDITION_OP_LABELS[op],
})),
);
const formFieldOptions = computed(() =>
formFields.value.map((f) => ({ value: f.key, label: f.label })),
);
const actionOptions = AUTOMATION_ACTIONS.map((a) => ({
value: a,
label: AUTOMATION_ACTION_LABELS[a],
}));
const activityTypeOptions = activityTypes.map((t) => ({
value: t.value,
label: t.label,
}));
const activityTypeDefOptions = computed(() => [
{ value: '', label: '— بدون نوع سفارشی —' },
...props.activityTypeDefs.map((t) => ({ value: t.id, label: t.name })),
]);
const assignModeOptions = computed(() =>
assignModes.value.map((m) => ({
value: m,
label: AUTOMATION_ASSIGN_MODE_LABELS[m],
})),
);
const userOptions = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
);
const teamOptions = computed(() =>
props.teams.map((t) => ({
value: t.id,
label: `${t.name} (${t.memberCount} نفر)`,
})),
);
const notifyUserOptions = computed(() => [
{ value: '', label: 'مسئول معامله / تیکت' },
...userOptions.value,
]);
const notifyChannelOptions = AUTOMATION_NOTIFY_CHANNELS.map((ch) => ({
value: ch,
label: AUTOMATION_NOTIFY_CHANNEL_LABELS[ch],
}));
const dueUnitOptions = dueUnits.map((u) => ({ value: u.value, label: u.label }));
function setAction(step: AutomationStep, action: AutomationAction) {
step.kind = 'action';
step.action = action;
@@ -202,440 +380,273 @@ watch(
</script>
<template>
<aside class="inspector">
<a-form layout="vertical" :style="{ marginBottom: 0 }">
<template v-if="selected === 'trigger'">
<h2>گره محرک</h2>
<p class="hint">وقتی این اتفاق بیفتد، زنجیره از راست به چپ اجرا می‌شود.</p>
<label>
اگر
<select v-model="trigger">
<optgroup
v-for="group in AUTOMATION_TRIGGER_GROUPS"
:key="group.label"
:label="group.label"
<a-form-item label="اگر" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select v-model:value="trigger" :options="triggerOptions" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="trigger === 'lead_submitted'"
label="فرم"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<option v-for="t in group.items" :key="t" :value="t">
{{ AUTOMATION_TRIGGER_LABELS[t] }}
</option>
</optgroup>
</select>
</label>
<label v-if="trigger === 'lead_submitted'">
فرم
<select v-model="sourceId">
<option :value="null">همه فرم‌ها</option>
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</label>
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p>
<label v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها
<select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
</select>
</label>
<p v-if="chainMode === 'branches'" class="hint">
شاخه‌ها روی بوم موازی نمایش داده می‌شوند و هر کدام جدا بررسی می‌شود. شرط‌های یک شاخه با «و»
ترکیب می‌شوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا می‌شود. برای شاخهٔ تازه از
دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
</p>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p>
<a-select v-model:value="sourceIdSelect" :options="formOptions" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="advanced || chainMode === 'branches'"
label="رفتار شرط‌ها"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select v-model:value="chainMode" :options="chainModeOptions" style="width: 100%" />
</a-form-item>
<a-alert
v-if="trigger === 'lead_submitted' && !sourceId"
type="info"
show-icon
message="برای شرط روی پاسخ فرم، یک فرم مشخص انتخاب کنید."
/>
</template>
<template v-else-if="selectedStep && isCondition">
<h2>گره شرط</h2>
<p class="hint">
{{
chainMode === 'branches'
? 'اگر شرط برقرار نباشد، فقط عمل‌های این شاخه (تا شرط بعدی) اجرا نمی‌شوند.'
: 'اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند.'
}}
</p>
<label>
فیلد
<select
<a-form-item label="فیلد" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
:value="selectedStep.conditionField"
@change="
setConditionField(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationConditionField,
)
"
>
<option v-for="f in conditionFields" :key="f" :value="f">
{{ AUTOMATION_CONDITION_FIELD_LABELS[f] }}
</option>
</select>
</label>
<template v-if="selectedStep.conditionField === 'form_field'">
<p v-if="!selectedForm" class="hint warn">
ابتدا در گره محرک یک فرم مشخص انتخاب کنید.
</p>
<label v-else>
فیلد فرم
<select
:value="selectedStep.conditionKey ?? ''"
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>انتخاب فیلد…</option>
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option>
</select>
</label>
</template>
<label>
مقایسه
<select v-model="selectedStep.conditionOp">
<option v-for="op in conditionOps" :key="op" :value="op">
{{ AUTOMATION_CONDITION_OP_LABELS[op] }}
</option>
</select>
</label>
<label v-if="selectedStep.conditionField === 'deal_amount'">
مبلغ
<input v-model.number="selectedStep.conditionValue" type="number" min="0" />
</label>
<label v-if="selectedStep.conditionField === 'ticket_priority'">
اولویت
<select v-model="selectedStep.conditionValue">
<option v-for="p in ticketPriorities" :key="p.value" :value="p.value">
{{ p.label }}
</option>
</select>
</label>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<div v-if="conditionFormField?.options?.length" class="user-picks">
<p class="field-label">گزینه‌ها (یک یا چند مورد)</p>
<label v-for="o in conditionFormField.options" :key="o" class="ch">
<input
type="checkbox"
:checked="formConditionValues(selectedStep).includes(o)"
@change="toggleConditionValue(selectedStep, o, ($event.target as HTMLInputElement).checked)"
:options="conditionFieldOptions"
style="width: 100%"
@change="onConditionFieldChange"
/>
{{ o }}
</label>
<p v-if="formConditionValues(selectedStep).length > 1" class="hint">
</a-form-item>
<a-form-item
v-if="selectedStep.conditionField === 'form_field' && selectedForm"
label="فیلد فرم"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="selectedStep.conditionKey ?? undefined"
:options="formFieldOptions"
placeholder="انتخاب فیلد…"
style="width: 100%"
@change="onConditionKeyChange"
/>
</a-form-item>
<a-form-item label="مقایسه" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.conditionOp"
:options="conditionOpOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.conditionField === 'deal_amount'"
label="مبلغ"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-input-number v-model:value="dealAmountValue" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="selectedStep.conditionField === 'ticket_priority'"
label="اولویت"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
v-model:value="selectedStep.conditionValue"
:options="ticketPriorities"
style="width: 100%"
/>
</a-form-item>
<a-alert
v-if="selectedStep.conditionField === 'form_field' && !selectedForm"
type="warning"
show-icon
message="ابتدا در گره محرک یک فرم مشخص انتخاب کنید."
/>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<a-form-item
v-if="conditionFormField?.options?.length"
label="گزینه‌ها"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-checkbox-group
:value="formConditionValues(selectedStep)"
:options="conditionFormField.options.map((o) => ({ value: o, label: o }))"
@change="onFormOptionValuesChange"
/>
<a-typography-text
v-if="formConditionValues(selectedStep).length > 1"
type="secondary"
:style="{ display: 'block', marginTop: `${token.marginXXS}px` }"
>
{{ multiValueHint(selectedStep.conditionOp) }}
</p>
</div>
<label v-else>
مقدار
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
</label>
</a-typography-text>
</a-form-item>
<a-form-item v-else label="مقدار" :style="{ marginBottom: `${token.marginSM}px` }">
<a-input v-model:value="formTextValue" :maxlength="200" />
</a-form-item>
</template>
</template>
<template v-else-if="selectedStep">
<h2>گره عمل</h2>
<p class="hint">بعد از محرک (و شرط‌ها)، این کار به ترتیب انجام می‌شود.</p>
<label>
آنگاه
<select
<a-form-item label="آنگاه" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
:value="selectedStep.action"
@change="
setAction(
selectedStep,
($event.target as HTMLSelectElement).value as AutomationAction,
)
"
>
<option v-for="a in AUTOMATION_ACTIONS" :key="a" :value="a">
{{ AUTOMATION_ACTION_LABELS[a] }}
</option>
</select>
</label>
<label>
عنوان
<input
v-model="selectedStep.title"
type="text"
maxlength="200"
:options="actionOptions"
style="width: 100%"
@change="onActionChange"
/>
</a-form-item>
<a-form-item label="عنوان" :style="{ marginBottom: `${token.marginSM}px` }">
<a-input
v-model:value="selectedStep.title"
:maxlength="200"
placeholder="اختیاری"
/>
</label>
<label>
توضیح / متن پیام
<textarea
v-model="selectedStep.body"
rows="3"
maxlength="1000"
</a-form-item>
<a-form-item label="توضیح / متن" :style="{ marginBottom: `${token.marginSM}px` }">
<a-textarea
v-model:value="selectedStep.body"
:rows="2"
:maxlength="1000"
placeholder="اختیاری"
/>
</label>
</a-form-item>
<label v-if="selectedStep.action === 'create_activity'">
نوع فعالیت
<select v-model="selectedStep.activityType">
<option v-for="t in activityTypes" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</label>
<template v-if="selectedStep.action === 'create_activity'">
<label v-if="activityTypeDefs.length">
نوع سفارشی (اختیاری)
<select v-model="selectedStep.activityTypeDefId">
<option :value="null">— بدون نوع سفارشی —</option>
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</label>
<label>
تخصیص به
<select v-model="selectedStep.assignMode">
<option v-for="m in assignModes" :key="m" :value="m">
{{ AUTOMATION_ASSIGN_MODE_LABELS[m] }}
</option>
</select>
</label>
<label v-if="selectedStep.assignMode === 'user'">
کاربر
<select v-model="selectedStep.assigneeId">
<option :value="null" disabled>انتخاب کاربر…</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
</label>
<template v-else-if="selectedStep.assignMode === 'team_round_robin'">
<label>
تیم
<select v-model="selectedStep.assigneeTeamId">
<option :value="null" disabled>انتخاب تیم…</option>
<option v-for="t in teams" :key="t.id" :value="t.id">
{{ t.name }} ({{ t.memberCount }} نفر)
</option>
</select>
</label>
<p v-if="!teams.length" class="hint warn">
هنوز تیمی تعریف نشده؛ از «تنظیمات ← تیم‌ها و سطح دسترسی» بسازید.
</p>
</template>
<div v-else-if="selectedStep.assignMode === 'users_round_robin'" class="user-picks">
<p class="field-label">کاربران</p>
<label v-for="u in users" :key="u.id" class="ch">
<input
type="checkbox"
<a-form-item label="نوع فعالیت" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.activityType"
:options="activityTypeOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="activityTypeDefs.length"
label="نوع سفارشی"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="nullableId(selectedStep.activityTypeDefId)"
:options="activityTypeDefOptions"
style="width: 100%"
@change="onActivityTypeDefChange"
/>
</a-form-item>
<a-form-item label="تخصیص به" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.assignMode"
:options="assignModeOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.assignMode === 'user'"
label="کاربر"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="nullableId(selectedStep.assigneeId)"
:options="userOptions"
placeholder="انتخاب کاربر…"
style="width: 100%"
@change="onAssigneeIdChange"
/>
</a-form-item>
<a-form-item
v-else-if="selectedStep.assignMode === 'team_round_robin'"
label="تیم"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-select
:value="nullableId(selectedStep.assigneeTeamId)"
:options="teamOptions"
placeholder="انتخاب تیم…"
style="width: 100%"
@change="onAssigneeTeamIdChange"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.assignMode === 'users_round_robin'"
label="کاربران"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-flex vertical :gap="token.marginXXS">
<a-checkbox
v-for="u in users"
:key="u.id"
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
@change="toggleUser(selectedStep, u.id, ($event.target as HTMLInputElement).checked)"
/>
@change="onUserCheck(selectedStep, u.id, $event)"
>
{{ u.fullName }}
</label>
</div>
<p
v-if="selectedStep.assignMode === 'team_round_robin' || selectedStep.assignMode === 'users_round_robin'"
class="hint"
>
هر بار نفر بعدی به نوبت انتخاب می‌شود تا کارها مساوی تقسیم شوند.
</p>
<div class="due">
<label>
موعد
<select
</a-checkbox>
</a-flex>
</a-form-item>
<a-form-item label="موعد" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
:value="dueUnitOf(selectedStep)"
@change="
setDue(
selectedStep,
($event.target as HTMLSelectElement).value as DueUnit,
dueAmountOf(selectedStep) || 1,
)
"
:options="dueUnitOptions"
style="width: 100%"
@change="onDueUnitChange"
/>
</a-form-item>
<a-form-item
v-if="dueUnitOf(selectedStep) !== 'now'"
label="تعداد"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<option v-for="u in dueUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
</select>
</label>
<label v-if="dueUnitOf(selectedStep) !== 'now'">
تعداد
<input
type="number"
min="1"
<a-input-number
:min="1"
:value="dueAmountOf(selectedStep)"
@input="
setDue(
selectedStep,
dueUnitOf(selectedStep),
Number(($event.target as HTMLInputElement).value),
)
"
style="width: 100%"
@change="onDueAmountChange"
/>
</a-form-item>
<a-alert
v-if="selectedStep.assignMode === 'team_round_robin' && !teams.length"
type="warning"
show-icon
message="هنوز تیمی تعریف نشده."
/>
</label>
</div>
</template>
<p v-if="selectedStep.action === 'create_activity' || selectedStep.action === 'create_notification'" class="hint">
در عنوان و متن می‌توانید از
<code v-pre>{{title}}</code>، <code v-pre>{{mobile}}</code>، <code v-pre>{{form}}</code> و
<code v-pre>{{field.کلید}}</code> استفاده کنید.
<template v-if="formFields.length">
کلیدها:
<span v-for="(f, i) in formFields" :key="f.key">
<code>{{ f.key }}</code> ({{ f.label }})<template v-if="i < formFields.length - 1">، </template>
</span>
</template>
</p>
<template v-if="selectedStep.action === 'create_notification'">
<p class="field-label">نحوه ارسال</p>
<div class="channels">
<label
v-for="ch in AUTOMATION_NOTIFY_CHANNELS"
:key="ch"
class="ch"
>
<input
v-model="selectedStep.notifyChannel"
type="radio"
:value="ch"
<a-form-item label="نحوه ارسال" :style="{ marginBottom: `${token.marginSM}px` }">
<a-radio-group
v-model:value="selectedStep.notifyChannel"
:options="notifyChannelOptions"
/>
{{ AUTOMATION_NOTIFY_CHANNEL_LABELS[ch] }}
</label>
</div>
<label>
گیرنده
<select v-model="selectedStep.notifyTarget">
<option value="staff">کاربر تیم</option>
<option value="person">مخاطب معامله / تیکت</option>
</select>
</label>
<label v-if="selectedStep.notifyTarget !== 'person'">
کاربر
<select v-model="selectedStep.notifyUserId">
<option :value="null">مسئول معامله / تیکت</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</label>
<p v-if="selectedStep.notifyTarget === 'person'" class="hint">
پیامک به موبایل مخاطب ارسال می‌شود. ناتیفیکیشن داخلی برای مخاطب نیست.
</p>
<p
v-else-if="selectedStep.notifyChannel === 'sms' || selectedStep.notifyChannel === 'both'"
class="hint"
</a-form-item>
<a-form-item label="گیرنده" :style="{ marginBottom: `${token.marginSM}px` }">
<a-select
v-model:value="selectedStep.notifyTarget"
:options="[
{ value: 'staff', label: 'کاربر تیم' },
{ value: 'person', label: 'مخاطب معامله / تیکت' },
]"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-if="selectedStep.notifyTarget !== 'person'"
label="کاربر"
:style="{ marginBottom: `${token.marginSM}px` }"
>
پیامک به موبایل همان کاربر ارسال می‌شود.
</p>
<a-select
:value="nullableId(selectedStep.notifyUserId)"
:options="notifyUserOptions"
style="width: 100%"
@change="onNotifyUserIdChange"
/>
</a-form-item>
</template>
<label v-if="selectedStep.action === 'add_loyalty_points'">
امتیاز
<input v-model.number="selectedStep.points" type="number" min="1" />
</label>
<a-form-item
v-if="selectedStep.action === 'add_loyalty_points'"
label="امتیاز"
:style="{ marginBottom: `${token.marginSM}px` }"
>
<a-input-number v-model:value="selectedStep.points" :min="1" style="width: 100%" />
</a-form-item>
</template>
<p v-else class="hint">یک گره را از بوم انتخاب کنید.</p>
</aside>
<a-typography-text v-else type="secondary">یک گره را از بوم انتخاب کنید.</a-typography-text>
</a-form>
</template>
<style scoped>
.inspector {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
gap: 0.75rem;
padding: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-width: 0;
}
h2 {
margin: 0;
font-size: 0.98rem;
font-weight: 600;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.82rem;
line-height: 1.6;
}
label,
.field-label {
display: grid;
gap: 0.35rem;
font-size: 0.84rem;
color: #64748b;
min-width: 0;
}
.field-label {
margin: 0;
font-weight: 700;
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
color: #0f172a;
background: #fff;
min-width: 0;
max-width: 100%;
text-overflow: ellipsis;
}
.channels {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.85rem;
}
.ch {
display: inline-flex !important;
flex-direction: row;
align-items: center;
gap: 0.32rem;
color: #334155;
cursor: pointer;
white-space: nowrap;
}
.ch input {
accent-color: #14b8a6;
width: 14px;
height: 14px;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.hint code {
font-size: 0.76rem;
background: #f1f5f9;
border-radius: 4px;
padding: 0 0.25rem;
direction: ltr;
unicode-bidi: embed;
}
.user-picks {
display: grid;
gap: 0.4rem;
max-height: 12rem;
overflow: auto;
}
.due {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
align-items: end;
}
.due input {
width: 5.5rem;
}
</style>
@@ -1,6 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, type Component } from 'vue';
import {
BellOutlined,
DeleteOutlined,
FilterOutlined,
FlagOutlined,
GiftOutlined,
} from '@/icons/ant-bridge';
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{
@@ -15,11 +21,11 @@ const emit = defineEmits<{ select: []; remove: [] }>();
const isCondition = computed(() => isAutomationCondition(props.step));
const icon = computed(() => {
if (isCondition.value) return 'filter';
if (props.step.action === 'create_notification') return 'bell';
if (props.step.action === 'add_loyalty_points') return 'gift';
return 'flag';
const icon = computed((): Component => {
if (isCondition.value) return FilterOutlined;
if (props.step.action === 'create_notification') return BellOutlined;
if (props.step.action === 'add_loyalty_points') return GiftOutlined;
return FlagOutlined;
});
const kindClass = computed(() =>
@@ -36,7 +42,7 @@ const kindClass = computed(() =>
@click="emit('select')"
>
<span class="icon">
<UiIcon :name="icon" :size="18" />
<component :is="icon" />
</span>
<span class="copy">
<span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span>
@@ -51,7 +57,7 @@ const kindClass = computed(() =>
title="حذف"
@click.stop="emit('remove')"
>
<UiIcon name="trash" :size="14" />
<DeleteOutlined />
</button>
</div>
</template>
@@ -125,6 +131,7 @@ const kindClass = computed(() =>
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
font-size: 18px;
}
.node.create_notification .icon {
@@ -184,6 +191,7 @@ em {
cursor: pointer;
opacity: 0;
pointer-events: none;
font-size: 14px;
}
.node-wrap:hover .trash,
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardActivityTypeDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
items: HomeDashboardActivityTypeDto[];
}>();
const rows = computed(() => props.items);
const hasData = computed(() => rows.value.some((r) => r.count > 0));
const total = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params;
const point = rows.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.label}<br/>${toFaDigits(point.count)} فعالیت باز`;
},
},
xAxis: {
type: 'category',
data: rows.value.map((r) => r.label),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#94a3b8',
fontFamily: 'IRANSans, Tahoma, sans-serif',
fontSize: 11,
},
},
yAxis: {
type: 'value',
minInterval: 1,
...(hasData.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false },
},
series: [
{
type: 'bar',
data: rows.value.map((r) => r.count),
barMaxWidth: 36,
itemStyle: {
color: '#14b8a6',
borderRadius: [8, 8, 0, 0],
},
label: {
show: true,
position: 'top',
color: '#0f172a',
fontWeight: 700,
fontFamily: 'IRANSans, Tahoma, sans-serif',
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
},
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">ترکیب فعالیت‌های باز</h3>
<span v-if="hasData" class="text-xs text-slate-400">{{ toFaDigits(total) }} مورد</span>
</div>
<a-empty v-if="!hasData" description="فعالیت بازی نیست" />
<div v-else class="h-56">
<UiChart :option="option" height="224px" />
</div>
</section>
</template>
@@ -2,13 +2,30 @@
import { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import UiIcon from '@/components/UiIcon.vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
import { LinkOutlined } from '@/icons/ant-bridge';
const props = defineProps<{
askQuery: string;
@@ -26,11 +43,53 @@ const emit = defineEmits<{
notice: [value: { error: string; done: string }];
}>();
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } = Typography;
const { token } = theme.useToken();
const AFormItem = AForm.Item;
const ATextarea = AInput.TextArea;
type AnswerMetric = { label: string; value: string };
const answerText = computed(() => rewriteGregorianDates(props.answer || '').trim());
/** وقتی پاسخ شبیه «برچسب: مقدار» است، به‌صورت کارت متریک نشان بده */
const answerMetrics = computed((): AnswerMetric[] | null => {
const text = answerText.value;
if (!text) return null;
const linePairs = text
.split(/\n+/)
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const m = line.match(/^([^::]+)\s*[::]\s*(.+)$/);
return m ? { label: m[1].trim(), value: m[2].trim() } : null;
});
if (linePairs.length >= 1 && linePairs.every(Boolean)) {
return linePairs as AnswerMetric[];
}
const inline = [...text.matchAll(/([^::\n]+?)\s*[::]\s*([۰-۹0-9٪%.,+\-]+)/g)].map((m) => ({
label: m[1].trim(),
value: m[2].trim(),
}));
return inline.length >= 2 ? inline : null;
});
const shortcuts = [
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
{ label: 'فعالیت‌های عقب‌افتاده چیست؟', icon: 'calendar' as const },
{ label: 'مهم‌ترین پیگیری امروز چیست؟', icon: 'send' as const },
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
{ label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' },
{ label: 'پیگیری امروز', prompt: 'مهم‌ترین پیگیری امروز چیست؟' },
{ label: 'خلاصه فروش', prompt: 'خلاصه وضعیت فروش را بده' },
];
const activityTypeOptions = [
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'task', label: 'کار' },
{ value: 'note', label: 'یادداشت' },
];
function sourceTo(source: AskMentoSourceDto) {
@@ -45,13 +104,6 @@ function pick(label: string) {
emit('ask', label);
}
const ACTIVITY_LABEL: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const voiceBuf = ref('');
const voiceBusy = ref(false);
const voiceError = ref('');
@@ -60,6 +112,13 @@ const draft = ref<VoiceCommandDraft | null>(null);
const confirming = ref(false);
const answerOpen = ref(false);
const draftOpen = computed({
get: () => Boolean(draft.value && draft.value.intent !== 'none'),
set: (open: boolean) => {
if (!open) cancelDraft();
},
});
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -272,12 +331,43 @@ watch(
</script>
<template>
<article id="home-ai-assistant" class="panel">
<header class="panel-head">
<h2>
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
{{ brand.aiNameFa }}
</h2>
<section id="home-ai-assistant" class="home-ai">
<div class="home-ai__pattern" aria-hidden="true" />
<div class="home-ai__orb home-ai__orb--a" aria-hidden="true" />
<div class="home-ai__orb home-ai__orb--b" aria-hidden="true" />
<header class="home-ai__head">
<div class="home-ai__identity">
<div class="home-ai__avatar">
<MentoAiAvatar :size="42" :show-bubble="false" />
</div>
<div class="home-ai__titles min-w-0">
<h3>{{ brand.aiNameFa }}</h3>
<p>همراه هوشمند کسب‌وکار شما</p>
</div>
</div>
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
</header>
<div class="home-ai__tip">
<template v-if="answer">{{ answer }}</template>
<template v-else-if="error">{{ error }}</template>
<template v-else>
<strong>پیشنهاد هوشمند امروز:</strong>
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
</template>
</div>
<div class="home-ai__composer">
<a-input
:value="askQuery"
:placeholder="brand.askPlaceholderFa"
:bordered="false"
:disabled="asking || voiceBusy || confirming"
class="home-ai__input"
@update:value="emit('update:askQuery', $event)"
@press-enter="onTypedSubmit"
/>
<VoiceToTextButton
variant="toolbar"
hide-error
@@ -286,322 +376,526 @@ watch(
@recording="onVoiceRecording"
@error="voiceError = $event"
/>
</header>
<a-button
type="primary"
:disabled="asking || voiceBusy || confirming"
:loading="asking || voiceBusy"
aria-label="ارسال"
@click="onTypedSubmit"
>
بپرس
</a-button>
</div>
<p class="ai-lead">چه کمکی می‌تونم بکنم؟</p>
<div class="ai-shortcuts">
<div class="home-ai__chips">
<span class="home-ai__chips-label">پیشنهادهای سریع</span>
<div class="home-ai__chip-list">
<button
v-for="item in shortcuts"
:key="item.label"
class="ai-chip"
type="button"
class="home-ai__chip"
:disabled="asking"
@click="pick(item.label)"
@click="pick(item.prompt)"
>
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
<span>{{ item.label }}</span>
{{ item.label }}
</button>
</div>
<div v-if="voiceBusy" class="ai-answer"><p>در حال فهمیدن دستور…</p></div>
<div v-else-if="confirming" class="ai-answer"><p>در حال ثبت…</p></div>
<div v-else-if="voiceDone" class="ai-answer"><p>{{ voiceDone }}</p></div>
<p v-else-if="voiceError" class="ai-error">{{ voiceError }}</p>
<p v-else-if="error" class="ai-error">{{ error }}</p>
<div v-else-if="asking" class="ai-answer">
<p>در حال پاسخ…</p>
</div>
<form class="ai-input" @submit.prevent="onTypedSubmit">
<input
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking || voiceBusy || confirming"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
<UiIcon name="send" :size="15" />
</button>
</form>
<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>
<UiModal
:open="Boolean(draft && draft.intent !== 'none')"
<a-modal
v-model:open="draftOpen"
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft"
destroy-on-close
@cancel="cancelDraft"
>
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p>
<p class="edit-hint">قبل از تأیید می‌توانید متن را اصلاح کنید.</p>
<div v-if="draft?.person" class="modal-fields">
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label>
<label>نام خانوادگی <input v-model="draft.person.lastName" /></label>
<label>موبایل <input v-model="draft.person.mobile" dir="ltr" /></label>
<label>تلفن <input v-model="draft.person.phone" dir="ltr" /></label>
<label>ایمیل <input v-model="draft.person.email" dir="ltr" /></label>
<label>وب‌سایت <input v-model="draft.person.website" dir="ltr" /></label>
<label>معرف <input v-model="draft.person.referrer" /></label>
<label>سمت <input v-model="draft.person.position" /></label>
<label>یادداشت <textarea v-model="draft.person.notes" rows="2" /></label>
</div>
<div v-else-if="draft?.activity" class="modal-fields">
<label>عنوان <input v-model="draft.activity.title" @input="onActivityTitleEdit" /></label>
<label>
نوع
<select v-model="draft.activity.type">
<option v-for="(label, key) in ACTIVITY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<label>
زمان
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
</label>
<label>
مشتری
<input v-model="draft.activity.personName" @input="onActivityPersonEdit" @change="rematchActivityPerson" />
</label>
<p v-if="draft.activity.personName && !draft.activity.personId" class="warn">
در فهرست مشتریان پرونده‌ای با این نام پیدا نشد. فعالیت ذخیره می‌شود، ولی داخل پروندهٔ آن شخص دیده نمی‌شود.
</p>
<label>توضیح <textarea v-model="draft.activity.description" rows="2" /></label>
</div>
<p v-if="draft?.missing.length" class="ai-error">ناقص است: {{ draft.missing.join('، ') }}</p>
<template #footer>
<UiButton variant="ghost" :disabled="confirming" @click="cancelDraft">انصراف</UiButton>
<UiButton variant="primary" :disabled="!draft?.canConfirm || confirming" :loading="confirming" @click="confirmDraft">
تأیید و انجام
</UiButton>
</template>
</UiModal>
<a-typography-paragraph v-if="draft">
{{ rewriteGregorianDates(draft.summary) }}
</a-typography-paragraph>
<a-typography-text type="secondary">قبل از تأیید می‌توانید متن را اصلاح کنید.</a-typography-text>
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer">
<p class="modal-lead answer-body">{{ rewriteGregorianDates(answer || '') }}</p>
<div v-if="sources?.length" class="sources">
<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>
<a-button :disabled="confirming" @click="cancelDraft">انصراف</a-button>
<a-button
type="primary"
:disabled="!draft?.canConfirm || confirming"
:loading="confirming"
@click="confirmDraft"
>
تأیید و انجام
</a-button>
</template>
</a-modal>
<a-modal
v-model:open="answerOpen"
:width="560"
centered
destroy-on-close
:body-style="{ paddingTop: `${token.paddingSM}px` }"
@cancel="closeAnswer"
>
<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>
</div>
<template #footer>
<UiButton variant="primary" @click="closeAnswer">بستن</UiButton>
</a-space>
</a-flex>
</template>
</UiModal>
</article>
</a-flex>
<template #footer>
<a-flex justify="end" :gap="token.paddingXS">
<a-button type="primary" @click="closeAnswer">متوجه شدم</a-button>
</a-flex>
</template>
</a-modal>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
.home-ai {
--home-ai-h: 330px;
position: relative;
display: flex;
flex-direction: column;
}
.panel-head {
margin-bottom: 0.55rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.modal-lead {
margin: 0 0 0.85rem;
color: #334155;
font-size: 0.9rem;
line-height: 1.7;
}
.answer-body {
white-space: pre-wrap;
}
.edit-hint {
margin: -0.35rem 0 0.75rem;
color: #64748b;
font-size: 0.78rem;
}
.modal-fields {
display: grid;
gap: 0.55rem;
}
.modal-fields label {
display: grid;
gap: 0.25rem;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
}
.modal-fields input,
.modal-fields select,
.modal-fields textarea {
width: 100%;
gap: 0.75rem;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
font: inherit;
font-size: 0.88rem;
font-weight: 500;
padding: 0.5rem 0.65rem;
width: 100%;
height: var(--home-ai-h);
min-height: var(--home-ai-h);
max-height: var(--home-ai-h);
padding: 0.85rem;
overflow: hidden;
border-radius: 1rem;
border: 1px solid rgb(226 232 240 / 0.9);
background:
linear-gradient(165deg, #ffffff 0%, #f8fffc 48%, #ffffff 100%);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
.modal-fields textarea {
resize: vertical;
.home-ai__pattern,
.home-ai__orb {
pointer-events: none;
position: absolute;
}
.warn {
display: inline-block;
margin-inline-start: 0.35rem;
color: #b45309;
font-size: 0.75rem;
.home-ai__pattern {
inset: 0;
opacity: 0.55;
background-image:
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.18) 1px, transparent 0),
linear-gradient(
125deg,
rgba(45, 212, 191, 0.06) 0%,
transparent 42%,
rgba(16, 185, 129, 0.05) 100%
);
background-size:
16px 16px,
auto;
mask-image: linear-gradient(140deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2));
}
h2 {
margin: 0;
font-size: 0.92rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
.home-ai__orb {
border-radius: 999px;
filter: blur(28px);
}
.spark {
color: #eab308;
display: grid;
.home-ai__orb--a {
top: -2.5rem;
inset-inline-end: -1.5rem;
width: 7rem;
height: 7rem;
background: rgb(52 211 153 / 0.22);
}
.ai-lead {
margin: 0 0 0.75rem;
padding: 0.55rem 0.75rem;
width: fit-content;
max-width: 100%;
background: #d1fae5;
color: #115e59;
font-size: 0.8rem;
font-weight: 600;
border-radius: 14px 14px 4px 14px;
.home-ai__orb--b {
bottom: -2rem;
inset-inline-start: -1.25rem;
width: 6rem;
height: 6rem;
background: rgb(45 212 191 / 0.16);
}
.ai-shortcuts {
display: grid;
gap: 0.4rem;
margin-bottom: 0.8rem;
flex: 1;
}
.ai-chip {
.home-ai__head,
.home-ai__identity,
.home-ai__composer,
.home-ai__chips,
.home-ai__chip-list {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.58rem 0.7rem;
border-radius: var(--radius);
background: #fff;
border: 1px solid #eef2f7;
color: #334155;
font-size: 0.76rem;
text-align: start;
font: inherit;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.ai-chip:hover:not(:disabled) {
background: #f0fdfa;
border-color: #99f6e4;
}
.ai-chip:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-ico {
width: 26px;
height: 26px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
.home-ai__head {
justify-content: space-between;
gap: 0.75rem;
flex-shrink: 0;
}
.ai-input {
display: flex;
gap: 0.4rem;
margin-top: auto;
.home-ai__identity {
gap: 0.7rem;
min-width: 0;
}
.ai-input input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
}
.ai-input button {
width: 40px;
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
.home-ai__avatar {
flex-shrink: 0;
width: 2.75rem;
height: 2.75rem;
display: grid;
place-items: center;
border-radius: 0.9rem;
background:
radial-gradient(circle at 35% 25%, rgba(255, 255, 255, 0.9), transparent 55%),
linear-gradient(145deg, #ecfdf5 0%, #d1fae5 100%);
border: 1px solid rgb(167 243 208 / 0.8);
box-shadow: 0 4px 12px rgb(16 185 129 / 0.12);
overflow: hidden;
}
.ai-input button:disabled {
opacity: 0.55;
.home-ai__avatar :deep(.mento-bot) {
width: 42px;
}
.ai-answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
.home-ai__avatar :deep(.bot-img) {
filter: drop-shadow(0 4px 10px rgba(34, 211, 238, 0.28));
}
.ai-answer p {
.home-ai__titles {
min-width: 0;
}
.home-ai__titles h3 {
margin: 0;
color: #134e4a;
font-size: 0.82rem;
line-height: 1.7;
font-size: 0.95rem;
font-weight: 800;
color: #0f172a;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.sources a {
.home-ai__titles p {
margin: 0.1rem 0 0;
font-size: 0.72rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.15rem 0.5rem;
color: #64748b;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-error {
margin: 0 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
.home-ai__tip {
position: relative;
z-index: 1;
flex: 1 1 auto;
min-height: 0;
padding: 0.75rem 0.85rem;
border-radius: 0.85rem;
border: 1px solid rgb(167 243 208 / 0.55);
background: rgb(236 253 245 / 0.55);
color: #475569;
font-size: 0.78rem;
line-height: 1.65;
overflow: auto;
}
.home-ai__tip strong {
color: #0f172a;
font-weight: 700;
}
.home-ai__composer {
flex-shrink: 0;
gap: 0.45rem;
padding: 0.3rem 0.35rem 0.3rem 0.55rem;
border-radius: 0.85rem;
border: 1px solid #e2e8f0;
background: rgb(248 250 252 / 0.92);
}
.home-ai__input {
flex: 1 1 auto;
min-width: 0;
background: transparent !important;
}
.home-ai__chips {
flex-shrink: 0;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.45rem 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid #f1f5f9;
}
.home-ai__chips-label {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
}
.home-ai__chip-list {
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
}
.home-ai__chip {
appearance: none;
margin: 0;
border: 1px solid rgb(167 243 208 / 0.7);
background: rgb(236 253 245 / 0.75);
color: #047857;
border-radius: 0.45rem;
padding: 0.2rem 0.5rem;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.home-ai__chip:hover:not(:disabled) {
background: #d1fae5;
border-color: #6ee7b7;
}
.home-ai__chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.home-ai__status {
position: absolute;
z-index: 2;
inset-inline: 0.85rem;
bottom: 0.7rem;
max-height: 4.5rem;
overflow: auto;
}
.home-ai__status :deep(.ant-alert) {
padding-block: 0.35rem;
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { Component } from 'vue';
import {
AlertOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
LeftOutlined,
} from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardAlertDto } from '@mento/shared';
const props = defineProps<{
alerts: HomeDashboardAlertDto[];
}>();
type Tone = HomeDashboardAlertDto['tone'];
type AlertRow = {
key: string;
tone: Tone;
title: string;
subtitle?: string;
link: string;
};
const TONE_ORDER: Record<Tone, number> = {
urgent: 0,
warning: 1,
info: 2,
success: 3,
};
const rows = computed<AlertRow[]>(() =>
[...(props.alerts ?? [])]
.map((a) => {
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
const title = parts[0]?.trim() || a.text;
const subtitle = parts.slice(1).join(' ').trim() || undefined;
return {
key: a.key,
tone: a.tone,
title,
subtitle,
link: a.link || '/',
};
})
.sort((a, b) => TONE_ORDER[a.tone] - TONE_ORDER[b.tone]),
);
const urgentCount = computed(() => rows.value.filter((r) => r.tone === 'urgent').length);
const warningCount = computed(() => rows.value.filter((r) => r.tone === 'warning').length);
function toneIcon(tone: Tone): Component {
if (tone === 'urgent') return ExclamationCircleOutlined;
if (tone === 'warning') return AlertOutlined;
if (tone === 'success') return CheckCircleOutlined;
return InfoCircleOutlined;
}
function toneLabel(tone: Tone) {
if (tone === 'urgent') return 'فوری';
if (tone === 'warning') return 'هشدار';
if (tone === 'success') return 'موفق';
return 'اطلاع';
}
function actionLabel(tone: Tone) {
if (tone === 'urgent') return 'اقدام';
if (tone === 'warning') return 'بررسی';
return 'مشاهده';
}
function rowClass(tone: Tone) {
if (tone === 'urgent') return 'border-rose-200/80 bg-rose-50/50 hover:bg-rose-50';
if (tone === 'warning') return 'border-amber-200/80 bg-amber-50/50 hover:bg-amber-50';
if (tone === 'success') return 'border-emerald-200/70 bg-emerald-50/40 hover:bg-emerald-50/80';
return 'border-slate-200/80 bg-slate-50/70 hover:bg-slate-100/80';
}
function barClass(tone: Tone) {
if (tone === 'urgent') return 'bg-rose-500';
if (tone === 'warning') return 'bg-amber-500';
if (tone === 'success') return 'bg-emerald-500';
return 'bg-blue-500';
}
function iconWrapClass(tone: Tone) {
if (tone === 'urgent') return 'bg-rose-100 text-rose-600';
if (tone === 'warning') return 'bg-amber-100 text-amber-600';
if (tone === 'success') return 'bg-emerald-100 text-emerald-600';
return 'bg-blue-100 text-blue-600';
}
function badgeClass(tone: Tone) {
if (tone === 'urgent') return 'bg-rose-100 text-rose-700';
if (tone === 'warning') return 'bg-amber-100 text-amber-700';
if (tone === 'success') return 'bg-emerald-100 text-emerald-700';
return 'bg-blue-100 text-blue-700';
}
function actionClass(tone: Tone) {
if (tone === 'urgent') return 'text-rose-600 group-hover:text-rose-700';
if (tone === 'warning') return 'text-amber-600 group-hover:text-amber-700';
if (tone === 'success') return 'text-emerald-600 group-hover:text-emerald-700';
return 'text-blue-600 group-hover:text-blue-700';
}
</script>
<template>
<section
v-if="rows.length"
class="bg-white rounded-2xl border border-slate-200/80 p-5 shadow-sm flex flex-col h-[330px]"
>
<header class="flex items-center justify-between gap-3 pb-3 mb-3 border-b border-slate-100 shrink-0">
<div class="flex items-center gap-2.5 min-w-0">
<span class="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75" />
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-rose-500" />
</span>
<div class="min-w-0">
<h3 class="m-0 font-bold text-slate-800 text-base leading-6">نیازمند توجه</h3>
<p class="m-0 mt-0.5 text-xs text-slate-400 truncate">
موارد با اولویت بالاتر اول آمده‌اند
</p>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<span
v-if="urgentCount"
class="text-[11px] font-bold bg-rose-50 text-rose-600 border border-rose-200/70 px-2 py-0.5 rounded-full"
>
{{ toFaDigits(urgentCount) }} فوری
</span>
<span
v-if="warningCount"
class="text-[11px] font-bold bg-amber-50 text-amber-700 border border-amber-200/70 px-2 py-0.5 rounded-full"
>
{{ toFaDigits(warningCount) }} هشدار
</span>
<span class="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2 py-0.5 rounded-full">
{{ toFaDigits(rows.length) }} مورد
</span>
</div>
</header>
<div class="flex-1 overflow-y-auto space-y-2 pe-1 min-h-0">
<RouterLink
v-for="item in rows"
:key="item.key"
:to="item.link"
class="group relative overflow-hidden rounded-xl border px-3 py-2.5 flex items-center gap-3 transition-all"
:class="rowClass(item.tone)"
>
<span
class="absolute inset-y-0 right-0 w-1 rounded-s-full"
:class="barClass(item.tone)"
aria-hidden="true"
/>
<span
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-sm"
:class="iconWrapClass(item.tone)"
aria-hidden="true"
>
<component :is="toneIcon(item.tone)" />
</span>
<div class="min-w-0 flex-1">
<h4 class="m-0 font-bold text-[13px] text-slate-800 leading-5 truncate">
{{ item.title }}
</h4>
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0"
:class="badgeClass(item.tone)"
>
{{ toneLabel(item.tone) }}
</span>
<span v-if="item.subtitle" class="text-[11px] text-slate-500 truncate">
{{ item.subtitle }}
</span>
</div>
</div>
<div
class="shrink-0 flex items-center gap-1 text-[11px] font-semibold"
:class="actionClass(item.tone)"
>
<span class="opacity-0 group-hover:opacity-100 transition-opacity hidden sm:inline">
{{ actionLabel(item.tone) }}
</span>
<LeftOutlined class="text-slate-400 group-hover:-translate-x-0.5 transition-transform" />
</div>
</RouterLink>
</div>
</section>
</template>
@@ -1,10 +1,18 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { GiftOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Flex as AFlex,
List as AList,
Modal as AModal,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
type BirthdayItem = {
@@ -28,6 +36,10 @@ type BirthdayMonth = {
const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null);
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>();
@@ -54,63 +66,78 @@ onMounted(async () => {
</script>
<template>
<section v-if="data" class="birthdays">
<div class="head">
<span class="ico"><UiIcon name="gift" :size="18" /></span>
<section v-if="data" class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm">
<a-flex align="center" wrap="wrap" gap="middle" justify="space-between">
<a-flex align="center" gap="middle">
<a-typography-text type="secondary"><GiftOutlined /></a-typography-text>
<div>
<strong>تولدهای {{ data.monthName }}</strong>
<p>{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند</p>
</div>
<a-typography-text strong>تولدهای {{ data.monthName }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند
</a-typography-text>
</div>
</a-flex>
<div class="chips">
<a-space wrap>
<RouterLink
v-for="person in data.preview"
:key="person.id"
class="chip"
:title="person.fullName"
:to="{ name: 'person-detail', params: { id: person.id } }"
>
<b>{{ toFaDigits(person.day) }}</b>
<span>{{ person.fullName }}</span>
<a-tag>
{{ toFaDigits(person.day) }} — {{ person.fullName }}
</a-tag>
</RouterLink>
</div>
<button type="button" class="more" @click="listOpen = true">
<a-button type="link" @click="listOpen = true">
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
</button>
</a-button>
</a-space>
</a-flex>
<UiModal :open="listOpen" :title="`تولدهای ${data.monthName}`" @close="listOpen = false">
<div class="days">
<section
v-for="[day, people] in groups"
:key="day"
class="day"
:class="{ past: day < data.today, today: day === data.today }"
<a-modal
v-model:open="listOpen"
:title="`تولدهای ${data.monthName}`"
:footer="null"
destroy-on-close
>
<a-list item-layout="vertical" :data-source="groups">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag v-if="item[0] < data!.today">گذشته</a-tag>
<a-tag v-else-if="item[0] === data!.today" color="processing">امروز</a-tag>
</template>
<a-list-item-meta>
<template #title>
{{ toFaDigits(item[0]) }} {{ data!.monthName }}
</template>
</a-list-item-meta>
<a-list :data-source="item[1]">
<template #renderItem="{ item: person }">
<a-list-item>
<RouterLink
:to="{ name: 'person-detail', params: { id: person.id } }"
@click="listOpen = false"
>
<h3>
<b>{{ toFaDigits(day) }}</b>
<span>{{ data.monthName }}</span>
<em v-if="day < data.today">گذشته</em>
<em v-else-if="day === data.today">امروز</em>
</h3>
<ul>
<li v-for="person in people" :key="person.id">
<RouterLink :to="{ name: 'person-detail', params: { id: person.id } }" @click="listOpen = false">
{{ person.fullName }}
</RouterLink>
<button
type="button"
class="sms"
<template #actions>
<a-button
type="link"
:disabled="!person.mobile"
@click="openSms(person)"
>
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
</button>
</li>
</ul>
</section>
</div>
</UiModal>
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-list-item>
</template>
</a-list>
</a-modal>
<SmsComposeModal
nest
@@ -126,289 +153,3 @@ onMounted(async () => {
/>
</section>
</template>
<style scoped>
.birthdays {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
max-width: 100%;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.35), transparent 42%),
linear-gradient(100deg, var(--accent-subtle) 0%, #fff 58%, #fff7f4 100%);
border: 1px solid color-mix(in srgb, var(--accent) 22%, #fff);
border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(15, 118, 110, 0.05);
padding: 0.55rem 0.75rem;
}
.head {
display: flex;
align-items: center;
gap: 0.55rem;
flex-shrink: 0;
}
.ico {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
background: #fff;
color: #e11d48;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, #fff);
}
.head strong {
display: block;
color: var(--accent-deep);
font-size: 0.88rem;
font-weight: 700;
}
.head p {
margin: 0.05rem 0 0;
color: #64748b;
font-size: 0.75rem;
}
.chips {
display: flex;
flex: 1 1 auto;
flex-wrap: nowrap;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.35rem;
max-width: 11.5rem;
min-width: 0;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, #fff);
background: rgba(255, 255, 255, 0.86);
color: #134e4a;
padding: 0.22rem 0.55rem 0.22rem 0.28rem;
text-decoration: none;
font-size: 0.78rem;
}
.chip b {
flex-shrink: 0;
font-weight: 700;
font-size: 0.72rem;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 0.12rem 0.38rem;
}
.chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more {
flex-shrink: 0;
border: 0;
border-radius: 999px;
background: var(--accent);
color: #fff;
padding: 0.32rem 0.7rem;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.more:hover {
background: var(--accent-hover);
}
.days {
display: grid;
gap: 0.85rem;
max-height: 62vh;
overflow: auto;
padding-inline-end: 0.15rem;
}
.day {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 14px;
overflow: hidden;
}
.day h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.55rem 0.8rem;
background: var(--accent-subtle);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 16%, #fff);
font-size: 0.86rem;
font-weight: 600;
color: var(--accent-deep);
}
.day h3 b {
display: grid;
place-items: center;
min-width: 1.7rem;
height: 1.7rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.78rem;
}
.day em {
margin-inline-start: auto;
font-style: normal;
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
background: #fff;
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.day.today h3 {
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
}
.day.today em {
color: var(--accent-deep);
}
.day.past {
opacity: 0.82;
}
.day ul {
list-style: none;
margin: 0;
padding: 0;
}
.day li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.62rem 0.85rem;
border-top: 1px solid #eef2f6;
}
.day li:first-child {
border-top: 0;
}
.day a {
flex: 1 1 auto;
color: #0f172a;
text-decoration: none;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.day a:hover {
color: var(--accent-deep);
}
.sms {
flex-shrink: 0;
border: 1px solid color-mix(in srgb, var(--accent) 35%, #fff);
background: #fff;
color: var(--accent-deep);
border-radius: 999px;
padding: 0.28rem 0.65rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.sms:hover:not(:disabled) {
background: var(--accent-subtle);
}
.sms:disabled {
border-color: #e2e8f0;
color: #94a3b8;
cursor: default;
}
.lead {
margin: 0 0 0.6rem;
}
.hint,
.err,
.ok {
margin: 0.55rem 0 0;
font-size: 0.82rem;
}
.hint {
color: #94a3b8;
}
.err {
color: #dc2626;
}
.ok {
color: #16a34a;
}
@media (max-width: 720px) {
.birthdays {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.45rem 0.6rem;
}
.head {
min-width: 0;
}
.head p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more {
grid-column: 2;
grid-row: 1;
}
.chips {
grid-column: 1 / -1;
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
.day li {
gap: 0.45rem;
padding-inline: 0.7rem;
}
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSeriesPointDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
series: HomeDashboardSeriesPointDto[];
}>();
const points = computed(() => props.series);
const hasData = computed(() => points.value.some((p) => p.value > 0));
const total = computed(() => points.value.reduce((a, p) => a + p.value, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 450,
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
tooltip: {
trigger: 'axis',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params;
const point = points.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.monthLabel}<br/><strong>${toFaDigits(point.value)}</strong> مشتری جدید`;
},
},
xAxis: {
type: 'category',
data: points.value.map((p) => p.monthLabel),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#94a3b8',
fontFamily: 'IRANSans, Tahoma, sans-serif',
fontSize: 11,
},
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
...(hasData.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false },
},
series: [
{
type: 'bar',
data: points.value.map((p) => p.value),
barMaxWidth: 32,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#34d399' },
{ offset: 1, color: '#059669' },
],
},
borderRadius: [8, 8, 0, 0],
},
label: {
show: true,
position: 'top',
color: '#64748b',
fontFamily: 'IRANSans, Tahoma, sans-serif',
fontSize: 11,
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
},
},
{
type: 'line',
data: points.value.map((p) => p.value),
smooth: 0.35,
symbol: 'none',
lineStyle: { color: '#0f766e', width: 2, opacity: 0.55 },
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">روند رشد مشتریان</h3>
<span v-if="hasData" class="text-xs font-bold bg-emerald-50 text-emerald-700 px-2.5 py-1 rounded-lg">
{{ toFaDigits(total) }} در ۱۲ ماه
</span>
</div>
<div class="h-56">
<UiChart :option="option" height="224px" />
</div>
</section>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSalesSummaryDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
summary: HomeDashboardSalesSummaryDto;
formatAmount: (value: number) => string;
}>();
const slices = computed(() => [
{ name: 'باز', value: props.summary.openCount, color: '#3b82f6' },
{ name: 'برنده', value: props.summary.wonCount, color: '#10b981' },
{ name: 'باخته', value: props.summary.lostCount, color: '#f43f5e' },
]);
const total = computed(() => slices.value.reduce((a, s) => a + s.value, 0));
const hasData = computed(() => total.value > 0);
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: slices.value.map((s) => s.color),
tooltip: {
trigger: 'item',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (p) => {
const item = Array.isArray(p) ? p[0] : p;
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪`;
},
},
series: [
{
type: 'pie',
radius: ['58%', '82%'],
center: ['50%', '50%'],
silent: !hasData.value,
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
labelLine: { show: false },
data: hasData.value
? slices.value.map((s) => ({ name: s.name, value: s.value }))
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<div class="flex items-center justify-between mb-3">
<h3 class="!m-0 !leading-6 font-bold text-slate-800 text-base">وضعیت معاملات</h3>
<span class="text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-200/60 px-2.5 py-1 rounded-lg">
نرخ برد {{ toFaDigits(summary.winRate) }}٪
</span>
</div>
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-full sm:w-[48%] sm:max-w-[240px] shrink-0">
<div class="relative w-full aspect-square">
<UiChart :option="option" height="100%" />
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span class="text-2xl font-black text-slate-800 leading-none">{{ toFaDigits(total) }}</span>
<span class="text-xs text-slate-400 mt-1">معامله کل</span>
</div>
</div>
</div>
<div class="w-full sm:flex-1 min-w-0 space-y-2.5">
<div class="p-3 rounded-xl bg-slate-50/80 border border-slate-100 flex items-center justify-between gap-2">
<div>
<div class="text-xs font-bold text-slate-700">باز</div>
<div class="text-[11px] text-slate-400">در جریان</div>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800">{{ toFaDigits(summary.openCount) }}</div>
<div class="text-xs text-slate-500">{{ formatAmount(summary.openAmount) }}</div>
</div>
</div>
<div class="p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 flex items-center justify-between gap-2">
<div>
<div class="text-xs font-bold text-emerald-900">برنده (۱۲ ماه)</div>
<div class="text-[11px] text-emerald-600/80">تکمیل موفق</div>
</div>
<div class="text-left">
<div class="text-sm font-black text-emerald-700">{{ toFaDigits(summary.wonCount) }}</div>
<div class="text-xs text-emerald-600">{{ formatAmount(summary.wonAmount) }}</div>
</div>
</div>
<div class="p-3 rounded-xl bg-rose-50/40 border border-rose-100 flex items-center justify-between gap-2">
<div>
<div class="text-xs font-bold text-rose-900">باخته (۱۲ ماه)</div>
<div class="text-[11px] text-rose-500">لغو یا عدم توافق</div>
</div>
<div class="text-left">
<div class="text-sm font-black text-rose-600">{{ toFaDigits(summary.lostCount) }}</div>
<div class="text-xs text-rose-500">{{ formatAmount(summary.lostAmount) }}</div>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty, Progress as AProgress } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardFunnelStageDto } from '@mento/shared';
const props = defineProps<{
funnel: HomeDashboardFunnelStageDto[];
formatAmount: (value: number) => string;
}>();
const rows = computed(() => props.funnel);
const hasData = computed(() => rows.value.some((r) => r.count > 0));
const openTotal = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
function share(count: number) {
if (!openTotal.value) return 0;
return Math.round((count / openTotal.value) * 100);
}
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">قیف فروش باز</h3>
<span v-if="hasData" class="text-xs font-semibold text-slate-500">
{{ toFaDigits(openTotal) }} معامله باز
</span>
</div>
<a-empty v-if="!hasData" description="معاملهٔ بازی در مسیر فروش نیست" />
<div v-else class="space-y-4">
<div v-for="row in rows" :key="row.stageId ?? row.stageName">
<div class="flex items-center justify-between gap-2 mb-1">
<span class="text-xs font-bold text-slate-700">{{ row.stageName }}</span>
<span class="text-xs font-semibold text-slate-500">
{{ toFaDigits(row.count) }} · {{ toFaDigits(share(row.count)) }}٪
</span>
</div>
<a-progress :percent="share(row.count)" :show-info="false" stroke-color="#10b981" />
<div class="text-[11px] text-slate-400">{{ formatAmount(row.amount) }}</div>
</div>
</div>
</section>
</template>
+274 -348
View File
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
import { SendOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Input as AInput,
Typography,
} from 'ant-design-vue';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
greeting: string;
firstName: string;
userInitial: string;
alerts: HomeDashboardAlertDto[];
askQuery: string;
asking?: boolean;
askAnswer?: string;
@@ -24,444 +26,368 @@ const emit = defineEmits<{
}>();
const auth = useAuthStore();
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
type HeroChip = {
key: string;
tone: HomeDashboardAlertDto['tone'];
title: string;
subtitle?: string;
link: string;
};
const chips = computed<HeroChip[]>(() =>
props.alerts.slice(0, 3).map((a) => {
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
const title = parts[0]?.trim() || a.text;
const subtitle = parts.slice(1).join(' ').trim() || undefined;
return {
key: a.key,
tone: a.tone,
title,
subtitle,
link: a.link || '/',
};
}),
);
function alertIcon(tone: string) {
if (tone === 'urgent') return 'fire' as const;
if (tone === 'warning') return 'warning' as const;
if (tone === 'success') return 'trend' as const;
return 'sparkle' as const;
}
/** فقط عکس واقعی کاربر؛ پیش‌فرض عمومی را نشان نده */
const hasCustomPhoto = computed(() => Boolean(auth.user?.hasAvatar));
const shortcutLabel = computed(() =>
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform)
? '⌘K'
: 'Ctrl+K',
);
const statusMessage = computed(() => {
if (props.asking) return props.statusText || 'در حال پاسخ…';
if (props.askError) return props.askError;
if (props.askAnswer) return props.askAnswer;
return '';
});
</script>
<template>
<header class="hero home-hero" style="position: relative">
<div
class="hero-surface"
aria-hidden="true"
style="position: absolute; inset: 0; overflow: hidden; pointer-events: none"
>
<div class="hero-glow" style="position: absolute" />
<div class="hero-mist" style="position: absolute; inset: 0" />
</div>
<div class="flex flex-col gap-3">
<div class="home-hero">
<div class="home-hero__gradient" aria-hidden="true" />
<div class="home-hero__pattern" aria-hidden="true" />
<div class="home-hero__orb home-hero__orb--start" aria-hidden="true" />
<div class="home-hero__orb home-hero__orb--end" aria-hidden="true" />
<div class="hero-body">
<div class="hero-greet">
<span class="hero-avatar">
<img :src="auth.avatarSrc" :alt="firstName" width="72" height="72" />
<i class="online" />
<div class="home-hero__row">
<div class="home-hero__greet">
<div class="home-hero__identity" :aria-label="firstName">
<img
v-if="hasCustomPhoto"
:src="auth.avatarSrc"
:alt="firstName"
class="home-hero__photo"
/>
<span v-else class="home-hero__monogram" aria-hidden="true">
{{ userInitial }}
</span>
<div class="greet-text">
<h1>{{ greeting }} {{ firstName }} 👋</h1>
<p v-if="chips.length">{{ brand.nameFa }} اینا رو برات پیدا کرده</p>
<p v-else>از {{ brand.nameFa }} بپرس یا کار امروز را شروع کن</p>
<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 v-if="chips.length" class="hero-chips">
<RouterLink
v-for="chip in chips"
:key="chip.key"
class="chip"
:class="`chip-${chip.tone}`"
:to="chip.link"
>
<span class="chip-ico"><UiIcon :name="alertIcon(chip.tone)" :size="13" /></span>
<span class="chip-text">
<strong>{{ chip.title }}</strong>
<small v-if="chip.subtitle">{{ chip.subtitle }}</small>
</span>
</RouterLink>
</div>
<div class="hero-ask-wrap">
<form class="hero-ask" @submit.prevent="emit('ask')">
<UiIcon name="sparkle" :size="18" class="ask-ico" />
<input
<div class="home-hero__ask">
<a-input
id="home-hero-ask"
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
:bordered="false"
class="home-hero__ask-input"
@update:value="emit('update:askQuery', $event)"
@press-enter="emit('ask')"
/>
<kbd>{{ shortcutLabel }}</kbd>
<button type="submit" class="ask-send" aria-label="ارسال" :disabled="asking">
<UiIcon name="send" :size="15" />
</button>
</form>
<p v-if="asking" class="hero-ask-status">{{ statusText || 'در حال پاسخ…' }}</p>
<p v-else-if="askError" class="hero-ask-status err">{{ askError }}</p>
<p v-else-if="askAnswer" class="hero-ask-status">{{ askAnswer }}</p>
<kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</kbd>
<a-button
type="primary"
shape="round"
class="home-hero__ask-btn"
:disabled="asking"
:loading="asking"
aria-label="ارسال"
@click="emit('ask')"
>
<span class="home-hero__ask-btn-inner">
<SendOutlined />
<span class="home-hero__ask-label">بپرس</span>
</span>
</a-button>
</div>
</div>
</div>
<MentoAiAvatar class="hero-bot" :size="156" />
</header>
<div
v-if="statusMessage"
class="rounded-xl border px-3.5 py-2.5"
:class="
askError
? 'border-rose-200 bg-rose-50/70'
: asking
? 'border-emerald-200/70 bg-emerald-50/50'
: 'border-slate-200 bg-slate-50/80'
"
>
<a-typography-paragraph
:type="askError ? 'danger' : 'secondary'"
:ellipsis="{ rows: 3 }"
class="!mb-0 !text-sm"
>
{{ statusMessage }}
</a-typography-paragraph>
</div>
</div>
</template>
<style scoped>
.hero {
.home-hero {
position: relative;
display: grid;
/* RTL: body سمت راست، ربات سمت چپ — فضای اضافه برای پاپ‌آپ */
grid-template-columns: minmax(0, 1fr) 210px;
gap: 0.7rem 1rem;
align-items: start;
align-self: start;
height: auto;
min-height: 0;
padding: 0.8rem 1.5rem 0.9rem;
border-radius: var(--radius);
overflow: visible;
color: #fff;
background: transparent;
isolation: isolate;
}
.hero-surface {
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius);
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
background-color: #134e4a;
background-image:
radial-gradient(ellipse 55% 90% at 8% 55%, rgba(103, 232, 249, 0.55) 0%, rgba(45, 212, 191, 0.22) 35%, transparent 68%),
radial-gradient(ellipse 50% 80% at 92% 40%, rgba(153, 246, 228, 0.28) 0%, transparent 55%),
radial-gradient(ellipse 70% 60% at 45% 30%, rgba(45, 212, 191, 0.18) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 60% 85%, rgba(15, 23, 42, 0.28) 0%, transparent 55%),
linear-gradient(
105deg,
#0b3d3a 0%,
#115e59 22%,
#0f766e 48%,
#134e4a 72%,
#0f3f3c 100%
);
box-shadow:
0 16px 36px rgba(15, 118, 110, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
border-radius: 1rem;
border: 1px solid rgb(16 137 125 / 0.28);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
}
.hero-glow {
position: absolute;
width: 320px;
height: 320px;
border-radius: 999px;
inset-inline-end: -70px;
top: 40%;
transform: translateY(-50%);
background:
radial-gradient(circle, rgba(165, 243, 252, 0.65) 0%, rgba(34, 211, 238, 0.28) 32%, transparent 68%);
filter: blur(8px);
.home-hero__gradient,
.home-hero__pattern,
.home-hero__orb {
pointer-events: none;
position: absolute;
}
.hero-mist {
position: absolute;
.home-hero__gradient,
.home-hero__pattern {
inset: 0;
background:
radial-gradient(ellipse 40% 35% at 25% 20%, rgba(255, 255, 255, 0.14) 0%, transparent 70%),
radial-gradient(ellipse 35% 40% at 70% 75%, rgba(204, 251, 241, 0.12) 0%, transparent 70%),
radial-gradient(ellipse 50% 45% at 50% 50%, rgba(15, 118, 110, 0.35) 0%, transparent 75%);
pointer-events: none;
}
.hero-body,
.hero-bot {
.home-hero__gradient {
background:
linear-gradient(
115deg,
#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;
z-index: 1;
}
.hero-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1rem;
width: 100%;
max-width: 100%;
padding-top: 0;
min-width: 0;
box-sizing: border-box;
padding: 1rem 1.25rem;
}
.hero-greet {
.home-hero__greet {
display: flex;
align-items: center;
gap: 0.9rem;
gap: 0.875rem;
min-width: 0;
max-width: 100%;
max-width: 22rem;
}
.greet-text {
min-width: 0;
}
.hero-avatar {
.home-hero__identity {
position: relative;
width: 72px;
height: 72px;
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
width: 3rem;
height: 3rem;
}
.hero-avatar img {
.home-hero__photo,
.home-hero__monogram {
display: flex;
width: 100%;
height: 100%;
border-radius: 0.85rem;
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;
bottom: 3px;
inset-inline-start: 3px;
width: 14px;
height: 14px;
inset-inline-start: 0;
bottom: 0;
width: 0.7rem;
height: 0.7rem;
border-radius: 999px;
background: #22c55e;
background: #10897d;
border: 2px solid #fff;
z-index: 1;
}
.greet-text h1 {
.home-hero__copy {
min-width: 0;
}
.home-hero__brand {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
font-size: 11px;
font-weight: 600;
letter-spacing: -0.01em;
color: #fff;
letter-spacing: 0.02em;
color: rgb(4 120 87 / 0.8);
}
.greet-text p {
margin: 0.25rem 0 0;
color: rgba(255, 255, 255, 0.88);
font-size: 0.9rem;
font-weight: 500;
}
.hero-chips {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
max-width: min(580px, 100%);
min-width: 0;
}
.chip {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
padding: 0.4rem 0.5rem;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
text-decoration: none;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.07);
}
.chip-text {
display: grid;
gap: 0.04rem;
min-width: 0;
}
.chip-text strong {
font-size: 0.68rem;
font-weight: 700;
line-height: 1.3;
.home-hero__title {
margin: 0.125rem 0 0 !important;
color: #1e293b !important;
font-size: 1.125rem !important;
font-weight: 800 !important;
line-height: 1.75rem !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-text small {
font-size: 0.6rem;
color: #64748b;
font-weight: 500;
line-height: 1.25;
.home-hero__subtitle {
display: block !important;
margin-top: 0.125rem !important;
color: #64748b !important;
font-size: 0.875rem !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-ico {
width: 22px;
height: 22px;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
}
.chip-urgent .chip-ico {
background: #fee2e2;
color: #ef4444;
}
.chip-warning .chip-ico {
background: #fef3c7;
color: #d97706;
}
.chip-success .chip-ico,
.chip-info .chip-ico {
background: #dcfce7;
color: #16a34a;
}
.hero-ask-wrap {
display: grid;
gap: 0.45rem;
max-width: min(520px, 100%);
width: 100%;
min-width: 0;
}
.hero-ask {
.home-hero__ask {
display: flex;
align-items: center;
gap: 0.55rem;
gap: 0.5rem;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding-block: 0.375rem;
padding-inline: 0.75rem 0.5rem;
background: #fff;
color: #0f172a;
border-radius: 999px;
padding: 0.45rem 0.45rem 0.45rem 0.9rem;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
border: 1px solid rgb(255 255 255 / 0.8);
border-radius: 1rem;
box-shadow:
0 4px 12px rgb(6 78 59 / 0.05),
0 1px 2px rgb(15 23 42 / 0.04);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.ask-ico {
color: #14b8a6;
flex-shrink: 0;
.home-hero__ask:focus-within {
border-color: #16c1a8;
box-shadow:
0 0 0 2px rgb(16 137 125 / 0.18),
0 4px 12px rgb(10 92 84 / 0.06);
}
.hero-ask input {
border: 0;
background: transparent;
flex: 1;
outline: none;
font-size: 0.9rem;
.home-hero__ask-input {
flex: 1 1 auto;
min-width: 0;
background: transparent !important;
}
.hero-ask input::placeholder {
color: #94a3b8;
}
.hero-ask kbd {
font-size: 0.68rem;
color: #94a3b8;
background: #f1f5f9;
.home-hero__ask-kbd {
display: none;
flex-shrink: 0;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.14rem 0.38rem;
flex-shrink: 0;
background: #f8fafc;
color: #94a3b8;
font-size: 11px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.ask-send {
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
.home-hero__ask-btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.ask-send:disabled {
opacity: 0.55;
cursor: wait;
.home-hero__ask-btn-inner {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
line-height: 1;
}
.hero-ask-status {
margin: 0;
padding: 0.55rem 0.85rem;
border-radius: 14px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
font-size: 0.82rem;
line-height: 1.7;
max-height: 7.5rem;
overflow: auto;
}
.hero-ask-status.err {
background: rgba(185, 28, 28, 0.28);
}
.hero-bot {
position: relative;
z-index: 1;
/* در RTL ستون ربات سمت چپ است؛ end = لبه بیرونی چپ */
justify-self: end;
align-self: center;
margin-inline-start: 0;
margin-inline-end: 0.35rem;
overflow: visible;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: minmax(0, 1fr) 160px;
}
.hero-chips {
grid-template-columns: minmax(0, 1fr);
max-width: min(420px, 100%);
}
}
@media (max-width: 700px) {
.hero {
grid-template-columns: minmax(0, 1fr);
min-height: auto;
padding-inline: 0.85rem;
overflow: hidden;
}
.hero-bot {
.home-hero__ask-label {
display: none;
}
.hero-ask kbd {
display: none;
@media (min-width: 640px) {
.home-hero__ask-kbd {
display: inline-flex;
}
.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);
}
.home-hero__greet {
max-width: none;
}
}
</style>
+78 -106
View File
@@ -1,125 +1,97 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
import {
CalendarOutlined,
CheckCircleOutlined,
FundOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@/icons/ant-bridge';
import type { Component } from 'vue';
import type { HomeDashboardKpiDto } from '@mento/shared';
import { currencySuffix } from '@/lib/money';
defineProps<{
const props = defineProps<{
kpis: HomeDashboardKpiDto[];
currency: string;
formatValue: (key: string, value: number) => string;
formatChange: (value: number | null) => string;
}>();
const icons: Record<string, 'calendar' | 'users' | 'briefcase' | 'check'> = {
sales: 'calendar',
customers: 'users',
'open-deals': 'briefcase',
followups: 'check',
function salesAmount(value: number) {
return new Intl.NumberFormat('fa-IR').format(value);
}
function salesUnit() {
return currencySuffix(props.currency);
}
const icons: Record<string, Component> = {
sales: FundOutlined,
customers: TeamOutlined,
'open-deals': CalendarOutlined,
followups: CheckCircleOutlined,
'win-rate': RiseOutlined,
overdue: WarningOutlined,
};
const iconTones: Record<string, string> = {
sales: '#14b8a6',
customers: '#38bdf8',
'open-deals': '#fb923c',
followups: '#a78bfa',
};
function pillClass(kpi: HomeDashboardKpiDto) {
if (kpi.key === 'overdue') return 'bg-rose-100/70 text-rose-600';
if (kpi.changePercent == null) return 'text-slate-400';
return kpi.changePercent < 0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600';
}
</script>
<template>
<div class="kpi-row">
<article v-for="kpi in kpis" :key="kpi.key" class="kpi-card">
<div class="kpi-icon" :style="{ color: iconTones[kpi.key] ?? '#14b8a6' }">
<UiIcon :name="icons[kpi.key] ?? 'chart'" :size="20" />
</div>
<div class="kpi-text">
<p class="kpi-label">{{ kpi.label }}</p>
<strong class="kpi-value">{{ formatValue(kpi.key, kpi.value) }}</strong>
<span
v-if="kpi.changePercent != null"
class="kpi-change"
:class="{ down: kpi.changePercent < 0 }"
<section class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<div
v-for="kpi in kpis"
:key="kpi.key"
class="rounded-2xl p-4 border shadow-sm flex flex-col justify-between transition duration-200 hover:-translate-y-0.5"
:class="
kpi.key === 'overdue'
? 'border-rose-200/70 bg-gradient-to-b from-white to-rose-50/20'
: 'bg-white border-slate-200/80'
"
>
<span class="arrow">{{ kpi.changePercent >= 0 ? '↑' : '↓' }}</span>
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
<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
class="w-8 h-8 rounded-xl flex items-center justify-center"
:class="kpi.key === 'overdue' ? 'bg-rose-100/80 text-rose-600' : 'bg-emerald-50 text-emerald-600'"
>
<component :is="icons[kpi.key] ?? FundOutlined" />
</span>
</div>
</article>
<div>
<div
class="!font-bold !text-[24px] !leading-8 tracking-tight"
:class="kpi.key === 'overdue' ? 'text-rose-600' : 'text-slate-800'"
>
<template v-if="kpi.key === 'sales'">
<span>{{ salesAmount(kpi.value) }}</span><span class="text-[16px] leading-6 font-semibold ms-1.5">{{ salesUnit() }}</span>
</template>
<template v-else>{{ formatValue(kpi.key, kpi.value) }}</template>
</div>
<div
v-if="kpi.changePercent != null"
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md"
:class="pillClass(kpi)"
>
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
</div>
<div
v-else-if="kpi.key === 'overdue'"
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md bg-rose-100/70 text-rose-600"
>
نیازمند اقدام فوری
</div>
<div v-else class="mt-2.5 text-[11px] font-medium text-slate-400">
{{ kpi.changeLabel || '—' }}
</div>
</div>
</div>
</section>
</template>
<style scoped>
.kpi-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.9rem;
}
.kpi-card {
display: flex;
gap: 0.85rem;
align-items: flex-start;
background: #fff;
border-radius: var(--radius);
padding: 1.1rem 1.15rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
}
.kpi-icon {
width: 48px;
height: 48px;
border-radius: var(--radius);
background: #f8fafc;
display: grid;
place-items: center;
flex-shrink: 0;
}
.kpi-label {
margin: 0;
color: #94a3b8;
font-size: 0.84rem;
}
.kpi-value {
display: block;
margin-top: 0.25rem;
font-size: 1.18rem;
color: #0f172a;
letter-spacing: -0.02em;
}
.kpi-change {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-top: 0.45rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: #dcfce7;
color: #16a34a;
font-size: 0.72rem;
font-weight: 700;
}
.arrow {
font-size: 0.82rem;
line-height: 1;
}
.kpi-change.down {
background: #fee2e2;
color: #dc2626;
}
@media (max-width: 1200px) {
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.kpi-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSliceDto } from '@mento/shared';
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
reasons: HomeDashboardSliceDto[];
formatAmount: (value: number) => string;
}>();
const rows = computed(() => props.reasons);
const hasData = computed(() => rows.value.some((r) => r.count > 0));
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0));
function share(count: number) {
if (!total.value) return 0;
return Math.round((count / total.value) * 100);
}
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: CHART_PALETTE,
tooltip: {
trigger: 'item',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (p) => {
const item = Array.isArray(p) ? p[0] : p;
const row = rows.value.find((r) => r.label === item.name);
const amount =
row?.amount != null ? `<br/>${props.formatAmount(row.amount)}` : '';
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪${amount}`;
},
},
legend: { show: false },
series: [
{
type: 'pie',
radius: ['58%', '78%'],
center: ['50%', '50%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: rows.value.map((r) => ({ name: r.label, value: r.count })),
},
],
}));
</script>
<template>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<div class="flex items-center justify-between mb-2">
<h3 class="font-bold text-slate-800 text-base">دلایل باخت</h3>
<span v-if="hasData" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
</div>
<a-empty v-if="!hasData" description="معاملهٔ باخته‌ای ثبت نشده" />
<template v-else>
<div class="relative w-36 h-36 mx-auto">
<UiChart :option="option" height="144px" />
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
<span class="text-[11px] text-slate-400">معامله باخته</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mt-3">
<div
v-for="(item, index) in rows"
:key="item.label"
class="rounded-xl border border-slate-200/70 bg-slate-50 px-2.5 py-2"
>
<div class="flex items-center gap-1.5">
<span
class="w-2 h-2 rounded-full shrink-0"
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
></span>
<span class="text-[11px] text-slate-600 truncate">{{ item.label }}</span>
</div>
<div class="text-sm font-black text-slate-800 mt-1">{{ toFaDigits(share(item.count)) }}٪</div>
</div>
</div>
</template>
</section>
</template>
@@ -1,8 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import {
FundOutlined,
LeftOutlined,
PlusOutlined,
} from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
import type { PipelineBoardDto } from '@mento/shared';
import {
PIPELINE_ACCENT,
type PipelineAccent,
} from '@/lib/pipeline-board';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
const props = defineProps<{
@@ -10,41 +19,100 @@ const props = defineProps<{
formatMoney: (amount: number, currency: string) => string;
}>();
type Card = { id: string; name: string; amount: string; when: string };
type Col = { id: string; name: string; count: number; cards: Card[] };
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'blue', 'orange'];
function relativeWhen(iso: string | null | undefined) {
if (!iso) return '';
type Card = {
id: string;
name: string;
initial: string;
amount: string;
when: string;
isToday: boolean;
};
type Col = {
id: string;
name: string;
count: number;
totalLabel: string;
accent: PipelineAccent;
cards: Card[];
};
function relativeWhen(iso: string | null | undefined): { label: string; isToday: boolean } {
if (!iso) return { label: '', isToday: false };
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days <= 0) return 'امروز';
if (days === 1) return 'دیروز';
return `${toFaDigits(days)} روز پیش`;
if (days <= 0) return { label: 'امروز', isToday: true };
if (days === 1) return { label: 'دیروز', isToday: false };
return { label: `${toFaDigits(days)} روز پیش`, isToday: false };
}
function initialOf(name: string | undefined | null) {
const t = (name ?? '').trim();
return t ? t.slice(0, 1) : '؟';
}
const cols = computed<Col[]>(() => {
const stages = props.board?.stages ?? [];
const hasDeals = stages.some((s) => s.deals.length);
if (!hasDeals) return [];
return stages.slice(0, 3).map((stage) => ({
return stages.slice(0, 3).map((stage, index) => {
const accent = ACCENT_CYCLE[index % ACCENT_CYCLE.length]!;
const currency = stage.deals[0]?.currency ?? 'IRR';
return {
id: stage.id,
name: stage.name,
count: stage.deals.length,
cards: stage.deals.slice(0, 2).map((deal) => ({
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: deal.company?.name || deal.person?.fullName || deal.title,
name,
initial: initialOf(name),
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
when: relativeWhen(deal.updatedAt || deal.createdAt),
})),
}));
when: when.label,
isToday: when.isToday,
};
}),
};
});
});
function accentOf(key: PipelineAccent) {
return PIPELINE_ACCENT[key];
}
</script>
<template>
<article class="panel">
<header class="panel-head">
<h2>جریان فروش</h2>
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink>
<section class="bg-white rounded-2xl border border-slate-200/80 p-5 sm:p-6 shadow-sm">
<header class="flex items-center justify-between gap-3 mb-5">
<div class="flex items-center gap-2.5 min-w-0">
<div
class="w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
aria-hidden="true"
>
<FundOutlined />
</div>
<div class="min-w-0">
<h3 class="font-bold text-slate-800 text-base m-0">جریان فروش</h3>
<p class="text-xs text-slate-400 m-0 mt-0.5">
نمای سریع معاملات در مراحل کلیدی
</p>
</div>
</div>
<RouterLink
:to="{ name: 'pipeline' }"
class="shrink-0 inline-flex items-center gap-1 text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-3 py-1.5 rounded-xl transition-colors"
>
<span>نمایش همه</span>
<LeftOutlined />
</RouterLink>
</header>
<HomePanelEmpty
@@ -54,132 +122,85 @@ const cols = computed<Col[]>(() => {
action-label="معامله جدید"
:action-to="{ name: 'deal-new' }"
/>
<div v-else class="mini-pipeline">
<div v-for="col in cols" :key="col.id" class="pipe-col">
<header>
<strong>{{ col.name }} ({{ toFaDigits(col.count) }})</strong>
</header>
<div class="pipe-cards">
<RouterLink
v-for="card in col.cards"
:key="card.id"
class="pipe-card"
:to="{ name: 'deal-detail', params: { id: card.id } }"
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
<article
v-for="col in cols"
:key="col.id"
class="rounded-2xl border border-slate-200/70 bg-slate-50/90 p-4 flex flex-col gap-3 min-w-0"
>
<span class="deal-mark">{{ card.name.slice(0, 1) }}</span>
<div>
<strong>{{ card.name }}</strong>
<span v-if="card.amount">{{ card.amount }}</span>
<em>{{ card.when }}</em>
<div class="flex items-start justify-between gap-2 pb-2 border-b border-slate-200/80">
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2.5 h-2.5 rounded-full shrink-0"
:style="{ background: accentOf(col.accent).solid }"
aria-hidden="true"
/>
<h4 class="font-bold text-sm text-slate-700 m-0 truncate">{{ col.name }}</h4>
</div>
<p v-if="col.totalLabel" class="text-[11px] text-slate-400 m-0 mt-1 truncate">
مجموع {{ col.totalLabel }}
</p>
</div>
<span
class="shrink-0 bg-white border border-slate-200 text-slate-700 text-xs font-bold px-2.5 py-0.5 rounded-full"
>
{{ toFaDigits(col.count) }}
</span>
</div>
<div class="flex flex-col gap-2.5 flex-1">
<RouterLink
v-for="item in col.cards"
:key="item.id"
:to="{ name: 'deal-detail', params: { id: item.id } }"
class="group bg-white p-3 rounded-xl border border-slate-200/80 shadow-sm hover:border-emerald-400 hover:shadow transition-all flex items-center gap-3 min-w-0"
>
<span
class="w-9 h-9 rounded-full font-bold text-xs flex items-center justify-center shrink-0 border"
:style="{
background: accentOf(col.accent).soft,
color: accentOf(col.accent).text,
borderColor: accentOf(col.accent).border,
}"
>
{{ item.initial }}
</span>
<div class="min-w-0 flex-1">
<div class="font-bold text-sm text-slate-800 truncate">{{ item.name }}</div>
<div class="text-xs text-slate-500 mt-0.5 truncate">
<template v-if="item.amount">
<span class="font-medium">{{ item.amount }}</span>
<span v-if="item.when" class="text-slate-300 mx-1">·</span>
</template>
<span
v-if="item.when"
:class="item.isToday ? 'text-emerald-600 font-semibold' : 'text-slate-400'"
>
{{ item.when }}
</span>
</div>
</div>
</RouterLink>
<div
v-if="!col.cards.length"
class="border border-dashed border-slate-200 rounded-xl px-3 py-5 text-center text-xs text-slate-400"
>
معامله‌ای در این مرحله نیست
</div>
</div>
<RouterLink
v-else-if="col.cards.length < 2"
:to="{ name: 'deal-new', query: { stageId: col.id } }"
class="mt-auto border border-dashed border-slate-200 hover:border-emerald-300 hover:bg-white rounded-xl px-3 py-3 text-center text-xs text-slate-400 hover:text-emerald-700 transition-colors inline-flex items-center justify-center gap-1"
>
<PlusOutlined />
افزودن معامله
</RouterLink>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
}
.mini-pipeline {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 0.65rem;
overflow-x: auto;
}
.pipe-col > header {
margin-bottom: 0.5rem;
font-size: 0.78rem;
color: #64748b;
}
.pipe-cards {
display: grid;
gap: 0.4rem;
}
.pipe-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: var(--radius);
background: #f8fafc;
border: 1px solid #eef2f7;
color: inherit;
font-size: 0.78rem;
}
.pipe-card div {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pipe-card strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
}
.pipe-card span {
color: #94a3b8;
font-size: 0.68rem;
}
.pipe-card em {
color: #cbd5e1;
font-size: 0.66rem;
font-style: normal;
}
.deal-mark {
width: 28px;
height: 28px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 600;
flex-shrink: 0;
}
@media (max-width: 700px) {
.mini-pipeline {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import { Button as AButton, Empty as AEmpty, Typography } from 'ant-design-vue';
defineProps<{
title: string;
@@ -8,70 +9,19 @@ defineProps<{
actionLabel: string;
actionTo: RouteLocationRaw;
}>();
const { Text: ATypographyText } = Typography;
</script>
<template>
<div class="empty-card">
<div class="art" aria-hidden="true">
<svg viewBox="0 0 168 148" fill="none">
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<strong>{{ title }}</strong>
<p>{{ description }}</p>
<RouterLink class="cta" :to="actionTo">{{ actionLabel }}</RouterLink>
</div>
<a-empty :description="null">
<template #description>
<a-typography-text strong>{{ title }}</a-typography-text>
<br />
<a-typography-text type="secondary">{{ description }}</a-typography-text>
</template>
<RouterLink :to="actionTo">
<a-button type="primary">{{ actionLabel }}</a-button>
</RouterLink>
</a-empty>
</template>
<style scoped>
.empty-card {
flex: 1;
min-height: 0;
display: grid;
justify-items: center;
align-content: center;
text-align: center;
gap: 0.45rem;
padding: 0.4rem 0.2rem 0.15rem;
}
.art svg {
width: 92px;
height: auto;
display: block;
}
strong {
font-size: 0.88rem;
color: #0f172a;
}
p {
margin: 0;
max-width: 16rem;
color: #94a3b8;
font-size: 0.75rem;
line-height: 1.7;
}
.cta {
margin-top: 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 0.9rem;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
}
</style>
@@ -1,7 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, type Component } from 'vue';
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import {
CalendarOutlined,
CheckOutlined,
MailOutlined,
PhoneOutlined,
} from '@/icons/ant-bridge';
import {
Flex as AFlex,
Tag as ATag,
Timeline as ATimeline,
Typography,
theme,
} from 'ant-design-vue';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
import type { ActivityDto } from '@mento/shared';
@@ -10,16 +22,21 @@ const props = defineProps<{
formatWhen: (value: string | null) => string;
}>();
const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
const ATimelineItem = ATimeline.Item;
type Row = {
id: string;
title: string;
when: string;
typeLabel: string;
type: string;
tone: 'done' | 'pending' | 'held' | 'late';
label: string;
};
const typeLabel: Record<string, string> = {
const typeLabelMap: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
@@ -31,23 +48,50 @@ function activityStatus(act: ActivityDto) {
if (act.dueAt && new Date(act.dueAt) < new Date()) {
return { label: 'عقب‌افتاده', tone: 'late' as const };
}
return { label: 'در انتظار پاسخ', tone: 'pending' as const };
return { label: 'در انتظار', tone: 'pending' as const };
}
function activityIcon(type: string) {
if (type === 'call') return 'phone' as const;
if (type === 'meeting') return 'calendar' as const;
if (type === 'task') return 'check' as const;
return 'mail' as const;
function activityIcon(type: string): Component {
if (type === 'call') return PhoneOutlined;
if (type === 'meeting') return CalendarOutlined;
if (type === 'task') return CheckOutlined;
return MailOutlined;
}
function tagColor(tone: Row['tone']) {
if (tone === 'late') return 'error';
if (tone === 'done' || tone === 'held') return 'success';
return 'processing';
}
function timelineColor(tone: Row['tone']) {
if (tone === 'late') return 'red';
if (tone === 'done' || tone === 'held') return 'green';
return 'blue';
}
function typeBg(type: string) {
if (type === 'call') return token.value.colorInfoBg;
if (type === 'meeting') return token.value.colorWarningBg;
if (type === 'task') return token.value.colorSuccessBg;
return token.value.colorFillAlter;
}
function typeFg(type: string) {
if (type === 'call') return token.value.colorInfo;
if (type === 'meeting') return token.value.colorWarning;
if (type === 'task') return token.value.colorSuccess;
return token.value.colorTextSecondary;
}
const rows = computed<Row[]>(() =>
props.activities.slice(0, 3).map((act) => {
props.activities.slice(0, 5).map((act) => {
const st = activityStatus(act);
return {
id: act.id,
title: act.title,
when: `${typeLabel[act.type] ?? act.type} · ${props.formatWhen(act.dueAt || act.createdAt)}`,
when: props.formatWhen(act.dueAt || act.createdAt),
typeLabel: typeLabelMap[act.type] ?? act.type,
type: act.type,
tone: st.tone,
label: st.label,
@@ -57,11 +101,16 @@ const rows = computed<Row[]>(() =>
</script>
<template>
<article class="panel">
<header class="panel-head">
<h2>فعالیت‌های اخیر</h2>
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink>
</header>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h3 class="font-bold text-slate-800 text-base">فعالیت‌های اخیر</h3>
<a-tag v-if="rows.length" :bordered="false">{{ rows.length }}</a-tag>
</div>
<RouterLink :to="{ name: 'activities' }" class="text-sm font-bold text-emerald-600">
مشاهده همه
</RouterLink>
</div>
<HomePanelEmpty
v-if="!rows.length"
@@ -70,117 +119,44 @@ const rows = computed<Row[]>(() =>
action-label="فعالیت جدید"
:action-to="{ name: 'activity-new' }"
/>
<ul v-else class="activity-list">
<li v-for="act in rows" :key="act.id">
<span class="act-icon">
<UiIcon :name="activityIcon(act.type)" :size="16" />
</span>
<div class="act-body">
<strong>{{ act.title }}</strong>
<span>{{ act.when }}</span>
</div>
<span class="status-pill" :class="act.tone">{{ act.label }}</span>
</li>
</ul>
</article>
<a-timeline v-else>
<a-timeline-item v-for="item in rows" :key="item.id" :color="timelineColor(item.tone)">
<a-flex
align="flex-start"
justify="space-between"
:gap="token.marginSM"
:style="{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: `${token.borderRadius}px`,
background: token.colorFillAlter,
minWidth: 0,
}"
>
<a-flex :gap="token.marginSM" :style="{ minWidth: 0, flex: 1 }">
<a-flex
align="center"
justify="center"
:style="{
width: '32px',
height: '32px',
borderRadius: `${token.borderRadius}px`,
background: typeBg(item.type),
color: typeFg(item.type),
flexShrink: 0,
}"
>
<component :is="activityIcon(item.type)" />
</a-flex>
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
<a-typography-text strong ellipsis>{{ item.title }}</a-typography-text>
<a-typography-text type="secondary" ellipsis>
{{ item.typeLabel }} · {{ item.when }}
</a-typography-text>
</a-flex>
</a-flex>
<a-tag :color="tagColor(item.tone)" style="flex-shrink: 0">{{ item.label }}</a-tag>
</a-flex>
</a-timeline-item>
</a-timeline>
</section>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
font-weight: 600;
}
.activity-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.activity-list li {
display: flex;
align-items: center;
gap: 0.65rem;
}
.act-icon {
width: 38px;
height: 38px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
flex-shrink: 0;
}
.act-body {
flex: 1;
min-width: 0;
display: grid;
gap: 0.12rem;
}
.act-body strong {
font-size: 0.84rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.act-body span {
color: #94a3b8;
font-size: 0.72rem;
}
.status-pill {
font-size: 0.68rem;
font-weight: 700;
padding: 0.22rem 0.5rem;
border-radius: 999px;
white-space: nowrap;
}
.status-pill.done,
.status-pill.held {
background: #dcfce7;
color: #166534;
}
.status-pill.pending {
background: #dbeafe;
color: #1d4ed8;
}
.status-pill.late {
background: #fee2e2;
color: #b91c1c;
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { Segmented as ASegmented } from 'ant-design-vue';
import type { HomeDashboardTrendPointDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
@@ -10,26 +11,57 @@ const props = defineProps<{
formatAmount: (value: number) => string;
}>();
const trend = computed(() => props.trend);
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
const change = computed(() => props.changePercent);
type PeriodMonths = 1 | 3 | 6 | 12;
const period = ref<PeriodMonths>(6);
const periodOptions = [
{ value: 1, label: 'این ماه' },
{ value: 3, label: '۳ ماه' },
{ value: 6, label: '۶ ماه' },
{ value: 12, label: '۱۲ ماه' },
];
const filteredTrend = computed(() => {
const all = props.trend;
if (!all.length) return [];
const n = period.value;
return all.slice(-Math.min(n, all.length));
});
const hasTrend = computed(() => filteredTrend.value.some((p) => p.amount > 0));
/** تغییر آخرین ماه نسبت به ماه قبلِ همان بازه */
const periodChange = computed(() => {
const rows = filteredTrend.value;
if (rows.length < 2) return props.changePercent;
const last = rows[rows.length - 1]!.amount;
const prev = rows[rows.length - 2]!.amount;
if (prev === 0) return last === 0 ? 0 : 100;
return Math.round(((last - prev) / Math.abs(prev)) * 1000) / 10;
});
const changeHint = computed(() => {
if (period.value === 1) return 'نسبت به ماه قبل';
return 'آخرین ماه نسبت به ماه قبل';
});
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
animationDuration: 450,
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
tooltip: {
trigger: 'axis',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params;
const point = trend.value[row.dataIndex ?? 0];
const point = filteredTrend.value[row.dataIndex ?? 0];
if (!point) return '';
return `${point.monthLabel}<br/>${props.formatAmount(point.amount)}`;
return `${point.monthLabel}<br/><strong>${props.formatAmount(point.amount)}</strong>`;
},
},
xAxis: {
type: 'category',
data: trend.value.map((p) => p.monthLabel),
data: filteredTrend.value.map((p) => p.monthLabel),
boundaryGap: false,
axisLine: { show: false },
axisTick: { show: false },
@@ -43,18 +75,19 @@ const option = computed<UiChartOption>(() => ({
type: 'value',
min: 0,
...(hasTrend.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#eef2f7' } },
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
axisLabel: { show: false },
},
series: [
{
type: 'line',
data: trend.value.map((p) => p.amount),
smooth: 0.35,
name: 'فروش',
data: filteredTrend.value.map((p) => p.amount),
smooth: 0.4,
symbol: 'circle',
symbolSize: 8,
itemStyle: { color: '#14b8a6', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#14b8a6', width: 3 },
symbolSize: period.value === 1 ? 10 : 8,
itemStyle: { color: '#10b981', borderColor: '#fff', borderWidth: 2 },
lineStyle: { color: '#10b981', width: 3 },
areaStyle: {
color: {
type: 'linear',
@@ -63,8 +96,8 @@ const option = computed<UiChartOption>(() => ({
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.28)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0)' },
{ offset: 0, color: 'rgba(16, 185, 129, 0.28)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' },
],
},
},
@@ -74,56 +107,22 @@ const option = computed<UiChartOption>(() => ({
</script>
<template>
<article class="panel">
<header class="panel-head">
<h2>روند فروش</h2>
<span v-if="hasTrend && change != null" class="mini-badge" :class="{ down: change < 0 }">
{{ formatChange(change) }} نسبت به ماه قبل
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 class="font-bold text-slate-800 text-base">روند فروش</h3>
<span
v-if="hasTrend && periodChange != null"
class="mt-1 inline-flex text-xs font-bold px-2 py-0.5 rounded-md"
:class="periodChange < 0 ? 'text-rose-600 bg-rose-50' : 'text-emerald-600 bg-emerald-50'"
>
{{ formatChange(periodChange) }} {{ changeHint }}
</span>
</header>
<UiChart :option="option" height="210px" />
</article>
</div>
<a-segmented v-model:value="period" :options="periodOptions" />
</div>
<div class="h-56">
<UiChart :option="option" height="224px" />
</div>
</section>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: var(--pad-modal);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid var(--line);
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
color: #0f172a;
}
.mini-badge {
font-size: 0.7rem;
font-weight: 700;
color: #16a34a;
background: #dcfce7;
border-radius: var(--radius);
padding: 0.18rem 0.5rem;
white-space: nowrap;
}
.mini-badge.down {
color: #dc2626;
background: #fee2e2;
}
</style>
@@ -1,21 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Empty as AEmpty } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardSourceDto } from '@mento/shared';
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
const props = defineProps<{
sources: HomeDashboardSourceDto[];
}>();
const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8'];
const rows = computed(() => props.sources);
const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: colors,
color: CHART_PALETTE,
tooltip: {
show: total.value > 0,
trigger: 'item',
@@ -28,147 +27,51 @@ const option = computed<UiChartOption>(() => ({
series: [
{
type: 'pie',
radius: ['58%', '82%'],
radius: ['55%', '78%'],
center: ['50%', '50%'],
silent: total.value === 0,
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
labelLine: { show: false },
data:
total.value > 0
? rows.value.map((s) => ({ name: s.label, value: s.count }))
: [{ name: '', value: 1, itemStyle: { color: '#e2e8f0' } }],
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
},
],
}));
</script>
<template>
<article class="panel">
<header class="panel-head">
<h2>منابع مشتریان</h2>
</header>
<div class="donut-wrap">
<div class="donut-chart-box">
<UiChart :option="option" height="132px" />
<div class="donut-center">
<strong>{{ toFaDigits(total) }}</strong>
<span>مشتری</span>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 text-base">منابع ورود مشتریان</h3>
<span v-if="total" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
</div>
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-full sm:w-[48%] sm:max-w-[220px] shrink-0">
<div class="relative w-full aspect-square">
<UiChart :option="option" height="100%" />
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
<span class="text-xs text-slate-400">مشتری</span>
</div>
</div>
<p v-if="!total" class="legend-empty">منبعی ثبت نشده</p>
<ul v-else class="donut-legend">
<li v-for="(row, i) in rows" :key="row.label">
<span class="dot" :style="{ background: colors[i % colors.length] }" />
<span class="lbl">{{ row.label }}</span>
<em>{{ toFaDigits(row.percent) }}٪</em>
</li>
</ul>
</div>
</article>
<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>
</section>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: var(--pad-modal);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid var(--line);
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-head {
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
color: #0f172a;
}
.donut-wrap {
display: grid;
grid-template-columns: 132px 1fr;
gap: 0.85rem;
align-items: center;
}
.donut-chart-box {
position: relative;
width: 132px;
height: 132px;
}
.donut-center {
position: absolute;
inset: 0;
display: grid;
place-content: center;
text-align: center;
gap: 0.05rem;
pointer-events: none;
}
.donut-center strong {
font-size: 1.15rem;
color: #0f172a;
}
.donut-center span {
font-size: 0.7rem;
color: #94a3b8;
}
.donut-legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.48rem;
font-size: 0.8rem;
}
.donut-legend li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.lbl {
flex: 1;
color: #475569;
}
.legend-empty {
margin: 0;
color: #94a3b8;
font-size: 0.8rem;
}
em {
font-style: normal;
color: #94a3b8;
font-weight: 700;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
}
@media (max-width: 500px) {
.donut-wrap {
grid-template-columns: 1fr;
justify-items: center;
}
}
</style>
@@ -1,6 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { TrophyOutlined } from '@/icons/ant-bridge';
import {
Avatar as AAvatar,
Flex as AFlex,
Progress as AProgress,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardTopCustomerDto } from '@mento/shared';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
@@ -9,25 +18,61 @@ const props = defineProps<{
customers: HomeDashboardTopCustomerDto[];
}>();
const rows = computed(() => props.customers.slice(0, 3));
const { token } = theme.useToken();
const { Text: ATypographyText } = Typography;
function trophy(rank: number) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return `#${toFaDigits(rank)}`;
const rows = computed(() => props.customers.slice(0, 5));
const maxDeals = computed(() => Math.max(...rows.value.map((c) => c.dealCount), 1));
function rankStyle(rank: number) {
if (rank === 1) {
return {
background: token.value.colorWarningBg,
color: token.value.colorWarning,
border: `1px solid ${token.value.colorWarningBorder}`,
};
}
if (rank === 2) {
return {
background: token.value.colorFillSecondary,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorder}`,
};
}
if (rank === 3) {
return {
background: token.value.colorPrimaryBg,
color: token.value.colorPrimary,
border: `1px solid ${token.value.colorPrimaryBorder}`,
};
}
return {
background: token.value.colorFillAlter,
color: token.value.colorTextSecondary,
border: `1px solid ${token.value.colorBorderSecondary}`,
};
}
function progressPercent(count: number) {
return Math.round((count / maxDeals.value) * 100);
}
</script>
<template>
<article class="panel">
<header class="panel-head">
<div>
<h2>مشتریان برتر</h2>
<p class="hint">بر اساس معاملات برندهٔ ۵ ماه اخیر</p>
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<TrophyOutlined class="text-amber-500" />
<h3 class="font-bold text-slate-800 text-base">مشتریان برتر</h3>
</div>
<RouterLink :to="{ name: 'contacts' }">مشاهده همه</RouterLink>
</header>
<RouterLink :to="{ name: 'contacts' }" class="text-sm font-bold text-emerald-600">
مشاهده همه
</RouterLink>
</div>
<a-typography-text type="secondary" style="display: block; margin-bottom: 12px">
بر اساس معاملات برندهٔ اخیر
</a-typography-text>
<HomePanelEmpty
v-if="!rows.length"
@@ -36,99 +81,51 @@ function trophy(rank: number) {
action-label="مشتری جدید"
:action-to="{ name: 'person-new' }"
/>
<ul v-else class="top-list">
<li v-for="row in rows" :key="row.id">
<span class="cust-avatar">{{ row.name.slice(0, 1) }}</span>
<div>
<strong>{{ row.name }}</strong>
<span>{{ toFaDigits(row.dealCount) }} معامله</span>
</div>
<span class="rank">{{ trophy(row.rank) }}</span>
</li>
</ul>
</article>
<a-flex v-else vertical :gap="token.marginSM">
<a-flex
v-for="item in rows"
:key="item.id"
align="center"
:gap="token.marginSM"
class="rounded-xl border"
:class="item.rank === 1 ? 'bg-amber-50/50 border-amber-200/80' : 'bg-slate-50 border-slate-200/70'"
:style="{ padding: `${token.paddingSM}px`, minWidth: 0 }"
>
<a-flex
align="center"
justify="center"
:style="{
width: '36px',
height: '36px',
borderRadius: '50%',
fontWeight: 700,
flexShrink: 0,
...rankStyle(item.rank),
}"
>
{{ toFaDigits(item.rank) }}
</a-flex>
<a-avatar :style="{ background: token.colorPrimary, flexShrink: 0 }">
{{ item.name.slice(0, 1) }}
</a-avatar>
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
<a-flex align="center" justify="space-between" :gap="token.marginXS">
<a-typography-text strong ellipsis>{{ item.name }}</a-typography-text>
<a-tag v-if="item.rank === 1" color="gold" style="flex-shrink: 0">رتبه یک</a-tag>
</a-flex>
<a-flex align="center" :gap="token.marginSM">
<a-progress
:percent="progressPercent(item.dealCount)"
:show-info="false"
:stroke-color="item.rank === 1 ? token.colorWarning : token.colorPrimary"
style="flex: 1; margin: 0; line-height: 1"
/>
<a-typography-text type="secondary" style="flex-shrink: 0; white-space: nowrap">
{{ toFaDigits(item.dealCount) }} معامله
</a-typography-text>
</a-flex>
</a-flex>
</a-flex>
</a-flex>
</section>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.85rem;
gap: 0.5rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
}
.hint {
margin: 0.15rem 0 0;
color: #94a3b8;
font-size: 0.7rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
font-weight: 600;
}
.top-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.top-list li {
display: flex;
align-items: center;
gap: 0.65rem;
}
.cust-avatar {
width: 38px;
height: 38px;
border-radius: var(--radius);
background: linear-gradient(145deg, #ccfbf1, #99f6e4);
color: #0f766e;
display: grid;
place-items: center;
font-weight: 600;
flex-shrink: 0;
}
.top-list div {
flex: 1;
min-width: 0;
display: grid;
gap: 0.1rem;
}
.top-list strong {
font-size: 0.84rem;
}
.top-list span {
color: #94a3b8;
font-size: 0.72rem;
}
.rank {
font-size: 1.15rem;
}
</style>
@@ -1,12 +1,30 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
import {
Alert as AAlert,
Card as ACard,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
ListItem as AListItem,
Segmented as ASegmented,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import { UserOutlined } from '@/icons/ant-bridge';
import UserAvatar from '@/components/UserAvatar.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification';
import crownImg from '@/assets/club/crown.png';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [
{ value: 'today', label: 'امروز' },
{ value: 'month', label: 'این ماه' },
@@ -40,316 +58,342 @@ onMounted(() => {
});
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
/** ترتیب نمایش از راست: سوم، اول، دوم */
const podium = computed(() => {
const [a, b, c] = top3.value;
return [c, a, b].filter((x): x is GamificationLeaderEntryDto => !!x);
});
const rest = computed(() => {
const items = board.value?.items ?? [];
return top3.value.length ? items.slice(top3.value.length) : items;
});
function chgClass(change: number | null) {
return change && change > 0 ? 'up' : change && change < 0 ? 'down' : '';
const allItems = computed(() => board.value?.items ?? []);
interface PodiumSlot {
rank: number;
entry: GamificationLeaderEntryDto | null;
stepHeight: number;
avatarSize: number;
label: string;
gradient: string;
numberColor: string;
ringColor: string;
borderColor: string;
}
/** چیدمان ۳ پله سکوی قهرمانی به ترتیب ورزشی از راست به چپ: دوم (نقره)، اول (طلا - مرکز)، سوم (برنز) */
const podiumSlots = computed<PodiumSlot[]>(() => {
const items = allItems.value;
if (!items.length) return [];
const first = items[0] ?? null;
const second = items[1] ?? null;
const third = items[2] ?? null;
return [
{
rank: 2,
entry: second,
stepHeight: 74,
avatarSize: 48,
label: 'نقره',
gradient: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 45%, #94a3b8 100%)',
numberColor: '#334155',
ringColor: '#94a3b8',
borderColor: '#cbd5e1',
},
{
rank: 1,
entry: first,
stepHeight: 110,
avatarSize: 58,
label: 'طلا',
gradient: 'linear-gradient(180deg, #fef08a 0%, #f59e0b 55%, #d97706 100%)',
numberColor: '#78350f',
ringColor: '#f59e0b',
borderColor: '#f59e0b',
},
{
rank: 3,
entry: third,
stepHeight: 52,
avatarSize: 44,
label: 'برنز',
gradient: 'linear-gradient(180deg, #e8b896 0%, #b87333 50%, #7c4a1e 100%)',
numberColor: '#5c3317',
ringColor: '#b87333',
borderColor: '#cd7f32',
},
];
});
const rest = computed(() => {
const items = allItems.value;
return items.length > 3 ? items.slice(3) : [];
});
const meOutside = computed(() => {
const b = board.value;
if (!b?.me) return null;
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
const inTop3 = allItems.value.slice(0, 3).some((i) => i.userId === b.me!.userId);
const inRest = rest.value.some((i) => i.userId === b.me!.userId);
return inTop3 || inRest ? null : b.me;
});
type Row = { key: string; sep: boolean; entry: GamificationLeaderEntryDto | null };
const rows = computed<Row[]>(() => {
const list: Row[] = rest.value.map((e) => ({ key: e.userId, sep: false, entry: e }));
if (meOutside.value) {
list.push({ key: '__sep', sep: true, entry: null });
list.push({ key: `__me-${meOutside.value.userId}`, sep: false, entry: meOutside.value });
}
return list;
});
function changeType(change: number | null): 'success' | 'danger' | undefined {
return change && change > 0 ? 'success' : change && change < 0 ? 'danger' : undefined;
}
</script>
<template>
<section class="board">
<div class="periods" role="tablist" aria-label="بازهٔ جدول امتیازات">
<button
v-for="p in PERIODS"
:key="p.value"
type="button"
role="tab"
:aria-selected="period === p.value"
:class="{ on: period === p.value }"
@click="period = p.value"
>
{{ p.label }}
</button>
</div>
<a-card title="جدول قهرمانان و رتبه‌بندی">
<a-flex vertical :gap="16">
<a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
<p v-if="error" class="err">{{ error }}</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<div v-if="podium.length" class="top3" :class="{ loading }">
<a-spin :spinning="loading">
<a-flex vertical :gap="16">
<!-- بخش سکوی قهرمانی ۳ پله با ارتفاع‌های نامساوی (Podium) -->
<div
v-for="e in podium"
:key="e.userId"
class="t-card"
:class="[`r${e.rank}`, { me: e.isMe }]"
v-if="podiumSlots.length"
:style="{
position: 'relative',
borderRadius: `${token.borderRadiusLG}px`,
background: 'radial-gradient(ellipse at top, rgba(16, 185, 129, 0.06), transparent 70%), linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%)',
border: `1px solid ${token.colorBorderSecondary}`,
padding: '24px 12px 0 12px',
overflow: 'hidden',
}"
>
<img v-if="e.rank === 1" :src="crownImg" alt="" class="t-crown" />
<span class="t-ava">
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="e.rank === 1 ? 50 : 46" />
<!-- ردیف ۳ پله قهرمانی هم‌تراز در کف سکو -->
<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' }" />
<!-- عکس / آواتار کاربر با حلقه متالیک متناسب با رتبه -->
<template v-if="slot.entry">
<span
:style="{
display: 'inline-flex',
padding: '2px',
borderRadius: '999px',
background: '#ffffff',
boxShadow: `0 0 0 3px ${slot.ringColor}, 0 4px 10px rgba(0, 0, 0, 0.1)`,
}"
>
<UserAvatar
:user-id="slot.entry.userId"
:has-avatar="slot.entry.hasAvatar"
:name="slot.entry.fullName"
:size="slot.avatarSize"
/>
</span>
<a-flex align="center" :gap="4" :style="{ maxWidth: '100%' }">
<a-typography-text
strong
:ellipsis="true"
:style="{
fontSize: slot.rank === 1 ? '13px' : '12px',
color: slot.entry.isMe ? token.colorPrimary : token.colorTextHeading,
maxWidth: '100%',
}"
>
{{ slot.entry.isMe ? 'شما' : slot.entry.fullName }}
</a-typography-text>
</a-flex>
<a-tag
v-if="slot.entry.isMe"
color="success"
:bordered="false"
:style="{ margin: 0, fontSize: '10px', lineHeight: '18px', padding: '0 6px' }"
>
شما
</a-tag>
<a-typography-text
:style="{
fontSize: '12px',
fontWeight: 'bold',
color: slot.rank === 1 ? '#d97706' : token.colorTextSecondary,
}"
>
{{ faNum(slot.entry.score) }} امتیاز
</a-typography-text>
</template>
<!-- حالت خالی اگر در تیم جایگاه پر نشده باشد -->
<template v-else>
<span
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: `${slot.avatarSize}px`,
height: `${slot.avatarSize}px`,
borderRadius: '999px',
background: 'rgba(0, 0, 0, 0.04)',
border: '2px dashed #cbd5e1',
color: '#94a3b8',
}"
>
<UserOutlined :style="{ fontSize: '20px' }" />
</span>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
خالی
</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: '11px' }">
—
</a-typography-text>
</template>
</a-flex>
<!-- پله سکوی قهرمانی با ارتفاع اختصاصی و جلوه ۳ بعدی -->
<div
:style="{
width: '100%',
height: `${slot.stepHeight}px`,
borderRadius: '12px 12px 0 0',
background: slot.gradient,
border: `1px solid ${slot.borderColor}`,
borderBottom: 'none',
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.06), inset 0 1px 2px rgba(255, 255, 255, 0.7)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}"
>
<span
:style="{
fontSize: slot.rank === 1 ? '28px' : '22px',
fontWeight: 900,
color: slot.numberColor,
lineHeight: 1,
textShadow: '0 1px 1px rgba(255, 255, 255, 0.8)',
}"
>
{{ toFaDigits(slot.rank) }}
</span>
<span
:style="{
fontSize: '11px',
fontWeight: 700,
color: slot.numberColor,
opacity: 0.85,
marginTop: '2px',
}"
>
{{ slot.label }}
</span>
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
<small>{{ faNum(e.score) }} امتیاز</small>
</div>
</div>
</a-flex>
<ol class="rows" :class="{ loading }">
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
<span class="rank">{{ toFaDigits(e.rank) }}</span>
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="28" />
<b class="who">{{ e.isMe ? 'شما' : e.fullName }}</b>
<span class="score">{{ faNum(e.score) }}</span>
<span class="chg" :class="chgClass(e.change)">
<template v-if="e.change">{{ e.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(e.change)) }}</template>
</span>
</li>
<template v-if="meOutside">
<li class="sep" aria-hidden="true">⋯</li>
<li class="me">
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="28" />
<b class="who">شما</b>
<span class="score">{{ faNum(meOutside.score) }}</span>
<span class="chg" :class="chgClass(meOutside.change)">
<template v-if="meOutside.change">
{{ meOutside.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(meOutside.change)) }}
<!-- خط پایه و زمین زیر پله‌های سکو -->
<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>
</span>
</li>
</a-typography-text>
</a-flex>
</a-list-item>
</template>
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
</ol>
</section>
</a-list>
<a-empty
v-if="board && !board.items.length"
description="هنوز کسی در این بازه امتیاز نگرفته است."
/>
</a-flex>
</a-spin>
</a-flex>
</a-card>
</template>
<style scoped>
.board {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.75rem 0.9rem 0.5rem;
box-shadow: var(--shadow-xs);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
min-width: 0;
}
.periods {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 3px;
border-radius: 12px;
background: #f8fafc;
}
.periods button {
border: 0;
background: none;
border-radius: 10px;
padding: 0.5rem 0.6rem;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: #64748b;
cursor: pointer;
white-space: nowrap;
}
.periods button.on {
background: #e0e7ff;
color: #1e1b4b;
}
.err {
margin: 0;
color: var(--danger);
font-size: var(--fs-sm);
}
.top3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
align-items: end;
padding-top: 0.8rem;
}
.t-card {
--bg: #e0e7ff;
--ring: #a5b4fc;
position: relative;
display: grid;
justify-items: center;
gap: 4px;
padding: 0 0.4rem 0.7rem;
border-radius: 14px;
background: var(--bg);
text-align: center;
min-width: 0;
margin-top: 1.6rem;
}
.t-card.r1 {
--bg: #fef3c7;
--ring: #f59e0b;
padding-bottom: 1rem;
margin-top: 0;
}
.t-card.r3 {
--bg: #ffedd5;
--ring: #fb923c;
}
.t-card.me {
box-shadow: 0 0 0 2px var(--accent);
}
.t-crown {
position: absolute;
top: -1.55rem;
width: 30px;
height: 30px;
object-fit: contain;
z-index: 1;
}
.t-ava {
margin-top: -1.4rem;
padding: 3px;
border-radius: 999px;
background: #fff;
box-shadow: 0 0 0 3px var(--ring);
display: grid;
}
.t-card.r1 .t-ava {
margin-top: -0.4rem;
}
.t-card strong {
margin-top: 0.3rem;
font-size: 0.92rem;
font-weight: 800;
color: #0f172a;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-card small {
font-size: 0.76rem;
color: #475569;
}
.t-card.r1 small {
color: #b45309;
font-weight: 700;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
.rows li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.7rem;
border-bottom: 1px solid #f1f5f9;
}
.rows li:last-child {
border-bottom: 0;
}
.rows li.me {
background: #fef9e7;
border-radius: 10px;
border-bottom-color: transparent;
}
.rows li.me .score,
.rows li.me .who {
font-weight: 800;
}
.rows li.sep {
justify-content: center;
color: var(--muted-light);
padding: 0;
border: 0;
}
.chg {
min-width: 1.8rem;
font-size: 0.74rem;
font-weight: 800;
flex: 0 0 auto;
}
.chg.up {
color: #16a34a;
}
.chg.down {
color: #ef4444;
}
.score {
min-width: 2.8rem;
font-size: 0.84rem;
font-weight: 600;
color: #0f172a;
flex: 0 0 auto;
}
.who {
flex: 1;
min-width: 0;
text-align: start;
font-size: 0.84rem;
font-weight: 600;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank {
width: 1.6rem;
text-align: center;
font-size: 0.82rem;
color: #64748b;
flex: 0 0 auto;
}
.loading {
opacity: 0.55;
}
.empty {
justify-content: center;
color: var(--muted);
font-size: var(--fs-sm);
border: 0 !important;
}
@media (max-width: 520px) {
.top3 {
gap: 6px;
}
.t-card strong {
font-size: 0.8rem;
}
}
</style>
@@ -0,0 +1,56 @@
import type { Component } from 'vue';
import {
AimOutlined,
BankOutlined,
CalendarOutlined,
CheckCircleOutlined,
ContainerOutlined,
CrownOutlined,
DollarOutlined,
FileTextOutlined,
FilterOutlined,
FireOutlined,
GiftOutlined,
MessageOutlined,
PhoneOutlined,
RiseOutlined,
SafetyCertificateOutlined,
ShoppingOutlined,
SlidersOutlined,
StarOutlined,
SyncOutlined,
TeamOutlined,
TrophyOutlined,
UserAddOutlined,
} from '@/icons/ant-bridge';
/** نگاشت نام آیکون‌های باشگاه (txIcon / پاداش / نشان) به آیکون‌های Ant Design */
const CLUB_ICONS: Record<string, Component> = {
trophy: TrophyOutlined,
'user-plus': UserAddOutlined,
funnel: FilterOutlined,
briefcase: ContainerOutlined,
phone: PhoneOutlined,
calendar: CalendarOutlined,
'calendar-sun': CalendarOutlined,
document: FileTextOutlined,
check: CheckCircleOutlined,
refresh: SyncOutlined,
dollar: DollarOutlined,
chat: MessageOutlined,
star: StarOutlined,
medal: SafetyCertificateOutlined,
gift: GiftOutlined,
sliders: SlidersOutlined,
building: BankOutlined,
trend: RiseOutlined,
crown: CrownOutlined,
bag: ShoppingOutlined,
target: AimOutlined,
fire: FireOutlined,
users: TeamOutlined,
};
export function clubIcon(name: string | null | undefined): Component {
return (name && CLUB_ICONS[name]) || StarOutlined;
}
+5 -5
View File
@@ -102,13 +102,13 @@ onUnmounted(() => {
<style scoped>
.hub {
width: min(28rem, calc(100vw - var(--hub-offset, 16rem) - 1.5rem));
max-height: calc(100dvh - 56px);
max-height: calc(100dvh - 16px - var(--control-h) - 28px);
overflow: hidden;
padding: 0.9rem 0.9rem 0.8rem;
display: grid;
align-content: start;
gap: 0.7rem;
border-radius: 28px;
border-radius: 16px;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
@@ -204,7 +204,7 @@ onUnmounted(() => {
gap: 0.28rem;
padding: 0.65rem 0.7rem 0.55rem;
border: 0;
border-radius: 16px;
border-radius: 12px;
background: #f7f8fa;
cursor: pointer;
text-align: start;
@@ -267,7 +267,7 @@ onUnmounted(() => {
min-height: 2.4rem;
padding: 0.45rem 0.7rem;
border: 0;
border-radius: 999px;
border-radius: 10px;
background: #ecfdf8;
color: #0f766e;
font: inherit;
@@ -316,7 +316,7 @@ onUnmounted(() => {
max-height: min(86dvh, calc(100dvh - 3.5rem));
overflow: auto;
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 24px 24px 0 0;
border-radius: 16px 16px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
}
@@ -1,9 +1,21 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import { useRouter } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Flex as AFlex,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import {
CalendarOutlined,
PlusOutlined,
UserAddOutlined,
} from '@/icons/ant-bridge';
withDefaults(
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
title?: string;
description?: string;
@@ -20,15 +32,24 @@ withDefaults(
secondaryIcon: 'calendar-plus',
},
);
const router = useRouter();
function go(to: RouteLocationRaw) {
void router.push(to);
}
const secondaryIconComp = () => {
if (props.secondaryIcon === 'user-plus') return UserAddOutlined;
return CalendarOutlined;
};
</script>
<template>
<div class="empty-state">
<div class="art" aria-hidden="true">
<span class="spark s1">✦</span>
<span class="spark s2">✦</span>
<span class="spark s3">✦</span>
<svg viewBox="0 0 168 132" fill="none">
<a-flex vertical align="center" :gap="16" :style="{ width: '100%', textAlign: 'center' }">
<div aria-hidden="true" :style="{ width: '168px' }">
<span aria-hidden="true">✦</span>
<svg viewBox="0 0 168 132" fill="none" :style="{ width: '168px', height: 'auto', display: 'block' }">
<path
d="M26 44c0-7.2 5.8-13 13-13h30l9 9h51c7.2 0 13 5.8 13 13v54c0 7.2-5.8 13-13 13H39c-7.2 0-13-5.8-13-13V44Z"
fill="#D9E6E8"
@@ -52,147 +73,24 @@ withDefaults(
</svg>
</div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<a-typography-title :level="4" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">{{ description }}</a-typography-text>
<div class="actions">
<RouterLink class="btn-primary" :to="primaryTo">
<span class="plus">+</span>
<a-space direction="vertical" style="width: min(100%, 304px)">
<a-button type="primary" block @click="go(primaryTo)">
<template #icon><PlusOutlined /></template>
{{ primaryLabel }}
</RouterLink>
<RouterLink v-if="secondaryLabel && secondaryTo" class="btn-secondary" :to="secondaryTo">
<UiIcon :name="secondaryIcon" :size="18" />
</a-button>
<a-button
v-if="secondaryLabel && secondaryTo"
block
@click="go(secondaryTo)"
>
<template #icon>
<component :is="secondaryIconComp()" />
</template>
{{ secondaryLabel }}
</RouterLink>
</div>
</div>
</a-button>
</a-space>
</a-flex>
</template>
<style scoped>
.empty-state {
display: grid;
justify-items: center;
align-content: center;
text-align: center;
width: 100%;
padding: 1.4rem 0.8rem 0.6rem;
}
.art {
position: relative;
width: 168px;
margin-bottom: 1.05rem;
}
.art svg {
width: 168px;
height: auto;
display: block;
}
.spark {
position: absolute;
color: #5eead4;
font-size: 0.68rem;
line-height: 1;
}
.s1 {
top: 6px;
inset-inline-end: 8px;
}
.s2 {
top: 44px;
inset-inline-start: 2px;
font-size: 0.52rem;
color: #99f6e4;
}
.s3 {
bottom: 20px;
inset-inline-end: -2px;
font-size: 0.46rem;
color: #99f6e4;
}
h3 {
margin: 0;
font-size: 1.08rem;
font-weight: 600;
color: #1e2937;
}
p {
margin: 0.5rem 0 1.25rem;
max-width: 21.5rem;
color: #94a3b8;
font-size: 0.84rem;
line-height: 1.85;
}
.actions {
display: grid;
gap: 0.7rem;
width: min(100%, 304px);
}
.btn-primary,
.btn-secondary {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
font-weight: 700;
font-size: 0.9rem;
}
.btn-primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.26);
}
.plus {
font-size: 1.2rem;
line-height: 1;
font-weight: 600;
}
.btn-secondary {
background: #fff;
color: #0f766e;
border: 1px solid #e2e8f0;
}
@media (max-width: 599px) {
.empty-state {
padding: 1.2rem 0.2rem 0.4rem;
}
.art,
.art svg {
width: 152px;
}
h3 {
font-size: 1.02rem;
}
p {
font-size: 0.8rem;
margin-bottom: 1.15rem;
}
.btn-primary,
.btn-secondary {
min-height: var(--control-h);
height: var(--control-h);
font-size: 0.86rem;
border-radius: var(--radius);
}
}
</style>
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Dropdown as ADropdown,
Menu as AMenu,
MenuItem as AMenuItem,
} from 'ant-design-vue';
import { MoreOutlined } from '@/icons/ant-bridge';
export type ProfileMoreItem = {
key: string;
@@ -15,207 +20,40 @@ const open = defineModel<boolean>({ default: false });
defineProps<{ items: ProfileMoreItem[] }>();
const emit = defineEmits<{ select: [key: string] }>();
const triggerRef = ref<HTMLButtonElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 220;
const height = panel?.offsetHeight || 0;
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
let nextLeft = rtl ? rect.right - width : rect.left;
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
watch(open, async (value) => {
if (!value) {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
return;
}
await nextTick();
place();
await nextTick();
place();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
function toggle() {
open.value = !open.value;
}
function close() {
function onSelect(key: string) {
emit('select', key);
open.value = false;
}
function scheduleClose() {
window.setTimeout(() => {
close();
}, 0);
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
close();
});
function onLinkClick() {
open.value = false;
}
</script>
<template>
<div class="wrap" @click.stop>
<button
ref="triggerRef"
class="icon-btn"
type="button"
aria-label="منوی بیشتر"
:aria-expanded="open"
@click.stop="toggle"
<a-dropdown v-model:open="open" :trigger="['click']" placement="bottomLeft">
<a-button aria-label="منوی بیشتر" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click.stop>
<a-menu-item
v-for="item in items"
:key="item.key"
:danger="item.danger"
@click="item.to ? onLinkClick() : onSelect(item.key)"
>
<UiIcon name="dots-v" :size="18" />
</button>
<Teleport to="body">
<div v-if="open" class="layer">
<button class="backdrop" type="button" aria-label="بستن منو" @click="close" />
<ul
ref="panelRef"
class="panel"
role="menu"
:style="{ top: `${top}px`, left: `${left}px` }"
@click.stop
>
<li v-for="item in items" :key="item.key" role="none">
<RouterLink
v-if="item.to"
class="item"
:class="{ danger: item.danger }"
role="menuitem"
:to="item.to"
@click.stop="scheduleClose"
:style="{ color: 'inherit', display: 'block' }"
@click="onLinkClick"
>
{{ item.label }}
</RouterLink>
<button
v-else
class="item"
:class="{ danger: item.danger }"
type="button"
role="menuitem"
@click.stop.prevent="onSelect(item.key)"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</Teleport>
</div>
<span v-else>{{ item.label }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<style scoped>
.wrap {
position: relative;
flex-shrink: 0;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: #fff;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #334155;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
cursor: pointer;
}
.layer {
position: fixed;
inset: 0;
z-index: 80;
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: default;
}
.panel {
position: absolute;
z-index: 1;
margin: 0;
padding: 0.4rem;
list-style: none;
width: 220px;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
gap: 2px;
}
.panel > li {
display: block;
width: 100%;
}
.item {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: var(--radius);
background: transparent;
color: #0f172a;
font: inherit;
font-weight: 600;
font-size: 0.9rem;
line-height: 1.6;
text-align: start;
text-decoration: none;
white-space: nowrap;
padding: 0.7rem 0.85rem;
cursor: pointer;
}
.item:hover {
background: #f0fdfa;
}
.item.danger {
color: #b91c1c;
}
.item.danger:hover {
background: #fef2f2;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue';
import { PlusOutlined } from '@/icons/ant-bridge';
import { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
@@ -6,7 +6,7 @@ import {
DeleteOutlined,
FlagFilled,
MoreOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
Avatar as AAvatar,
Button as AButton,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { PlusOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Card as ACard,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue';
import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
import {
Button as AButton,
Dropdown as ADropdown,
@@ -5,7 +5,7 @@ import {
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
Avatar as AAvatar,
Button as AButton,
@@ -9,8 +9,31 @@ import type {
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiField, UiSelect } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Steps as ASteps,
Step as AStep,
Typography,
} from 'ant-design-vue';
import {
AppstoreOutlined,
AimOutlined,
CheckCircleOutlined,
} from '@/icons/ant-bridge';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
type Step = 'target' | 'map' | 'review';
@@ -31,10 +54,23 @@ const stageMap = ref<Record<string, string>>({});
const busy = ref(false);
const error = ref('');
const stepIndex = computed(() => {
if (step.value === 'target') return 0;
if (step.value === 'map') return 1;
return 2;
});
const selectedTarget = computed(
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null,
);
const targetOptions = computed(() =>
(preview.value?.targets ?? []).map((target) => ({
value: target.id,
label: target.name,
})),
);
const canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
const canContinueMap = computed(() => {
const current = preview.value;
@@ -71,6 +107,13 @@ function compatibleTargets(src: PipelineMergePreviewStageDto) {
});
}
function compatibleOptions(src: PipelineMergePreviewStageDto) {
return compatibleTargets(src).map((stage) => ({
value: stage.id,
label: stage.name,
}));
}
function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
if (stage.isWon) return 'برد';
if (stage.isLost) return 'باخت';
@@ -171,298 +214,181 @@ watch(targetId, () => {
</script>
<template>
<div class="merge-wizard">
<ol class="steps" aria-label="مراحل ادغام کاریز">
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li>
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li>
<li :class="{ active: step === 'review' }">۳. بازبینی</li>
</ol>
<a-flex vertical :gap="16">
<a-steps :current="stepIndex">
<a-step title="مقصد" />
<a-step title="نگاشت" />
<a-step title="بازبینی" />
</a-steps>
<p v-if="busy && !preview" class="muted">در حال آماده‌سازی پیش‌نمایش…</p>
<template v-else-if="preview">
<section v-if="step === 'target'" class="step-panel">
<div class="intro">
<UiIcon name="layers" :size="20" />
<div>
<h3>کاریز مقصد را انتخاب کنید</h3>
<p>
<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 }}» به کاریز مقصد منتقل می‌شوند.
</p>
</div>
</div>
</a-typography-text>
</a-flex>
</a-flex>
<UiField label="کاریز مقصد" required>
<UiSelect v-model="targetId" required>
<option value="" disabled>انتخاب کنید</option>
<option v-for="target in preview.targets" :key="target.id" :value="target.id">
{{ target.name }}
</option>
</UiSelect>
</UiField>
<a-form layout="vertical">
<a-form-item label="کاریز مقصد" required>
<a-select
v-model:value="targetId"
:options="targetOptions"
placeholder="انتخاب کنید"
style="width: 100%"
/>
</a-form-item>
</a-form>
<div class="summary-grid">
<article>
<strong>{{ toFaDigits(preview.dealCount) }}</strong>
<span>معامله منتقل می‌شود</span>
</article>
<article>
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong>
<span>فرم سرنخ به مقصد وصل می‌شود</span>
</article>
<article v-if="preview.source.isDefault" class="warn">
<strong>هشدار</strong>
<span>کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود.</span>
</article>
</div>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :md="8">
<a-card :bordered="true">
<a-typography-text strong>{{ toFaDigits(preview.dealCount) }}</a-typography-text>
<br />
<a-typography-text type="secondary">معامله منتقل می‌شود</a-typography-text>
</a-card>
</a-col>
<a-col :xs="24" :md="8">
<a-card :bordered="true">
<a-typography-text strong>{{ toFaDigits(preview.leadForms.length) }}</a-typography-text>
<br />
<a-typography-text type="secondary">فرم سرنخ به مقصد وصل می‌شود</a-typography-text>
</a-card>
</a-col>
<a-col v-if="preview.source.isDefault" :xs="24" :md="8">
<a-alert
type="warning"
show-icon
message="هشدار"
description="کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود."
/>
</a-col>
</a-row>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p>
</section>
<a-alert
v-if="selectedTarget?.isDefault"
type="info"
show-icon
message="مقصد انتخاب‌شده کاریز پیش‌فرض است."
/>
</a-flex>
<section v-else-if="step === 'map'" class="step-panel">
<div class="intro">
<UiIcon name="target" :size="20" />
<div>
<h3>مرحله‌های مبدا را نگاشت کنید</h3>
<p>هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.</p>
</div>
</div>
<a-flex v-else-if="step === 'map'" vertical :gap="16">
<a-flex :gap="8" align="flex-start">
<AimOutlined />
<a-flex vertical :gap="4">
<a-typography-title :level="5" :style="{ margin: 0 }">
مرحله‌های مبدا را نگاشت کنید
</a-typography-title>
<a-typography-text type="secondary">
هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.
</a-typography-text>
</a-flex>
</a-flex>
<div class="map-list">
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row">
<div class="stage-info">
<strong>{{ sourceStage.name }}</strong>
<span>
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
</span>
</div>
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]">
<option value="" disabled>مرحله مقصد</option>
<option
v-for="targetStage in compatibleTargets(sourceStage)"
:key="targetStage.id"
:value="targetStage.id"
<a-form layout="vertical">
<a-row
v-for="sourceStage in preview.stages"
:key="sourceStage.id"
:gutter="[16, 0]"
align="middle"
>
{{ targetStage.name }}
</option>
</UiSelect>
</article>
</div>
</section>
<a-col :xs="24" :md="12">
<a-form-item :style="{ marginBottom: '12px' }">
<a-typography-text strong>{{ sourceStage.name }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
</a-typography-text>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="مرحله مقصد"
required
:validate-status="stageMap[sourceStage.id] ? undefined : 'error'"
:style="{ marginBottom: '12px' }"
>
<a-select
v-model:value="stageMap[sourceStage.id]"
:options="compatibleOptions(sourceStage)"
placeholder="مرحله مقصد"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-flex>
<section v-else class="step-panel">
<div class="intro">
<UiIcon name="check" :size="20" />
<div>
<h3>بازبینی نهایی</h3>
<p>
<a-flex v-else vertical :gap="16">
<a-flex :gap="8" align="flex-start">
<CheckCircleOutlined />
<a-flex vertical :gap="4">
<a-typography-title :level="5" :style="{ margin: 0 }">بازبینی نهایی</a-typography-title>
<a-typography-paragraph type="secondary" :style="{ margin: 0 }">
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
کاریز «{{ preview.source.name }}» حذف می‌شود.
</p>
</div>
</div>
</a-typography-paragraph>
</a-flex>
</a-flex>
<dl class="review-list">
<div>
<dt>مبدا</dt>
<dd>{{ preview.source.name }}</dd>
</div>
<div>
<dt>مقصد</dt>
<dd>{{ selectedTarget?.name }}</dd>
</div>
<div>
<dt>روش فرم‌ها</dt>
<dd>همه فرم‌های سرنخ به مقصد وصل می‌شوند.</dd>
</div>
</dl>
</section>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="مبدا">{{ preview.source.name }}</a-descriptions-item>
<a-descriptions-item label="مقصد">{{ selectedTarget?.name }}</a-descriptions-item>
<a-descriptions-item label="روش فرم‌ها">
همه فرم‌های سرنخ به مقصد وصل می‌شوند.
</a-descriptions-item>
</a-descriptions>
</a-flex>
</template>
<p v-else-if="!busy && !error" class="muted">
<a-typography-text v-else-if="!busy && !error" type="secondary">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</p>
</a-typography-text>
</a-spin>
<p v-if="error" class="error">{{ error }}</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<div class="actions">
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton>
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'">
قبلی
</UiButton>
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'">
قبلی
</UiButton>
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
<a-space wrap>
<a-button :disabled="busy" @click="emit('close')">بستن</a-button>
<a-button v-if="step === 'map'" :disabled="busy" @click="step = 'target'">قبلی</a-button>
<a-button v-else-if="step === 'review'" :disabled="busy" @click="step = 'map'">قبلی</a-button>
<a-button
v-if="step === 'target'"
type="primary"
:disabled="!preview || !canContinueTarget"
@click="nextFromTarget"
>
ادامه
</UiButton>
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap">
</a-button>
<a-button
v-else-if="step === 'map'"
type="primary"
:disabled="!canContinueMap"
@click="nextFromMap"
>
بازبینی
</UiButton>
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge">
</a-button>
<a-button
v-else
type="primary"
danger
:loading="busy"
:disabled="!canContinueMap"
@click="submitMerge"
>
ادغام و حذف مبدا
</UiButton>
</div>
</div>
</a-button>
</a-space>
</a-flex>
</template>
<style scoped>
.merge-wizard {
display: grid;
gap: 1rem;
}
.steps {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0;
margin: 0;
list-style: none;
}
.steps li {
padding: 0.3rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 0.85rem;
}
.steps li.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.steps li.done {
color: var(--ink);
}
.step-panel {
display: grid;
gap: 1rem;
}
.intro {
display: flex;
gap: 0.65rem;
align-items: flex-start;
}
.intro h3,
.intro p {
margin: 0;
}
.intro h3 {
font-size: 1rem;
}
.intro p {
margin-top: 0.25rem;
color: var(--muted);
line-height: 1.8;
}
.summary-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.summary-grid article {
display: grid;
gap: 0.2rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.summary-grid strong {
color: var(--ink);
}
.summary-grid span,
.muted,
.note {
color: var(--muted);
font-size: 0.9rem;
}
.summary-grid .warn {
border-color: var(--danger);
background: var(--danger-soft);
}
.map-list {
display: grid;
gap: 0.65rem;
}
.map-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 18rem);
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.stage-info {
display: grid;
gap: 0.25rem;
}
.stage-info span {
color: var(--muted);
font-size: 0.85rem;
}
.review-list {
display: grid;
gap: 0.65rem;
margin: 0;
}
.review-list div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--line);
}
.review-list dt,
.review-list dd {
margin: 0;
}
.review-list dt {
color: var(--muted);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-start;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
.error {
color: #b91c1c;
margin: 0;
}
@media (max-width: 720px) {
.map-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import {
AppstoreOutlined,
FundOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
import {
Flex as AFlex,
InputNumber as AInputNumber,
@@ -11,6 +11,20 @@ import {
weekdayIndex,
} from '@/lib/routines';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import {
Card as ACard,
Col as ACol,
Flex as AFlex,
Progress as AProgress,
Row as ARow,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = defineProps<{
score: RoutineScoreDto | null;
@@ -29,216 +43,98 @@ const rows = computed(() => {
});
const strip = computed(() => props.days ?? []);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
const STATE_STYLES: Record<string, { bg: string; fg: string }> = {
complete: { bg: 'var(--ant-color-success-bg)', fg: 'var(--ant-color-success)' },
partial: { bg: 'var(--ant-color-warning-bg)', fg: 'var(--ant-color-warning)' },
missed: { bg: 'var(--ant-color-error-bg)', fg: 'var(--ant-color-error)' },
empty: { bg: 'var(--ant-color-fill-secondary)', fg: 'var(--ant-color-text-quaternary)' },
};
function stateStyle(state: string) {
return STATE_STYLES[state] ?? STATE_STYLES.empty;
}
</script>
<template>
<section class="score-card">
<header>
<h3>امتیاز روزانه</h3>
<span class="streak" :title="'روزهای متوالی با انجام همه کارهای اجباری'">
<strong>{{ toFaDigits(score?.streak ?? 0) }}</strong>
<a-card :bordered="false">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-title :level="5" :style="{ margin: 0 }">امتیاز روزانه</a-typography-title>
<a-typography-text type="secondary" title="روزهای متوالی با انجام همه کارهای اجباری">
<a-typography-text strong :style="{ color: 'var(--ant-color-primary)' }">
{{ toFaDigits(score?.streak ?? 0) }}
</a-typography-text>
روز پیاپی کامل
</span>
</header>
</a-typography-text>
</a-flex>
<div class="scores">
<div v-for="r in rows" :key="r.label" class="score" :class="`tone-${percentTone(r.value)}`">
<span>{{ r.label }}</span>
<strong>{{ formatPercent(r.value) }}</strong>
<i class="bar"><b :style="{ width: `${r.value ?? 0}%` }" /></i>
</div>
</div>
<a-row :gutter="[16, 16]">
<a-col v-for="r in rows" :key="r.label" :span="12">
<a-flex justify="space-between" align="baseline" :gap="8">
<a-typography-text type="secondary">{{ r.label }}</a-typography-text>
<a-typography-text strong>{{ formatPercent(r.value) }}</a-typography-text>
</a-flex>
<a-progress
:percent="r.value ?? 0"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(r.value)]"
/>
</a-col>
</a-row>
<div v-if="strip.length" class="strip-wrap">
<div class="strip" role="list" aria-label="وضعیت روزهای اخیر">
<span
<template v-if="strip.length">
<a-flex :gap="4" role="list" aria-label="وضعیت روزهای اخیر">
<a-flex
v-for="d in strip"
:key="d.date"
vertical
align="center"
:gap="2"
role="listitem"
class="cell"
:class="`s-${d.state}`"
:style="{ flex: 1, minWidth: 0 }"
>
<a-tooltip
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
>
<em>{{ DAY_STATE_SYMBOLS[d.state] }}</em>
<small>{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}</small>
</span>
</div>
<div class="legend">
<span><i class="s-complete">✓</i> کامل</span>
<span><i class="s-partial">△</i> ناقص</span>
<span><i class="s-missed">×</i> از دست رفته</span>
</div>
</div>
</section>
<a-flex
justify="center"
align="center"
:style="{
width: '26px',
height: '26px',
flexShrink: 0,
borderRadius: token.borderRadiusSM,
background: stateStyle(d.state).bg,
color: stateStyle(d.state).fg,
}"
>
<a-typography-text
strong
:style="{ color: stateStyle(d.state).fg, lineHeight: 1 }"
>
{{ DAY_STATE_SYMBOLS[d.state] }}
</a-typography-text>
</a-flex>
</a-tooltip>
<a-typography-text type="secondary">
{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}
</a-typography-text>
</a-flex>
</a-flex>
<a-flex wrap="wrap" :gap="8">
<a-tag color="success" :bordered="false">✓ کامل</a-tag>
<a-tag color="warning" :bordered="false">△ ناقص</a-tag>
<a-tag color="error" :bordered="false">× از دست رفته</a-tag>
</a-flex>
</template>
</a-flex>
</a-card>
</template>
<style scoped>
.score-card {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.streak {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-size: var(--fs-xs);
color: var(--muted);
}
.streak strong {
font-size: var(--fs-lg);
color: var(--accent-deep);
}
.scores {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.score {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 4px 8px;
}
.score span {
font-size: var(--fs-xs);
color: var(--muted);
}
.score strong {
font-size: var(--fs-md);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--line-strong);
transition: width 0.4s ease;
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.strip-wrap {
display: grid;
gap: var(--sp-2);
}
.strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 4px;
}
.cell {
display: grid;
justify-items: center;
gap: 2px;
}
.cell em {
width: 100%;
aspect-ratio: 1;
max-width: 26px;
border-radius: 6px;
display: grid;
place-items: center;
font-style: normal;
font-size: 0.75rem;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted-light);
}
.cell small {
font-size: 0.62rem;
color: var(--muted-light);
}
.s-complete,
.cell.s-complete em {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial,
.cell.s-partial em {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed,
.cell.s-missed em {
background: var(--danger-soft);
color: var(--danger);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.legend i {
width: 16px;
height: 16px;
border-radius: 4px;
display: inline-grid;
place-items: center;
font-style: normal;
font-size: 0.65rem;
font-weight: var(--fw-bold);
}
</style>
@@ -10,7 +10,7 @@ import {
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue';
import { CloseOutlined } from '@/icons/ant-bridge';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
+79 -128
View File
@@ -1,14 +1,32 @@
<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<{
label: string;
value: string;
hint?: string;
icon?: IconName;
icon?: string;
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
/** تغییر نسبت به دوره قبل (درصد یا عدد)؛ مثبت = بهتر */
delta?: number | null;
@@ -17,135 +35,68 @@ withDefaults(
{ hint: '', icon: undefined, tone: 'neutral', delta: null, deltaSuffix: '' },
);
const ICONS: Record<string, Component> = {
check: CheckOutlined,
clock: ClockCircleOutlined,
warning: WarningOutlined,
target: AimOutlined,
'x-circle': CloseCircleOutlined,
calendar: CalendarOutlined,
flag: FlagOutlined,
users: TeamOutlined,
refresh: SyncOutlined,
};
const TONES: Record<string, { bg: string; fg: string }> = {
accent: { bg: 'var(--ant-color-primary-bg)', fg: 'var(--ant-color-primary)' },
success: { bg: 'var(--ant-color-success-bg)', fg: 'var(--ant-color-success)' },
warning: { bg: 'var(--ant-color-warning-bg)', fg: 'var(--ant-color-warning)' },
danger: { bg: 'var(--ant-color-error-bg)', fg: 'var(--ant-color-error)' },
info: { bg: 'var(--ant-color-info-bg)', fg: 'var(--ant-color-info)' },
neutral: { bg: 'var(--ant-color-fill-secondary)', fg: 'var(--ant-color-text-secondary)' },
};
const iconComp = computed(() => (props.icon ? (ICONS[props.icon] ?? AimOutlined) : null));
const toneStyle = computed(() => TONES[props.tone] ?? TONES.neutral);
function fa(n: number) {
return Math.abs(n).toLocaleString('fa-IR');
}
</script>
<template>
<div class="kpi" :class="`tone-${tone}`">
<div class="kpi-top">
<span class="kpi-label">{{ label }}</span>
<span v-if="icon" class="kpi-ico"><UiIcon :name="icon" :size="16" /></span>
</div>
<strong class="kpi-value">{{ value }}</strong>
<div v-if="hint || delta != null" class="kpi-foot">
<span v-if="delta != null && delta !== 0" class="delta" :class="delta > 0 ? 'up' : 'down'">
<a-card :bordered="false" :style="{ height: '100%' }">
<a-flex vertical :gap="6">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">{{ label }}</a-typography-text>
<a-avatar
v-if="iconComp"
:style="{ background: toneStyle.bg, color: toneStyle.fg, flexShrink: 0 }"
>
<template #icon><component :is="iconComp" /></template>
</a-avatar>
</a-flex>
<a-statistic
:value="value"
:value-style="
tone === 'danger'
? { color: 'var(--ant-color-error)', fontWeight: 700 }
: { fontWeight: 700 }
"
/>
<a-flex v-if="hint || delta != null" align="center" wrap="wrap" :gap="6">
<a-typography-text
v-if="delta != null && delta !== 0"
:type="delta > 0 ? 'success' : 'danger'"
strong
>
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
</span>
<span v-else-if="delta === 0" class="delta flat">بدون تغییر</span>
<span v-if="hint" class="kpi-hint">{{ hint }}</span>
</div>
</div>
</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>
<style scoped>
.kpi {
display: grid;
gap: 6px;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
min-width: 0;
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.kpi-label {
font-size: var(--fs-sm);
color: var(--muted);
font-weight: var(--fw-medium);
}
.kpi-ico {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.tone-accent .kpi-ico {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.tone-success .kpi-ico {
background: var(--success-soft);
color: var(--success-deep);
}
.tone-warning .kpi-ico {
background: var(--warning-soft);
color: var(--warning-deep);
}
.tone-danger .kpi-ico {
background: var(--danger-soft);
color: var(--danger);
}
.tone-info .kpi-ico {
background: var(--info-soft);
color: var(--info-deep);
}
.kpi-value {
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.tone-danger .kpi-value {
color: var(--danger);
}
.kpi-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: var(--fs-xs);
}
.kpi-hint {
color: var(--muted);
}
.delta {
font-weight: var(--fw-semibold);
}
.delta.up {
color: var(--success-deep);
}
.delta.down {
color: var(--danger);
}
.delta.flat {
color: var(--muted);
}
@media (max-width: 900px) {
.kpi {
padding: var(--sp-3);
}
.kpi-value {
font-size: var(--fs-xl);
}
}
</style>
@@ -7,11 +7,11 @@ import {
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CheckOutlined, PlusOutlined } from '@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 QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
@@ -132,7 +132,8 @@ function onCreated(t: RoutineTaskDto) {
:style="{ marginTop: '12px' }"
/>
<a-spin :spinning="loading && !items.length">
<CrmSkeleton :loading="loading && !items.length">
<CrmSkeletonList :rows="4">
<a-empty
v-if="!loading && !items.length"
:description="emptyTitle"
@@ -185,7 +186,8 @@ function onCreated(t: RoutineTaskDto) {
/>
</template>
</a-flex>
</a-spin>
</CrmSkeletonList>
</CrmSkeleton>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
@@ -4,6 +4,15 @@ import type { RoutineCalendarDayDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import { dateToJalali, toFaDigits } from '@/lib/jalali';
import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines';
import {
Button as AButton,
Flex as AFlex,
Typography,
theme,
} from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const props = withDefaults(
defineProps<{
@@ -47,6 +56,39 @@ function heatLevel(d?: RoutineCalendarDayDto): number {
return 1;
}
const STATE_BG: Record<string, string> = {
complete: 'var(--ant-color-success-bg)',
partial: 'var(--ant-color-warning-bg)',
missed: 'var(--ant-color-error-bg)',
empty: 'var(--ant-color-bg-container)',
future: 'var(--ant-color-fill-quaternary)',
};
const STATE_DOT: Record<string, string> = {
complete: 'var(--ant-color-success)',
partial: 'var(--ant-color-warning)',
missed: 'var(--ant-color-error)',
empty: 'var(--ant-color-text-quaternary)',
future: 'var(--ant-color-text-quaternary)',
};
const HEAT_BG: Record<number, string> = {
0: 'var(--ant-color-fill-quaternary)',
1: 'var(--ant-color-info-bg)',
2: 'var(--ant-color-primary-bg)',
3: 'var(--ant-color-success-bg)',
4: 'var(--ant-color-success)',
};
function cellKey(c: (typeof cells.value)[number]) {
return c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty');
}
function cellBackground(c: (typeof cells.value)[number]) {
if (props.mode === 'heat') return HEAT_BG[heatLevel(c.day)];
return STATE_BG[cellKey(c)] ?? STATE_BG.empty;
}
function title(c: (typeof cells.value)[number]) {
const d = c.day;
if (!d || !d.total) return 'بدون کار';
@@ -55,211 +97,78 @@ function title(c: (typeof cells.value)[number]) {
</script>
<template>
<div class="mgrid" :class="[mode, { compact }]">
<div v-for="w in ROUTINE_WEEKDAY_LABELS" :key="w" class="wd">{{ compact ? w.charAt(0) : w }}</div>
<a-flex vertical :gap="6">
<a-flex :gap="6">
<a-typography-text
v-for="w in ROUTINE_WEEKDAY_LABELS"
:key="w"
type="secondary"
strong
:style="{ flex: '1 1 0', minWidth: 0, textAlign: 'center' }"
>
{{ compact ? w.charAt(0) : w }}
</a-typography-text>
</a-flex>
<a-flex wrap="wrap" :gap="6">
<template v-for="c in cells" :key="c.key">
<span v-if="c.blank" class="cell blank" />
<button
<a-flex
v-if="c.blank"
:style="{ flex: '0 0 calc((100% - 36px) / 7)', minWidth: 0 }"
/>
<a-button
v-else
type="button"
class="cell"
:class="[
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
{ sel: selected === c.key, today: c.key === today },
]"
:title="title(c)"
: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)"
>
<span class="num">{{ toFaDigits(c.jd) }}</span>
<template v-if="!compact && c.day?.total">
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span>
<i v-if="mode === 'state'" class="dot" />
<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>
</button>
</a-flex>
</a-flex>
</template>
</div>
</template>
<style scoped>
.mgrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.wd {
text-align: center;
font-size: var(--fs-xs);
color: var(--muted);
font-weight: var(--fw-semibold);
padding-bottom: 2px;
}
.cell {
position: relative;
min-height: 74px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
cursor: pointer;
font: inherit;
text-align: right;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.cell.blank {
border: 0;
background: transparent;
cursor: default;
}
.cell:not(.blank):hover {
border-color: var(--line-strong);
}
.cell.sel {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.cell.today .num {
background: var(--accent);
color: #fff;
}
.num {
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 99px;
display: inline-grid;
place-items: center;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.frac {
font-size: var(--fs-xs);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
position: absolute;
top: 10px;
inset-inline-end: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-strong);
}
/* وضعیت روز */
.s-complete {
background: color-mix(in srgb, var(--success-soft) 45%, var(--surface));
}
.s-complete .dot {
background: var(--success);
}
.s-partial {
background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface));
}
.s-partial .dot {
background: var(--warning);
}
.s-missed {
background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface));
}
.s-missed .dot {
background: var(--danger);
}
.s-future {
background: var(--surface);
}
.s-empty {
background: var(--surface);
}
/* Heatmap */
.h-0 {
background: var(--surface-subtle);
}
.h-1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h-2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h-3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h-4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.h-3 .num,
.h-4 .num,
.h-3 .frac,
.h-4 .frac {
color: #fff;
}
.compact {
gap: 4px;
}
.compact .cell {
min-height: 0;
aspect-ratio: 1;
padding: 2px;
align-items: center;
justify-content: center;
border-color: transparent;
}
.compact .num {
font-size: 0.7rem;
min-width: 0;
height: auto;
}
@media (max-width: 700px) {
.mgrid {
gap: 3px;
}
.cell {
min-height: 54px;
padding: 4px;
align-items: center;
}
.frac {
font-size: 0.62rem;
}
.dot {
position: static;
width: 7px;
height: 7px;
}
}
</style>
@@ -19,7 +19,7 @@ import {
FlagOutlined,
MoreOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
} from '@/icons/ant-bridge';
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
@@ -1,29 +1,26 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import type { RoutineMemberStatDto } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import { toFaDigits } from '@/lib/jalali';
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
import {
Empty as AEmpty,
Flex as AFlex,
Progress as AProgress,
Space as ASpace,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ members: RoutineMemberStatDto[] }>();
const emit = defineEmits<{ select: [userId: string] }>();
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
const sortKey = ref<SortKey>('percent');
const sortDir = ref<'asc' | 'desc'>('desc');
const columns: Array<{ key: SortKey; label: string }> = [
{ key: 'name', label: 'کاربر' },
{ key: 'total', label: 'کل کارها' },
{ key: 'completed', label: 'انجام شده' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'skipped', label: 'رد شده' },
{ key: 'percent', label: 'درصد انجام' },
{ key: 'streak', label: 'روز پیاپی کامل' },
{ key: 'avg', label: 'میانگین زمان انجام' },
];
function value(m: RoutineMemberStatDto, key: SortKey): number | string {
switch (key) {
case 'name':
@@ -37,155 +34,101 @@ function value(m: RoutineMemberStatDto, key: SortKey): number | string {
}
}
const rows = computed(() => {
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...props.members].sort((a, b) => {
const va = value(a, sortKey.value);
const vb = value(b, sortKey.value);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir;
return ((va as number) - (vb as number)) * dir;
});
});
function sortBy(key: SortKey) {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc';
function sorterFor(key: SortKey) {
return (a: RoutineMemberStatDto, b: RoutineMemberStatDto) => {
const va = value(a, key);
const vb = value(b, key);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa');
return (va as number) - (vb as number);
};
}
const columns = computed<TableColumnsType<RoutineMemberStatDto>>(() => [
{ title: 'کاربر', key: 'name', sorter: sorterFor('name'), defaultSortOrder: undefined },
{
title: 'کل کارها',
key: 'total',
sorter: sorterFor('total'),
customRender: ({ record }) => toFaDigits(record.total),
},
{
title: 'انجام شده',
key: 'completed',
sorter: sorterFor('completed'),
customRender: ({ record }) => toFaDigits(record.completed),
},
{
title: 'عقب‌افتاده',
key: 'overdue',
sorter: sorterFor('overdue'),
customRender: ({ record }) => toFaDigits(record.overdue),
},
{
title: 'رد شده',
key: 'skipped',
sorter: sorterFor('skipped'),
customRender: ({ record }) => toFaDigits(record.skipped),
},
{ title: 'درصد انجام', key: 'percent', sorter: sorterFor('percent'), defaultSortOrder: 'descend' },
{
title: 'روز پیاپی کامل',
key: 'streak',
sorter: sorterFor('streak'),
customRender: ({ record }) => toFaDigits(record.streak),
},
{
title: 'میانگین زمان انجام',
key: 'avg',
sorter: sorterFor('avg'),
customRender: ({ record }) => formatMinutes(record.avgCompletionMinutes),
},
]);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
function rowProps(record: RoutineMemberStatDto) {
return {
onClick: () => emit('select', record.user.id),
style: { cursor: 'pointer' },
};
}
</script>
<template>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th
v-for="c in columns"
:key="c.key"
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
<a-table
:columns="columns"
:data-source="props.members"
:pagination="false"
:row-key="(r: RoutineMemberStatDto) => r.user.id"
:custom-row="rowProps"
:scroll="{ x: 'max-content' }"
>
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)">
{{ c.label }}
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)">
<td>
<span class="who">
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" />
{{ m.user.fullName }}
</span>
</td>
<td>{{ toFaDigits(m.total) }}</td>
<td class="ok">{{ toFaDigits(m.completed) }}</td>
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td>
<td>{{ toFaDigits(m.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(m.percent)}`">
<strong>{{ formatPercent(m.percent) }}</strong>
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
</span>
</td>
<td>{{ toFaDigits(m.streak) }}</td>
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty">داده‌ای برای این بازه نیست.</td>
</tr>
</tbody>
</table>
</div>
<template #emptyText>
<a-empty description="داده‌ای برای این بازه نیست." />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-space :size="8">
<UserAvatar :name="record.user.fullName" :user-id="record.user.id" :size="28" />
<a-typography-text strong>{{ record.user.fullName }}</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'percent'">
<a-flex align="center" :gap="8" :style="{ minWidth: '8rem' }">
<a-typography-text strong>{{ formatPercent(record.percent) }}</a-typography-text>
<a-progress
:percent="Math.max(0, Math.min(100, Math.round(record.percent ?? 0)))"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(record.percent)]"
:style="{ flex: 1 }"
/>
</a-flex>
</template>
</template>
</a-table>
</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">
import { computed } from 'vue';
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 { useAuthStore } from '@/stores/auth';
@@ -12,56 +13,48 @@ const auth = useAuthStore();
const links = computed(() => {
const role = auth.user?.role;
const permissions = auth.user?.permissions;
const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
const all: Array<{ to: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ to: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ to: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ to: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
];
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 path = route.path;
for (const link of links.value) {
if (link.exact) {
if (path === link.value) return link.value;
} else if (path === link.value || path.startsWith(`${link.value}/`)) {
return link.value;
}
}
return '/finance';
const selectedKeys = computed(() => {
const match = links.value.find((link) => on(link.to, link.exact));
return match ? [match.to] : [];
});
function onChange(value: string | number) {
const next = String(value);
if (next && next !== route.path) void router.push(next);
}
const items = computed<MenuProps['items']>(() =>
links.value.map((link) => ({
key: link.to,
label: link.label,
})),
);
const onClick: MenuProps['onClick'] = ({ key }) => {
void router.push(String(key));
};
</script>
<template>
<div
:style="{
width: '100%',
overflowX: 'auto',
flexShrink: 0,
minHeight: '36px',
}"
>
<a-segmented
:value="active"
:options="options"
dir="rtl"
:style="{ minWidth: 'max-content' }"
@change="onChange"
<a-menu
mode="horizontal"
:selected-keys="selectedKeys"
:items="items"
@click="onClick"
/>
</div>
</template>
+1 -10
View File
@@ -23,9 +23,7 @@ withDefaults(
<div class="titles">
<h1 v-if="title" class="title">{{ title }}</h1>
<slot v-else name="title" />
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
<slot v-else name="lead" />
<slot name="lead" />
</div>
</div>
@@ -87,13 +85,6 @@ withDefaults(
line-height: var(--lh-tight);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: var(--lh-normal);
}
.actions {
display: flex;
align-items: center;
+163
View File
@@ -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 { router } from './router';
import { vTableCols } from './lib/tableColumns';
import './styles/fonts.css';
import './styles/main.css';
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";
:root {
@@ -20,12 +24,12 @@
--line-light: #f1f5f9;
--line-strong: #cbd5e1;
/* Accent / Primary (Teal) */
--accent: #14b8a6;
--accent-hover: #0d9488;
--accent-deep: #0f766e;
--accent-soft: #ccfbf1;
--accent-subtle: #f0fdfa;
/* Accent / Primary (Montact brand) */
--accent: #10897d;
--accent-hover: #0d7369;
--accent-deep: #0a5c54;
--accent-soft: #d0ebe8;
--accent-subtle: #eef8f7;
--accent-contrast: #ffffff;
/* Semantics */
@@ -141,8 +145,9 @@ html {
html,
body,
#app {
min-height: 100%;
height: 100%;
max-width: 100%;
overflow: hidden;
}
body {
@@ -150,7 +155,6 @@ body {
font-family: var(--font);
color: var(--ink);
background: var(--bg);
overflow-x: clip;
}
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).
* 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_100 = '#f3f4f6';
const GRAY_200 = '#e5e7eb';
export const antdTheme = {
token: {
colorPrimary: '#10b981',
colorPrimary: '#10897d',
fontFamily: '"IRANSans", Tahoma, sans-serif',
/** Page / subtle surface fill */
@@ -64,6 +65,14 @@ export const antdTheme = {
contentBg: GRAY_50,
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: {
colorBorder: GRAY_200,
},
@@ -73,5 +82,16 @@ export const antdTheme = {
Form: {
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;
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">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
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 & {
noteId: string;
noteTitle: string;
};
const ATT_COL_LABELS = ['نام فایل', 'یادداشت', 'حجم', 'تاریخ'] as const;
registerTableColumns('attachments', [...ATT_COL_LABELS]);
const tableCols = useTableColumns(() => 'attachments');
const loading = ref(false);
const error = ref('');
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) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function rowKey(row: Row) {
return `${row.noteId}-${row.id}`;
}
async function load() {
loading.value = true;
error.value = '';
@@ -62,62 +104,56 @@ onMounted(load);
</script>
<template>
<section class="page" dir="rtl">
<header class="head">
<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>
<a-flex vertical :gap="16">
<a-typography-title :level="3" :style="{ margin: 0 }">پیوست‌ها</a-typography-title>
<style scoped>
.page { display: grid; gap: 1rem; }
h1 { margin: 0; font-size: 1.25rem; }
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
.error { color: var(--danger, #dc2626); font-weight: 600; }
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; min-width: 640px; }
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 { color: #0f766e; font-weight: 700; }
button {
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
padding: 0 0.75rem; background: #fff; cursor: pointer; font: inherit;
}
</style>
<a-alert v-if="error" type="error" show-icon :message="error" />
<CrmSkeleton :loading="loading">
<CrmSkeletonTable :rows="8" :columns="5">
<a-card v-if="listEmpty && !error" :bordered="false">
<a-empty description="پیوستی ثبت نشده." />
</a-card>
<a-card v-else-if="!error && items.length" :bordered="false">
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
<TableColumnsMenu table-key="attachments" align="end" />
</a-flex>
<a-table
: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