Compare commits
4
Commits
main
...
b11affbdfd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11affbdfd | ||
|
|
7aaa15fad2 | ||
|
|
39c62834e7 | ||
|
|
26792f9a37 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-*)` پاکسازی شده است؟
|
||||
@@ -1533,6 +1533,7 @@ export class ReportsService {
|
||||
async home(actor: Actor): Promise<HomeDashboardDto> {
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor, 'deals');
|
||||
const invoiceScope = ownerWhere(actor, 'finance');
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
@@ -1543,6 +1544,7 @@ export class ReportsService {
|
||||
const prev = addJalaliMonths(jy, jm, -1);
|
||||
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
|
||||
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
|
||||
const trendWindowStart = addMonthsStart(now, -12);
|
||||
|
||||
const todayStart = startOfDay(now);
|
||||
const todayEnd = endOfDay(now);
|
||||
@@ -1555,17 +1557,25 @@ export class ReportsService {
|
||||
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
|
||||
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
|
||||
'issued',
|
||||
'partially_paid',
|
||||
'paid',
|
||||
];
|
||||
|
||||
const [
|
||||
wonDeals,
|
||||
openDeals,
|
||||
openDealRows,
|
||||
lostDeals,
|
||||
staleDeals,
|
||||
peopleCur,
|
||||
peoplePrev,
|
||||
peopleRecent,
|
||||
openActivitiesToday,
|
||||
openActivitiesYesterday,
|
||||
openDealsWeekAgo,
|
||||
goals,
|
||||
overdueCount,
|
||||
invoices,
|
||||
activityTypeGroups,
|
||||
] = await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
@@ -1573,7 +1583,7 @@ export class ReportsService {
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'won',
|
||||
updatedAt: { gte: addMonthsStart(now, -5) },
|
||||
updatedAt: { gte: trendWindowStart },
|
||||
},
|
||||
dealScope,
|
||||
dealsPrior,
|
||||
@@ -1590,7 +1600,7 @@ export class ReportsService {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ organizationId: orgId, status: 'open' },
|
||||
@@ -1598,6 +1608,30 @@ export class ReportsService {
|
||||
dealsPrior,
|
||||
],
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, position: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'lost',
|
||||
updatedAt: { gte: trendWindowStart },
|
||||
},
|
||||
dealScope,
|
||||
dealsPrior,
|
||||
],
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
lostReason: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
@@ -1624,19 +1658,13 @@ export class ReportsService {
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 5,
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
createdAt: { gte: curMonthStart, lte: curMonthEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
|
||||
createdAt: { gte: trendWindowStart, lte: curMonthEnd },
|
||||
},
|
||||
select: { createdAt: true, importSource: true },
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
@@ -1656,6 +1684,7 @@ export class ReportsService {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
||||
},
|
||||
activityScope,
|
||||
@@ -1697,8 +1726,67 @@ export class ReportsService {
|
||||
],
|
||||
},
|
||||
}),
|
||||
this.prisma.invoice.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...invoiceScope,
|
||||
status: { in: invoiceStatuses },
|
||||
OR: [
|
||||
{ issuedAt: { gte: trendWindowStart, lte: curMonthEnd } },
|
||||
{ createdAt: { gte: trendWindowStart, lte: curMonthEnd } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
issuedAt: true,
|
||||
createdAt: true,
|
||||
lineItems: {
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
currency: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.groupBy({
|
||||
by: ['type'],
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
},
|
||||
activityScope,
|
||||
activitiesPrior,
|
||||
],
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const openDeals = openDealRows.length;
|
||||
|
||||
const invoiceInRange = (
|
||||
inv: { issuedAt: Date | null; createdAt: Date },
|
||||
from: Date,
|
||||
to: Date,
|
||||
) => {
|
||||
const anchor = inv.issuedAt ?? inv.createdAt;
|
||||
return anchor >= from && anchor <= to;
|
||||
};
|
||||
|
||||
const sumInvoiceInRange = (from: Date, to: Date) => {
|
||||
let amount = 0;
|
||||
for (const inv of invoices) {
|
||||
if (!invoiceInRange(inv, from, to)) continue;
|
||||
for (const line of inv.lineItems) {
|
||||
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
|
||||
amount += toBaseAmount(lineTotal, line.currency, rates);
|
||||
}
|
||||
}
|
||||
return amount;
|
||||
};
|
||||
|
||||
const sumWonInRange = (start: Date, end: Date) =>
|
||||
wonDeals
|
||||
.filter((d) => d.updatedAt >= start && d.updatedAt <= end)
|
||||
@@ -1707,8 +1795,21 @@ export class ReportsService {
|
||||
0,
|
||||
);
|
||||
|
||||
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
|
||||
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
|
||||
const salesInRange = (start: Date, end: Date) => {
|
||||
const inv = sumInvoiceInRange(start, end);
|
||||
const won = sumWonInRange(start, end);
|
||||
return Math.round(inv > 0 ? inv : won);
|
||||
};
|
||||
|
||||
const peopleCur = peopleRecent.filter(
|
||||
(p) => p.createdAt >= curMonthStart && p.createdAt <= curMonthEnd,
|
||||
).length;
|
||||
const peoplePrev = peopleRecent.filter(
|
||||
(p) => p.createdAt >= prevMonthStart && p.createdAt <= prevMonthEnd,
|
||||
).length;
|
||||
|
||||
const salesCur = salesInRange(curMonthStart, curMonthEnd);
|
||||
const salesPrev = salesInRange(prevMonthStart, prevMonthEnd);
|
||||
const targetAmount = goals.reduce(
|
||||
(acc, g) => acc + Number(g.targetAmount),
|
||||
0,
|
||||
@@ -1716,7 +1817,7 @@ export class ReportsService {
|
||||
|
||||
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
|
||||
[];
|
||||
for (let i = 4; i >= 0; i -= 1) {
|
||||
for (let i = 11; i >= 0; i -= 1) {
|
||||
const m = addJalaliMonths(jy, jm, -i);
|
||||
trendMonths.push({
|
||||
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
|
||||
@@ -1729,14 +1830,28 @@ export class ReportsService {
|
||||
const salesTrend = trendMonths.map((m) => ({
|
||||
monthKey: m.key,
|
||||
monthLabel: m.label,
|
||||
amount: Math.round(sumWonInRange(m.start, m.end)),
|
||||
amount: salesInRange(m.start, m.end),
|
||||
}));
|
||||
|
||||
const customerGrowth = trendMonths.map((m) => ({
|
||||
monthKey: m.key,
|
||||
monthLabel: m.label,
|
||||
value: peopleRecent.filter(
|
||||
(p) => p.createdAt >= m.start && p.createdAt <= m.end,
|
||||
).length,
|
||||
}));
|
||||
|
||||
const sourceMap = new Map<string, number>();
|
||||
for (const d of wonDeals) {
|
||||
const label = normalizeSource(d.source);
|
||||
for (const p of peopleRecent) {
|
||||
const label = normalizeImportSource(p.importSource);
|
||||
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
|
||||
}
|
||||
if (sourceMap.size === 0) {
|
||||
for (const d of wonDeals) {
|
||||
const label = normalizeSource(d.source);
|
||||
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
|
||||
const sources = [...sourceMap.entries()]
|
||||
.map(([label, count]) => ({
|
||||
@@ -1780,6 +1895,101 @@ export class ReportsService {
|
||||
.slice(0, 5)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }));
|
||||
|
||||
const wonAmountAll = Math.round(
|
||||
wonDeals.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const lostAmountAll = Math.round(
|
||||
lostDeals.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const openAmountAll = Math.round(
|
||||
openDealRows.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const closedCount = wonDeals.length + lostDeals.length;
|
||||
const winRate = closedCount
|
||||
? Math.round((wonDeals.length / closedCount) * 1000) / 10
|
||||
: 0;
|
||||
const salesSummary: HomeDashboardDto['salesSummary'] = {
|
||||
openCount: openDeals,
|
||||
wonCount: wonDeals.length,
|
||||
lostCount: lostDeals.length,
|
||||
openAmount: openAmountAll,
|
||||
wonAmount: wonAmountAll,
|
||||
lostAmount: lostAmountAll,
|
||||
winRate,
|
||||
};
|
||||
|
||||
const funnelMap = new Map<
|
||||
string,
|
||||
{ stageId: string; stageName: string; position: number; count: number; amount: number }
|
||||
>();
|
||||
for (const d of openDealRows) {
|
||||
const stageId = d.stage?.id ?? d.stageId ?? 'unknown';
|
||||
const stageName = d.stage?.name ?? 'بدون مرحله';
|
||||
const position = d.stage?.position ?? 999;
|
||||
const cur = funnelMap.get(stageId) ?? {
|
||||
stageId,
|
||||
stageName,
|
||||
position,
|
||||
count: 0,
|
||||
amount: 0,
|
||||
};
|
||||
cur.count += 1;
|
||||
cur.amount += decimalToBase(d.amount, d.currency, rates);
|
||||
funnelMap.set(stageId, cur);
|
||||
}
|
||||
const funnel = [...funnelMap.values()]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.slice(0, 8)
|
||||
.map((row) => ({
|
||||
stageId: row.stageId,
|
||||
stageName: row.stageName,
|
||||
count: row.count,
|
||||
amount: Math.round(row.amount),
|
||||
}));
|
||||
|
||||
const lostReasonMap = new Map<string, { count: number; amount: number }>();
|
||||
for (const d of lostDeals) {
|
||||
const label = normalizeLostReason(d.lostReason);
|
||||
const prevRow = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
|
||||
prevRow.count += 1;
|
||||
prevRow.amount += decimalToBase(d.amount, d.currency, rates);
|
||||
lostReasonMap.set(label, prevRow);
|
||||
}
|
||||
const lostReasonTotal =
|
||||
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
|
||||
const lostReasons = [...lostReasonMap.entries()]
|
||||
.map(([label, row]) => ({
|
||||
label,
|
||||
count: row.count,
|
||||
amount: Math.round(row.amount),
|
||||
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
const activityTypeLabel: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'کار',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
const activityByType = activityTypeGroups
|
||||
.map((row) => ({
|
||||
type: row.type,
|
||||
label: activityTypeLabel[row.type] ?? row.type,
|
||||
count: row._count._all,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const alerts: HomeDashboardDto['alerts'] = [];
|
||||
if (staleDeals.length) {
|
||||
const first = staleDeals[0]!;
|
||||
@@ -1857,11 +2067,30 @@ export class ReportsService {
|
||||
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
|
||||
changeLabel: 'نسبت به دیروز',
|
||||
},
|
||||
{
|
||||
key: 'win-rate',
|
||||
label: 'نرخ برد',
|
||||
value: winRate,
|
||||
changePercent: null,
|
||||
changeLabel: '۱۲ ماه اخیر',
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'عقبافتاده',
|
||||
value: overdueCount,
|
||||
changePercent: null,
|
||||
changeLabel: 'فعالیت باز',
|
||||
},
|
||||
],
|
||||
alerts,
|
||||
salesTrend,
|
||||
customerGrowth,
|
||||
sources,
|
||||
topCustomers,
|
||||
salesSummary,
|
||||
funnel,
|
||||
lostReasons,
|
||||
activityByType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1919,6 +2148,21 @@ function normalizeSource(raw: string | null | undefined): string {
|
||||
return map[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
function normalizeImportSource(raw: string | null | undefined): string {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return 'سایر';
|
||||
const map: Record<string, string> = {
|
||||
manual: 'دستی',
|
||||
tahesab: 'تهحساب',
|
||||
didar: 'دیدار',
|
||||
excel: 'اکسل',
|
||||
phone: 'تلفن',
|
||||
lead_form: 'فرم لید',
|
||||
other: 'سایر',
|
||||
};
|
||||
return map[value] ?? normalizeSource(raw);
|
||||
}
|
||||
|
||||
function normalizeLostReason(raw: string | null | undefined): string {
|
||||
const value = raw?.trim();
|
||||
if (!value) return 'بدون دلیل ثبتشده';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 '@ant-design/icons-vue';
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,6 +5,20 @@ import { api } from '@/lib/api';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
|
||||
import { rewriteGregorianDates } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
Space as ASpace,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } =
|
||||
Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
@@ -148,193 +162,108 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ai-panel">
|
||||
<header>
|
||||
<strong>پیشنهاد {{ brand.aiName }}</strong>
|
||||
<button
|
||||
<a-card :bordered="true" :style="{ marginTop: '12px' }">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="!isPending"
|
||||
type="button"
|
||||
class="link"
|
||||
type="link"
|
||||
:disabled="loading"
|
||||
@click="analyze"
|
||||
>
|
||||
{{ loading ? '…' : 'تحلیل مجدد' }}
|
||||
</button>
|
||||
<button
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="!suggestion"
|
||||
type="button"
|
||||
class="link"
|
||||
type="link"
|
||||
:disabled="loading"
|
||||
@click="analyze"
|
||||
>
|
||||
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
|
||||
</button>
|
||||
</header>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
|
||||
<p v-else-if="suggestion?.status === 'failed'" class="error">
|
||||
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
|
||||
<button type="button" class="link" @click="analyze">تلاش دوباره</button>
|
||||
</p>
|
||||
<p v-else-if="!suggestion" class="muted">
|
||||
<button type="button" class="link" :disabled="loading" @click="analyze">
|
||||
تحلیل این یادداشت با AI
|
||||
</button>
|
||||
</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" />
|
||||
<a-typography-text v-else-if="loading && !suggestion" type="secondary">
|
||||
در حال تحلیل یادداشت…
|
||||
</a-typography-text>
|
||||
<a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4">
|
||||
<a-alert
|
||||
type="error"
|
||||
show-icon
|
||||
:message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
|
||||
/>
|
||||
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
|
||||
</a-flex>
|
||||
<a-button
|
||||
v-else-if="!suggestion"
|
||||
type="link"
|
||||
:disabled="loading"
|
||||
@click="analyze"
|
||||
>
|
||||
تحلیل این یادداشت با AI
|
||||
</a-button>
|
||||
|
||||
<template v-else-if="isPending && payload">
|
||||
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
|
||||
<a-typography-paragraph
|
||||
v-if="formatPayloadSummary(payload)"
|
||||
type="secondary"
|
||||
:style="{ marginTop: '8px' }"
|
||||
>
|
||||
{{ formatPayloadSummary(payload) }}
|
||||
</a-typography-paragraph>
|
||||
|
||||
<div v-if="payload.nextAction" class="block">
|
||||
<label class="check">
|
||||
<input v-model="applyNextAction" type="checkbox" />
|
||||
کار بعدی
|
||||
</label>
|
||||
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
|
||||
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
|
||||
</div>
|
||||
<a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }">
|
||||
<a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox>
|
||||
<a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" />
|
||||
<JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" />
|
||||
</a-flex>
|
||||
|
||||
<div v-if="memoryItems.length" class="block">
|
||||
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره میشود)</p>
|
||||
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
|
||||
<label
|
||||
<a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
حافظه مشتری (با تأیید شما ذخیره میشود)
|
||||
</a-typography-title>
|
||||
<a-alert
|
||||
v-if="!hasPerson"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
|
||||
/>
|
||||
<a-flex
|
||||
v-for="(_, index) in memoryItems"
|
||||
:key="index"
|
||||
class="memory-row"
|
||||
:gap="8"
|
||||
align="flex-start"
|
||||
>
|
||||
<input
|
||||
v-model="selectedMemory[index]"
|
||||
type="checkbox"
|
||||
<a-checkbox
|
||||
v-model:checked="selectedMemory[index]"
|
||||
:disabled="!hasPerson"
|
||||
/>
|
||||
<textarea v-model="memoryEdits[index]" rows="2" />
|
||||
</label>
|
||||
</div>
|
||||
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" :disabled="applying" @click="apply">
|
||||
{{ applying ? '…' : 'اعمال انتخابشده' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
|
||||
رد کردن
|
||||
</button>
|
||||
</div>
|
||||
<a-space :size="8" wrap :style="{ marginTop: '12px' }">
|
||||
<a-button type="primary" :disabled="applying" :loading="applying" @click="apply">
|
||||
اعمال انتخابشده
|
||||
</a-button>
|
||||
<a-button :disabled="applying" @click="dismiss">رد کردن</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
|
||||
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
|
||||
</section>
|
||||
<a-typography-text
|
||||
v-else-if="suggestion?.status === 'applied'"
|
||||
type="success"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
>
|
||||
پیشنهاد اعمال شد.
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-else-if="suggestion?.status === 'dismissed'"
|
||||
type="secondary"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
>
|
||||
پیشنهاد رد شد.
|
||||
</a-typography-text>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.45rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions .btn,
|
||||
.actions .btn-ghost {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #067647;
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin: 0;
|
||||
color: #b54708;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b42318;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -118,74 +118,83 @@ 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>
|
||||
|
||||
<div v-if="chargeOpen" class="layer">
|
||||
<div class="backdrop" @click="closeCharge" />
|
||||
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
|
||||
<header>
|
||||
<strong>شارژ اعتبار پیامک</strong>
|
||||
<button type="button" class="x" @click="closeCharge">×</button>
|
||||
</header>
|
||||
<Teleport to="body">
|
||||
<div v-if="chargeOpen" class="layer">
|
||||
<div class="backdrop" @click="closeCharge" />
|
||||
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
|
||||
<header>
|
||||
<strong>شارژ اعتبار پیامک</strong>
|
||||
<button type="button" class="x" @click="closeCharge">×</button>
|
||||
</header>
|
||||
|
||||
<div v-if="chargeInfo" class="charge-body">
|
||||
<div class="instruction-box">
|
||||
<p>{{ chargeInfo.instruction }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-head">
|
||||
<span>شماره کارت</span>
|
||||
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
|
||||
کپی
|
||||
</button>
|
||||
<div v-if="chargeInfo" class="charge-body">
|
||||
<div class="instruction-box">
|
||||
<p>{{ chargeInfo.instruction }}</p>
|
||||
</div>
|
||||
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-head">
|
||||
<span>شماره شبا</span>
|
||||
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
|
||||
کپی
|
||||
</button>
|
||||
<div class="info-card">
|
||||
<div class="info-head">
|
||||
<span>شماره کارت</span>
|
||||
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
|
||||
کپی
|
||||
</button>
|
||||
</div>
|
||||
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
|
||||
</div>
|
||||
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-head">
|
||||
<span>شماره شبا</span>
|
||||
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
|
||||
کپی
|
||||
</button>
|
||||
</div>
|
||||
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card muted-card">
|
||||
<span class="small">به نام</span>
|
||||
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card muted-card">
|
||||
<span class="small">به نام</span>
|
||||
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
|
||||
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
|
||||
</div>
|
||||
|
||||
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div v-if="warnOpen" class="layer">
|
||||
<div class="backdrop" @click="warnOpen = false" />
|
||||
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||
<header>
|
||||
<strong>موجودی پیامک منفی است</strong>
|
||||
<button type="button" class="x" @click="warnOpen = false">×</button>
|
||||
</header>
|
||||
<p>
|
||||
اعتبار پیامک محیط شما منفی شده
|
||||
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
||||
شارژ کنید.
|
||||
</p>
|
||||
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="warnOpen" class="layer">
|
||||
<div class="backdrop" @click="warnOpen = false" />
|
||||
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||
<header>
|
||||
<strong>موجودی پیامک منفی است</strong>
|
||||
<button type="button" class="x" @click="warnOpen = false">×</button>
|
||||
</header>
|
||||
<p>
|
||||
اعتبار پیامک محیط شما منفی شده
|
||||
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
||||
شارژ کنید.
|
||||
</p>
|
||||
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -196,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;
|
||||
@@ -213,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 {
|
||||
@@ -228,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;
|
||||
@@ -235,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 {
|
||||
@@ -278,6 +267,8 @@ defineExpose({ refresh: load });
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
@@ -288,7 +279,7 @@ defineExpose({ refresh: load });
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
width: min(420px, 100%);
|
||||
max-height: min(90vh, 640px);
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
@@ -297,6 +288,7 @@ defineExpose({ refresh: load });
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal header {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import { Spin as ASpin } from 'ant-design-vue';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import {
|
||||
@@ -414,8 +415,21 @@ 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',
|
||||
}"
|
||||
>
|
||||
<a-spin
|
||||
v-if="initialLoading && !Object.keys(columns).length"
|
||||
tip="در حال بارگذاری ستونها…"
|
||||
/>
|
||||
<PipelineBoard
|
||||
v-else
|
||||
:stages="boardStages"
|
||||
@@ -465,9 +479,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 '@ant-design/icons-vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ActivityFieldDef[];
|
||||
@@ -24,8 +46,6 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const model = defineModel<ActivityFieldValues>({ required: true });
|
||||
const auth = useAuthStore();
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
|
||||
|
||||
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
|
||||
const v = model.value[key];
|
||||
@@ -46,9 +66,20 @@ function multi(key: string): string[] {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
|
||||
}
|
||||
|
||||
function toggleMulti(key: string, opt: string) {
|
||||
const list = multi(key);
|
||||
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
|
||||
function fieldSpan(type: ActivityFieldDef['type']): number {
|
||||
return ['textarea', 'checklist', 'participants', 'multiselect'].includes(type) ? 24 : 12;
|
||||
}
|
||||
|
||||
function selectOptions(f: ActivityFieldDef) {
|
||||
return (f.options ?? []).map((opt) => ({ value: opt, label: opt }));
|
||||
}
|
||||
|
||||
function onNumberUpdate(key: string, value: number | string | null) {
|
||||
if (value == null || value === '') {
|
||||
set(key, null);
|
||||
return;
|
||||
}
|
||||
set(key, Number(value));
|
||||
}
|
||||
|
||||
/* ---------- چکلیست ---------- */
|
||||
@@ -56,7 +87,9 @@ const checklistDraft = ref<Record<string, string>>({});
|
||||
|
||||
function checklist(key: string): ActivityChecklistItem[] {
|
||||
const v = model.value[key];
|
||||
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
|
||||
return Array.isArray(v)
|
||||
? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object')
|
||||
: [];
|
||||
}
|
||||
|
||||
function addChecklistItem(key: string) {
|
||||
@@ -115,39 +148,82 @@ function addGuest(key: string) {
|
||||
|
||||
const personQuery = ref<Record<string, string>>({});
|
||||
const personResults = ref<Record<string, PersonDto[]>>({});
|
||||
const personOpenKey = ref('');
|
||||
const personSearching = ref<Record<string, boolean>>({});
|
||||
let personTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let personSeq = 0;
|
||||
|
||||
function searchPeople(key: string) {
|
||||
personOpenKey.value = key;
|
||||
function searchPeople(key: string, raw: string) {
|
||||
personQuery.value = { ...personQuery.value, [key]: raw };
|
||||
if (personTimer) clearTimeout(personTimer);
|
||||
const q = (personQuery.value[key] ?? '').trim();
|
||||
const q = raw.trim();
|
||||
if (!q) {
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
personSearching.value = { ...personSearching.value, [key]: false };
|
||||
return;
|
||||
}
|
||||
personSearching.value = { ...personSearching.value, [key]: true };
|
||||
const seq = ++personSeq;
|
||||
personTimer = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||
params: { q, limit: 8 },
|
||||
});
|
||||
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
|
||||
if (seq === personSeq) {
|
||||
personResults.value = { ...personResults.value, [key]: data.items };
|
||||
}
|
||||
} catch {
|
||||
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
|
||||
if (seq === personSeq) {
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
}
|
||||
} finally {
|
||||
if (seq === personSeq) {
|
||||
personSearching.value = { ...personSearching.value, [key]: false };
|
||||
}
|
||||
}
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function addPerson(key: string, p: PersonDto) {
|
||||
const personSelectOptions = computed(() => {
|
||||
const map: Record<string, Array<{ value: string; label: string; company: string }>> = {};
|
||||
for (const f of props.fields) {
|
||||
if (f.type !== 'participants') continue;
|
||||
const selectedIds = new Set(participants(f.key).people.map((p) => p.id));
|
||||
map[f.key] = (personResults.value[f.key] ?? [])
|
||||
.filter((p) => !selectedIds.has(p.id))
|
||||
.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.fullName,
|
||||
company: p.company?.name ?? '',
|
||||
}));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function onPersonPick(key: string, value: unknown) {
|
||||
const id = value == null ? '' : String(value);
|
||||
if (!id) return;
|
||||
const hit =
|
||||
(personResults.value[key] ?? []).find((p) => p.id === id) ??
|
||||
null;
|
||||
if (!hit) return;
|
||||
const cur = participants(key);
|
||||
if (!cur.people.some((x) => x.id === p.id)) {
|
||||
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
|
||||
if (!cur.people.some((x) => x.id === hit.id)) {
|
||||
patchParticipants(key, { people: [...cur.people, { id: hit.id, name: hit.fullName }] });
|
||||
}
|
||||
personQuery.value = { ...personQuery.value, [key]: '' };
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
personOpenKey.value = '';
|
||||
}
|
||||
|
||||
function removePerson(key: string, id: string) {
|
||||
patchParticipants(key, {
|
||||
people: participants(key).people.filter((x) => x.id !== id),
|
||||
});
|
||||
}
|
||||
|
||||
function removeGuest(key: string, name: string) {
|
||||
patchParticipants(key, {
|
||||
guests: participants(key).guests.filter((x) => x !== name),
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -157,523 +233,237 @@ watch(
|
||||
guestDraft.value = {};
|
||||
personQuery.value = {};
|
||||
personResults.value = {};
|
||||
personSearching.value = {};
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fields-grid">
|
||||
<div
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col
|
||||
v-for="f in fields"
|
||||
:key="f.key"
|
||||
class="cf"
|
||||
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
|
||||
:xs="24"
|
||||
:md="fieldSpan(f.type)"
|
||||
>
|
||||
<label :for="`cf-${f.key}`">
|
||||
{{ f.label }}
|
||||
<i v-if="f.required">*</i>
|
||||
</label>
|
||||
<a-form-item :required="f.required">
|
||||
<template #label>
|
||||
{{ f.label }}
|
||||
</template>
|
||||
|
||||
<input
|
||||
v-if="f.type === 'text' || f.type === 'url'"
|
||||
:id="`cf-${f.key}`"
|
||||
:type="f.type === 'url' ? 'url' : 'text'"
|
||||
:dir="f.type === 'url' ? 'ltr' : undefined"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
maxlength="1000"
|
||||
@input="set(f.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-else-if="f.type === 'textarea'"
|
||||
:id="`cf-${f.key}`"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
rows="3"
|
||||
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="f.type === 'number'"
|
||||
:id="`cf-${f.key}`"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
dir="ltr"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
@input="
|
||||
set(
|
||||
f.key,
|
||||
($event.target as HTMLInputElement).value === ''
|
||||
? null
|
||||
: Number(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="f.type === 'date'"
|
||||
:id="`cf-${f.key}`"
|
||||
:model-value="str(f.key)"
|
||||
@update:model-value="(v: string) => set(f.key, v || null)"
|
||||
/>
|
||||
|
||||
<select
|
||||
v-else-if="f.type === 'select'"
|
||||
:id="`cf-${f.key}`"
|
||||
:value="str(f.key)"
|
||||
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
|
||||
<div v-else-if="f.type === 'multiselect'" class="chips">
|
||||
<button
|
||||
v-for="opt in f.options ?? []"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="chip-opt"
|
||||
:class="{ on: multi(f.key).includes(opt) }"
|
||||
@click="toggleMulti(f.key, opt)"
|
||||
>
|
||||
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-else-if="f.type === 'checkbox'" class="switch">
|
||||
<input
|
||||
:id="`cf-${f.key}`"
|
||||
type="checkbox"
|
||||
:checked="get<boolean>(f.key, false) === true"
|
||||
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
|
||||
<a-input
|
||||
v-if="f.type === 'text' || f.type === 'url'"
|
||||
:value="str(f.key)"
|
||||
:type="f.type === 'url' ? 'url' : 'text'"
|
||||
:dir="f.type === 'url' ? 'ltr' : undefined"
|
||||
:placeholder="f.placeholder || ''"
|
||||
:maxlength="1000"
|
||||
style="width: 100%"
|
||||
@update:value="(v: string) => set(f.key, v)"
|
||||
/>
|
||||
<i />
|
||||
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
|
||||
</label>
|
||||
|
||||
<div v-else-if="f.type === 'checklist'" class="checklist">
|
||||
<p v-if="checklist(f.key).length" class="progress">
|
||||
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
||||
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
|
||||
<input
|
||||
type="checkbox"
|
||||
<a-textarea
|
||||
v-else-if="f.type === 'textarea'"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
:rows="3"
|
||||
style="width: 100%"
|
||||
@update:value="(v: string) => set(f.key, v)"
|
||||
/>
|
||||
|
||||
<a-input-number
|
||||
v-else-if="f.type === 'number'"
|
||||
:value="str(f.key) === '' ? undefined : Number(str(f.key))"
|
||||
:placeholder="f.placeholder || ''"
|
||||
style="width: 100%"
|
||||
@update:value="(v: number | string | null) => onNumberUpdate(f.key, v)"
|
||||
/>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="f.type === 'date'"
|
||||
:model-value="str(f.key)"
|
||||
style="width: 100%"
|
||||
@update:model-value="(v: string) => set(f.key, v || null)"
|
||||
/>
|
||||
|
||||
<a-select
|
||||
v-else-if="f.type === 'select'"
|
||||
:value="str(f.key) || undefined"
|
||||
:options="selectOptions(f)"
|
||||
allow-clear
|
||||
placeholder="— انتخاب کنید —"
|
||||
style="width: 100%"
|
||||
@update:value="(v: unknown) => set(f.key, v == null || v === '' ? null : String(v))"
|
||||
/>
|
||||
|
||||
<a-select
|
||||
v-else-if="f.type === 'multiselect'"
|
||||
mode="multiple"
|
||||
:value="multi(f.key)"
|
||||
:options="selectOptions(f)"
|
||||
:placeholder="f.placeholder || 'انتخاب کنید'"
|
||||
style="width: 100%"
|
||||
@update:value="(v: unknown) => set(f.key, Array.isArray(v) ? v.map(String) : [])"
|
||||
/>
|
||||
|
||||
<a-space v-else-if="f.type === 'checkbox'" :size="8">
|
||||
<a-switch
|
||||
:checked="get<boolean>(f.key, false) === true"
|
||||
@update:checked="(v) => set(f.key, v === true)"
|
||||
/>
|
||||
<a-typography-text>
|
||||
{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
|
||||
<a-flex v-else-if="f.type === 'checklist'" vertical :gap="8">
|
||||
<a-typography-text v-if="checklist(f.key).length" type="success">
|
||||
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
||||
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
||||
</a-typography-text>
|
||||
<a-flex
|
||||
v-for="(it, idx) in checklist(f.key)"
|
||||
:key="it.id"
|
||||
align="center"
|
||||
:gap="8"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="it.done"
|
||||
aria-label="انجام شد"
|
||||
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
|
||||
@update:checked="(v: boolean) => updateChecklistItem(f.key, it.id, { done: v })"
|
||||
/>
|
||||
<input
|
||||
class="item-text"
|
||||
<a-input
|
||||
:value="it.text"
|
||||
maxlength="300"
|
||||
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
|
||||
:maxlength="300"
|
||||
:style="{
|
||||
flex: 1,
|
||||
textDecoration: it.done ? 'line-through' : undefined,
|
||||
}"
|
||||
@update:value="(v: string) => updateChecklistItem(f.key, it.id, { text: v })"
|
||||
/>
|
||||
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mini"
|
||||
<a-button
|
||||
:disabled="idx === 0"
|
||||
aria-label="بالا"
|
||||
@click="moveChecklistItem(f.key, idx, -1)"
|
||||
>
|
||||
<template #icon><UpOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
:disabled="idx === checklist(f.key).length - 1"
|
||||
aria-label="پایین"
|
||||
@click="moveChecklistItem(f.key, idx, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
|
||||
<UiIcon name="x" :size="13" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="checklistDraft[f.key]"
|
||||
maxlength="300"
|
||||
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
|
||||
@keydown.enter.prevent="addChecklistItem(f.key)"
|
||||
/>
|
||||
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'participants'" class="participants">
|
||||
<div class="p-block">
|
||||
<small>همکاران (فعالیت در لیست آنها هم نمایش داده میشود)</small>
|
||||
<UserMultiSelect
|
||||
:model-value="participants(f.key).userIds"
|
||||
:users="users"
|
||||
:disabled="!canAssignOthers"
|
||||
add-label="افزودن همکار"
|
||||
empty-label="همکاری انتخاب نشده"
|
||||
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-block">
|
||||
<small>اشخاص / مشتریان</small>
|
||||
<div class="tags">
|
||||
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
|
||||
<UiIcon name="user" :size="12" />
|
||||
{{ p.name }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="person-search">
|
||||
<input
|
||||
v-model="personQuery[f.key]"
|
||||
type="search"
|
||||
placeholder="جستجوی نام یا موبایل شخص…"
|
||||
@input="searchPeople(f.key)"
|
||||
@focus="personOpenKey = f.key"
|
||||
<template #icon><DownOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
aria-label="حذف"
|
||||
@click="removeChecklistItem(f.key, it.id)"
|
||||
>
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-space :size="8" style="width: 100%">
|
||||
<a-input
|
||||
:value="checklistDraft[f.key] ?? ''"
|
||||
:maxlength="300"
|
||||
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
|
||||
style="width: 100%"
|
||||
@update:value="(v: string) => (checklistDraft[f.key] = v)"
|
||||
@pressEnter="addChecklistItem(f.key)"
|
||||
/>
|
||||
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
|
||||
<li v-for="p in personResults[f.key]" :key="p.id">
|
||||
<button type="button" @click="addPerson(f.key, p)">
|
||||
<b>{{ p.fullName }}</b>
|
||||
<small v-if="p.company?.name">{{ p.company.name }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-block">
|
||||
<small>مهمانان دیگر (نام آزاد)</small>
|
||||
<div class="tags">
|
||||
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
|
||||
{{ g }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="guestDraft[f.key]"
|
||||
maxlength="120"
|
||||
placeholder="نام مهمان و Enter"
|
||||
@keydown.enter.prevent="addGuest(f.key)"
|
||||
/>
|
||||
<button type="button" class="add-btn" @click="addGuest(f.key)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
<a-button type="dashed" @click="addChecklistItem(f.key)">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<a-flex v-else-if="f.type === 'participants'" vertical :gap="16">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">
|
||||
همکاران (فعالیت در لیست آنها هم نمایش داده میشود)
|
||||
</a-typography-text>
|
||||
<UserMultiSelect
|
||||
:model-value="participants(f.key).userIds"
|
||||
:users="users"
|
||||
add-label="افزودن همکار"
|
||||
empty-label="همکاری انتخاب نشده"
|
||||
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
||||
/>
|
||||
</a-flex>
|
||||
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">اشخاص / مشتریان</a-typography-text>
|
||||
<a-space v-if="participants(f.key).people.length" :size="6" wrap>
|
||||
<a-tag
|
||||
v-for="p in participants(f.key).people"
|
||||
:key="p.id"
|
||||
closable
|
||||
color="cyan"
|
||||
:bordered="false"
|
||||
@close="removePerson(f.key, p.id)"
|
||||
>
|
||||
<UserOutlined />
|
||||
{{ p.name }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-select
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
:value="undefined"
|
||||
:options="personSelectOptions[f.key] ?? []"
|
||||
:not-found-content="
|
||||
personSearching[f.key]
|
||||
? 'در حال جستجو…'
|
||||
: (personQuery[f.key] || '').trim()
|
||||
? 'شخصی پیدا نشد.'
|
||||
: 'نام یا موبایل را جستجو کنید'
|
||||
"
|
||||
placeholder="جستجوی نام یا موبایل شخص…"
|
||||
style="width: 100%"
|
||||
@search="(q: string) => searchPeople(f.key, q)"
|
||||
@update:value="(v: unknown) => onPersonPick(f.key, v)"
|
||||
>
|
||||
<template #option="{ label, company }">
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text>{{ label }}</a-typography-text>
|
||||
<a-typography-text v-if="company" type="secondary">{{ company }}</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-flex>
|
||||
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">مهمانان دیگر (نام آزاد)</a-typography-text>
|
||||
<a-space v-if="participants(f.key).guests.length" :size="6" wrap>
|
||||
<a-tag
|
||||
v-for="g in participants(f.key).guests"
|
||||
:key="g"
|
||||
closable
|
||||
:bordered="false"
|
||||
@close="removeGuest(f.key, g)"
|
||||
>
|
||||
{{ g }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-space :size="8" style="width: 100%">
|
||||
<a-input
|
||||
:value="guestDraft[f.key] ?? ''"
|
||||
:maxlength="120"
|
||||
placeholder="نام مهمان و Enter"
|
||||
style="width: 100%"
|
||||
@update:value="(v: string) => (guestDraft[f.key] = v)"
|
||||
@pressEnter="addGuest(f.key)"
|
||||
/>
|
||||
<a-button type="dashed" @click="addGuest(f.key)">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.cf {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cf > label {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: var(--fs-xs, 0.78rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cf > label i {
|
||||
color: var(--color-danger, #dc2626);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cf input:not([type='checkbox']),
|
||||
.cf select,
|
||||
.cf textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cf textarea {
|
||||
min-height: 5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chips,
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chip-opt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip-opt.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.switch i {
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.switch i::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 3px;
|
||||
top: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
transition: 0.18s;
|
||||
}
|
||||
|
||||
.switch input:checked + i {
|
||||
background: #14b8a6;
|
||||
}
|
||||
|
||||
.switch input:checked + i::after {
|
||||
inset-inline-start: 21px;
|
||||
}
|
||||
|
||||
.checklist ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.checklist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.checklist li input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #14b8a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checklist .item-text {
|
||||
flex: 1;
|
||||
min-height: 2.2rem !important;
|
||||
}
|
||||
|
||||
.checklist li.done .item-text {
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mini.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px dashed #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.p-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.p-block > small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag.guest {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tag button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.person-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.results {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 20;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results button {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.results b {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.fields-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.cf.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,10 +12,25 @@ import {
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_STAGE_KIND_LABELS,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
} from 'ant-design-vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
@@ -62,6 +77,19 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function confirmAsync(content: string, title = 'تأیید'): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okText: 'ادامه',
|
||||
cancelText: 'انصراف',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isDirty(stage: ActivityStageDto) {
|
||||
const d = drafts.value[stage.id];
|
||||
return Boolean(
|
||||
@@ -78,9 +106,9 @@ async function save(stage: ActivityStageDto) {
|
||||
}
|
||||
if (
|
||||
d.kind !== stage.kind &&
|
||||
!window.confirm(
|
||||
!(await confirmAsync(
|
||||
'با تغییر نوع ستون، فعالیتهای فعلی آن وضعیتشان را نگه میدارند و به اولین ستون هموضعیت خودشان منتقل میشوند. ادامه میدهید؟',
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -119,23 +147,25 @@ async function move(index: number, dir: -1 | 1) {
|
||||
}
|
||||
|
||||
async function remove(stage: ActivityStageDto) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`ستون «${stage.name}» حذف شود؟ فعالیتهای آن به اولین ستون هموضعیت منتقل میشوند.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
busyId.value = stage.id;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/activities/stages/${stage.id}`);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'حذف ستون',
|
||||
content: `ستون «${stage.name}» حذف شود؟ فعالیتهای آن به اولین ستون هموضعیت منتقل میشوند.`,
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
busyId.value = stage.id;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/activities/stages/${stage.id}`);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function create() {
|
||||
@@ -156,226 +186,163 @@ async function create() {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function dotColor(stage: ActivityStageDto) {
|
||||
const color = drafts.value[stage.id]?.color ?? stage.color;
|
||||
return PIPELINE_ACCENT[color].solid;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="boardName ? `مدیریت ستونهای گروه «${boardName}»` : 'مدیریت ستونها و تبهای فعالیت'"
|
||||
@close="emit('close')"
|
||||
:footer="null"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="manager">
|
||||
<p class="hint">
|
||||
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجامشده</b> کشیده شود خودکار انجامشده ثبت
|
||||
میشود و در ستون <b>لغو شده</b> لغو میشود. از هر نوع حداقل یک ستون لازم است.
|
||||
</p>
|
||||
<a-flex vertical gap="middle">
|
||||
<a-typography-text type="secondary">
|
||||
هر ستون یک «نوع» دارد: فعالیتی که به ستون <strong>انجامشده</strong> کشیده شود خودکار انجامشده ثبت
|
||||
میشود و در ستون <strong>لغو شده</strong> لغو میشود. از هر نوع حداقل یک ستون لازم است.
|
||||
</a-typography-text>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<a-alert v-if="error" type="error" :message="error" show-icon />
|
||||
|
||||
<div class="rows">
|
||||
<div
|
||||
<a-flex vertical gap="small">
|
||||
<a-row
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.id"
|
||||
class="row"
|
||||
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }"
|
||||
:gutter="[8, 8]"
|
||||
align="middle"
|
||||
>
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<input
|
||||
v-if="drafts[stage.id]"
|
||||
v-model="drafts[stage.id]!.name"
|
||||
class="name"
|
||||
maxlength="80"
|
||||
aria-label="نام ستون"
|
||||
@keydown.enter.prevent="save(stage)"
|
||||
/>
|
||||
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.kind" aria-label="نوع ستون">
|
||||
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
|
||||
</select>
|
||||
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.color" aria-label="رنگ ستون">
|
||||
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
|
||||
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
:disabled="index === 0 || Boolean(busyId)"
|
||||
title="انتقال به قبل"
|
||||
aria-label="انتقال به قبل"
|
||||
@click="move(index, -1)"
|
||||
<a-col flex="none">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:style="{
|
||||
display: 'inline-block',
|
||||
width: '0.65rem',
|
||||
height: '0.65rem',
|
||||
borderRadius: '999px',
|
||||
background: dotColor(stage),
|
||||
}"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="8">
|
||||
<a-input
|
||||
v-if="drafts[stage.id]"
|
||||
v-model:value="drafts[stage.id]!.name"
|
||||
:maxlength="80"
|
||||
aria-label="نام ستون"
|
||||
@keydown.enter.prevent="save(stage)"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<a-select
|
||||
v-if="drafts[stage.id]"
|
||||
v-model:value="drafts[stage.id]!.kind"
|
||||
aria-label="نوع ستون"
|
||||
style="width: 100%"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
:disabled="index === stages.length - 1 || Boolean(busyId)"
|
||||
title="انتقال به بعد"
|
||||
aria-label="انتقال به بعد"
|
||||
@click="move(index, 1)"
|
||||
<a-select-option v-for="k in KINDS" :key="k" :value="k">
|
||||
{{ ACTIVITY_STAGE_KIND_LABELS[k] }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="5">
|
||||
<a-select
|
||||
v-if="drafts[stage.id]"
|
||||
v-model:value="drafts[stage.id]!.color"
|
||||
aria-label="رنگ ستون"
|
||||
style="width: 100%"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<UiButton
|
||||
size="sm"
|
||||
type="button"
|
||||
:disabled="!isDirty(stage) || Boolean(busyId)"
|
||||
:loading="busyId === stage.id"
|
||||
@click="save(stage)"
|
||||
>
|
||||
ذخیره
|
||||
</UiButton>
|
||||
<button
|
||||
type="button"
|
||||
class="icon danger"
|
||||
:disabled="Boolean(busyId)"
|
||||
title="حذف ستون"
|
||||
aria-label="حذف ستون"
|
||||
@click="remove(stage)"
|
||||
>
|
||||
<UiIcon name="trash" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
|
||||
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="5">
|
||||
<a-flex align="center" gap="small" wrap="wrap" justify="flex-end">
|
||||
<a-button
|
||||
type="text"
|
||||
:disabled="index === 0 || Boolean(busyId)"
|
||||
title="انتقال به قبل"
|
||||
aria-label="انتقال به قبل"
|
||||
@click="move(index, -1)"
|
||||
>
|
||||
<template #icon><ArrowUpOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
:disabled="index === stages.length - 1 || Boolean(busyId)"
|
||||
title="انتقال به بعد"
|
||||
aria-label="انتقال به بعد"
|
||||
@click="move(index, 1)"
|
||||
>
|
||||
<template #icon><ArrowDownOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!isDirty(stage) || Boolean(busyId)"
|
||||
:loading="busyId === stage.id"
|
||||
@click="save(stage)"
|
||||
>
|
||||
ذخیره
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
danger
|
||||
:disabled="Boolean(busyId)"
|
||||
title="حذف ستون"
|
||||
aria-label="حذف ستون"
|
||||
@click="remove(stage)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-flex>
|
||||
|
||||
<form class="create" @submit.prevent="create">
|
||||
<strong>ستون جدید</strong>
|
||||
<div class="create-fields">
|
||||
<input v-model="createForm.name" maxlength="80" placeholder="مثلاً: منتظر پاسخ مشتری" />
|
||||
<select v-model="createForm.kind" aria-label="نوع ستون جدید">
|
||||
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
|
||||
</select>
|
||||
<select v-model="createForm.color" aria-label="رنگ ستون جدید">
|
||||
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
|
||||
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
:disabled="!createForm.name.trim() || Boolean(busyId)"
|
||||
:loading="busyId === 'create'"
|
||||
>
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</UiModal>
|
||||
<a-divider />
|
||||
|
||||
<a-flex vertical gap="small" @submit.prevent="create">
|
||||
<a-typography-text strong>ستون جدید</a-typography-text>
|
||||
<a-row :gutter="[8, 8]" align="middle">
|
||||
<a-col :xs="24" :sm="10">
|
||||
<a-input
|
||||
v-model:value="createForm.name"
|
||||
:maxlength="80"
|
||||
placeholder="مثلاً: منتظر پاسخ مشتری"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="5">
|
||||
<a-select v-model:value="createForm.kind" aria-label="نوع ستون جدید" style="width: 100%">
|
||||
<a-select-option v-for="k in KINDS" :key="k" :value="k">
|
||||
{{ ACTIVITY_STAGE_KIND_LABELS[k] }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="5">
|
||||
<a-select v-model:value="createForm.color" aria-label="رنگ ستون جدید" style="width: 100%">
|
||||
<a-select-option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
|
||||
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="4">
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="button"
|
||||
block
|
||||
:disabled="!createForm.name.trim() || Boolean(busyId)"
|
||||
:loading="busyId === 'create'"
|
||||
@click="create"
|
||||
>
|
||||
افزودن
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manager {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 0.7rem minmax(0, 1fr) 9rem 6.5rem auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--dot);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.create {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.create-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 9rem 6.5rem auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
grid-template-columns: 0.7rem minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.row select {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.create-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { computed, ref, type Component } from 'vue';
|
||||
import AutomationNode from '@/components/automations/AutomationNode.vue';
|
||||
import {
|
||||
AUTOMATION_ACTION_LABELS,
|
||||
@@ -22,6 +21,21 @@ 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,
|
||||
GiftOutlined,
|
||||
PlusOutlined,
|
||||
SelectOutlined,
|
||||
ThunderboltOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
export type AutomationAddPayload =
|
||||
| { kind: 'action'; action: AutomationAction }
|
||||
@@ -60,6 +74,101 @@ 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;
|
||||
}
|
||||
|
||||
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
|
||||
const steps = props.steps;
|
||||
const { prefixEnd, branches } = splitAutomationBranches(steps);
|
||||
@@ -151,10 +260,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 +317,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 +350,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 +371,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 +393,44 @@ 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>
|
||||
<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>
|
||||
</div>
|
||||
<AutomationNode
|
||||
v-else
|
||||
@@ -292,7 +456,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 +472,44 @@ function removeBranch(lane: BranchLane) {
|
||||
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
|
||||
@click.stop="openAdd(item)"
|
||||
>
|
||||
<UiIcon name="plus" :size="14" />
|
||||
<PlusOutlined :style="{ fontSize: '14px' }" />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<AutomationNode
|
||||
v-else
|
||||
@@ -330,65 +530,133 @@ 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-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 +664,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 +701,7 @@ function removeBranch(lane: BranchLane) {
|
||||
flex-shrink: 0;
|
||||
color: #c2410c;
|
||||
background: #ffedd5;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
@@ -535,7 +804,7 @@ strong {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: 1.3rem;
|
||||
padding-block: 0.7rem;
|
||||
}
|
||||
|
||||
.branch::before {
|
||||
@@ -608,66 +877,92 @@ 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);
|
||||
}
|
||||
|
||||
.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 +975,10 @@ strong {
|
||||
@media (max-width: 700px) {
|
||||
.canvas {
|
||||
min-height: 0;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.world {
|
||||
padding: 1rem 0.75rem 4rem;
|
||||
}
|
||||
|
||||
.flow,
|
||||
@@ -749,7 +1047,7 @@ strong {
|
||||
}
|
||||
|
||||
.picker {
|
||||
max-width: none;
|
||||
width: min(220px, 70vw);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,24 @@ import {
|
||||
formConditionValues,
|
||||
isAutomationCondition,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Checkbox as ACheckbox,
|
||||
CheckboxGroup as ACheckboxGroup,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
RadioGroup as ARadioGroup,
|
||||
Select as ASelect,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const props = defineProps<{
|
||||
selected: 'trigger' | string;
|
||||
@@ -124,6 +142,87 @@ function toggleUser(step: AutomationStep, id: string, on: boolean) {
|
||||
step.assigneeUserIds = [...cur];
|
||||
}
|
||||
|
||||
function onUserCheck(step: AutomationStep, id: string, e: { target: { checked: boolean } }) {
|
||||
toggleUser(step, id, e.target.checked);
|
||||
}
|
||||
|
||||
function onConditionFieldChange(v: unknown) {
|
||||
if (typeof v === 'string' && selectedStep.value) {
|
||||
setConditionField(selectedStep.value, v as AutomationConditionField);
|
||||
}
|
||||
}
|
||||
|
||||
function onConditionKeyChange(v: unknown) {
|
||||
if (typeof v === 'string' && selectedStep.value) setConditionKey(selectedStep.value, v);
|
||||
}
|
||||
|
||||
function onActionChange(v: unknown) {
|
||||
if (typeof v === 'string' && selectedStep.value) {
|
||||
setAction(selectedStep.value, v as AutomationAction);
|
||||
}
|
||||
}
|
||||
|
||||
function onDueUnitChange(v: unknown) {
|
||||
if (!selectedStep.value || typeof v !== 'string') return;
|
||||
setDue(selectedStep.value, v as DueUnit, dueAmountOf(selectedStep.value) || 1);
|
||||
}
|
||||
|
||||
function onDueAmountChange(v: string | number | null) {
|
||||
if (!selectedStep.value) return;
|
||||
setDue(selectedStep.value, dueUnitOf(selectedStep.value), Number(v ?? 1));
|
||||
}
|
||||
|
||||
function onFormOptionValuesChange(vals: Array<string | number | boolean>) {
|
||||
if (!selectedStep.value) return;
|
||||
const next = vals.map(String);
|
||||
const order = conditionFormField.value?.options ?? [];
|
||||
next.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
selectedStep.value.conditionValues = next;
|
||||
selectedStep.value.conditionValue = next[0] ?? '';
|
||||
}
|
||||
|
||||
const dealAmountValue = computed({
|
||||
get: () =>
|
||||
typeof selectedStep.value?.conditionValue === 'number'
|
||||
? selectedStep.value.conditionValue
|
||||
: Number(selectedStep.value?.conditionValue ?? 0),
|
||||
set: (v: number | null) => {
|
||||
if (selectedStep.value) selectedStep.value.conditionValue = Number(v ?? 0);
|
||||
},
|
||||
});
|
||||
|
||||
const formTextValue = computed({
|
||||
get: () => String(selectedStep.value?.conditionValue ?? ''),
|
||||
set: (v: string) => {
|
||||
if (selectedStep.value) selectedStep.value.conditionValue = v;
|
||||
},
|
||||
});
|
||||
|
||||
function nullableId(value: string | null | undefined) {
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function setNullableId(
|
||||
key: 'activityTypeDefId' | 'assigneeId' | 'assigneeTeamId' | 'notifyUserId',
|
||||
v: unknown,
|
||||
) {
|
||||
if (!selectedStep.value) return;
|
||||
selectedStep.value[key] = typeof v === 'string' && v ? v : null;
|
||||
}
|
||||
|
||||
function onActivityTypeDefChange(v: unknown) {
|
||||
setNullableId('activityTypeDefId', v);
|
||||
}
|
||||
function onAssigneeIdChange(v: unknown) {
|
||||
setNullableId('assigneeId', v);
|
||||
}
|
||||
function onAssigneeTeamIdChange(v: unknown) {
|
||||
setNullableId('assigneeTeamId', v);
|
||||
}
|
||||
function onNotifyUserIdChange(v: unknown) {
|
||||
setNullableId('notifyUserId', v);
|
||||
}
|
||||
|
||||
function setConditionKey(step: AutomationStep, key: string) {
|
||||
step.conditionKey = key || null;
|
||||
const field = formFields.value.find((f) => f.key === key);
|
||||
@@ -131,15 +230,6 @@ function setConditionKey(step: AutomationStep, key: string) {
|
||||
step.conditionValues = step.conditionValue ? [step.conditionValue] : [];
|
||||
}
|
||||
|
||||
function toggleConditionValue(step: AutomationStep, option: string, on: boolean) {
|
||||
const cur = formConditionValues(step).filter((v) => v !== option);
|
||||
if (on) cur.push(option);
|
||||
const order = conditionFormField.value?.options ?? [];
|
||||
cur.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
step.conditionValues = cur;
|
||||
step.conditionValue = cur[0] ?? '';
|
||||
}
|
||||
|
||||
function multiValueHint(op: AutomationConditionOp | undefined) {
|
||||
if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینهها انتخاب شده باشد.';
|
||||
if (op === 'not_contains') return 'هیچکدام از این گزینهها نباید انتخاب شده باشد.';
|
||||
@@ -162,6 +252,94 @@ const ticketPriorities: Array<{ value: TicketPriority; label: string }> = [
|
||||
{ value: 'urgent', label: 'فوری' },
|
||||
];
|
||||
|
||||
const triggerOptions = AUTOMATION_TRIGGER_GROUPS.map((group) => ({
|
||||
label: group.label,
|
||||
options: group.items.map((t) => ({
|
||||
value: t,
|
||||
label: AUTOMATION_TRIGGER_LABELS[t],
|
||||
})),
|
||||
}));
|
||||
|
||||
const formOptions = computed(() => [
|
||||
{ value: '', label: 'همه فرمها' },
|
||||
...props.forms.map((f) => ({ value: f.id, label: f.name })),
|
||||
]);
|
||||
|
||||
const sourceIdSelect = computed({
|
||||
get: () => sourceId.value ?? '',
|
||||
set: (v: string) => {
|
||||
sourceId.value = v || null;
|
||||
},
|
||||
});
|
||||
|
||||
const chainModeOptions = [
|
||||
{ value: 'branches' as const, label: AUTOMATION_CHAIN_MODE_LABELS.branches },
|
||||
{ value: 'stop' as const, label: AUTOMATION_CHAIN_MODE_LABELS.stop },
|
||||
];
|
||||
|
||||
const conditionFieldOptions = computed(() =>
|
||||
conditionFields.value.map((f) => ({
|
||||
value: f,
|
||||
label: AUTOMATION_CONDITION_FIELD_LABELS[f],
|
||||
})),
|
||||
);
|
||||
|
||||
const conditionOpOptions = computed(() =>
|
||||
conditionOps.value.map((op) => ({
|
||||
value: op,
|
||||
label: AUTOMATION_CONDITION_OP_LABELS[op],
|
||||
})),
|
||||
);
|
||||
|
||||
const formFieldOptions = computed(() =>
|
||||
formFields.value.map((f) => ({ value: f.key, label: f.label })),
|
||||
);
|
||||
|
||||
const actionOptions = AUTOMATION_ACTIONS.map((a) => ({
|
||||
value: a,
|
||||
label: AUTOMATION_ACTION_LABELS[a],
|
||||
}));
|
||||
|
||||
const activityTypeOptions = activityTypes.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
}));
|
||||
|
||||
const activityTypeDefOptions = computed(() => [
|
||||
{ value: '', label: '— بدون نوع سفارشی —' },
|
||||
...props.activityTypeDefs.map((t) => ({ value: t.id, label: t.name })),
|
||||
]);
|
||||
|
||||
const assignModeOptions = computed(() =>
|
||||
assignModes.value.map((m) => ({
|
||||
value: m,
|
||||
label: AUTOMATION_ASSIGN_MODE_LABELS[m],
|
||||
})),
|
||||
);
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
);
|
||||
|
||||
const teamOptions = computed(() =>
|
||||
props.teams.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.memberCount} نفر)`,
|
||||
})),
|
||||
);
|
||||
|
||||
const notifyUserOptions = computed(() => [
|
||||
{ value: '', label: 'مسئول معامله / تیکت' },
|
||||
...userOptions.value,
|
||||
]);
|
||||
|
||||
const notifyChannelOptions = AUTOMATION_NOTIFY_CHANNELS.map((ch) => ({
|
||||
value: ch,
|
||||
label: AUTOMATION_NOTIFY_CHANNEL_LABELS[ch],
|
||||
}));
|
||||
|
||||
const dueUnitOptions = dueUnits.map((u) => ({ value: u.value, label: u.label }));
|
||||
|
||||
function setAction(step: AutomationStep, action: AutomationAction) {
|
||||
step.kind = 'action';
|
||||
step.action = action;
|
||||
@@ -202,440 +380,273 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="inspector">
|
||||
<a-form layout="vertical" :style="{ marginBottom: 0 }">
|
||||
<template v-if="selected === 'trigger'">
|
||||
<h2>گره محرک</h2>
|
||||
<p class="hint">وقتی این اتفاق بیفتد، زنجیره از راست به چپ اجرا میشود.</p>
|
||||
<label>
|
||||
اگر
|
||||
<select v-model="trigger">
|
||||
<optgroup
|
||||
v-for="group in AUTOMATION_TRIGGER_GROUPS"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
>
|
||||
<option v-for="t in group.items" :key="t" :value="t">
|
||||
{{ AUTOMATION_TRIGGER_LABELS[t] }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="trigger === 'lead_submitted'">
|
||||
فرم
|
||||
<select v-model="sourceId">
|
||||
<option :value="null">همه فرمها</option>
|
||||
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
|
||||
برای شرط روی پاسخهای فرم، یک فرم مشخص را انتخاب کنید.
|
||||
</p>
|
||||
<label v-if="advanced || chainMode === 'branches'">
|
||||
رفتار شرطها
|
||||
<select v-model="chainMode">
|
||||
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
|
||||
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="chainMode === 'branches'" class="hint">
|
||||
شاخهها روی بوم موازی نمایش داده میشوند و هر کدام جدا بررسی میشود. شرطهای یک شاخه با «و»
|
||||
ترکیب میشوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا میشود. برای شاخهٔ تازه از
|
||||
دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
|
||||
</p>
|
||||
<p v-else-if="!advanced" class="hint">
|
||||
اتوماسیون ساده: شرطها پشتسرهم بررسی میشوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
|
||||
میکند. اتوماسیون چندشاخه در نسخهٔ حرفهای فعال است.
|
||||
</p>
|
||||
<a-form-item label="اگر" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select v-model:value="trigger" :options="triggerOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="trigger === 'lead_submitted'"
|
||||
label="فرم"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-select v-model:value="sourceIdSelect" :options="formOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="advanced || chainMode === 'branches'"
|
||||
label="رفتار شرطها"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-select v-model:value="chainMode" :options="chainModeOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="trigger === 'lead_submitted' && !sourceId"
|
||||
type="info"
|
||||
show-icon
|
||||
message="برای شرط روی پاسخ فرم، یک فرم مشخص انتخاب کنید."
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedStep && isCondition">
|
||||
<h2>گره شرط</h2>
|
||||
<p class="hint">
|
||||
{{
|
||||
chainMode === 'branches'
|
||||
? 'اگر شرط برقرار نباشد، فقط عملهای این شاخه (تا شرط بعدی) اجرا نمیشوند.'
|
||||
: 'اگر شرط برقرار نباشد، عملهای بعد از این گره اجرا نمیشوند.'
|
||||
}}
|
||||
</p>
|
||||
<label>
|
||||
فیلد
|
||||
<select
|
||||
<a-form-item label="فیلد" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
:value="selectedStep.conditionField"
|
||||
@change="
|
||||
setConditionField(
|
||||
selectedStep,
|
||||
($event.target as HTMLSelectElement).value as AutomationConditionField,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="f in conditionFields" :key="f" :value="f">
|
||||
{{ AUTOMATION_CONDITION_FIELD_LABELS[f] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="selectedStep.conditionField === 'form_field'">
|
||||
<p v-if="!selectedForm" class="hint warn">
|
||||
ابتدا در گره محرک یک فرم مشخص انتخاب کنید.
|
||||
</p>
|
||||
<label v-else>
|
||||
فیلد فرم
|
||||
<select
|
||||
:value="selectedStep.conditionKey ?? ''"
|
||||
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="" disabled>انتخاب فیلد…</option>
|
||||
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<label>
|
||||
مقایسه
|
||||
<select v-model="selectedStep.conditionOp">
|
||||
<option v-for="op in conditionOps" :key="op" :value="op">
|
||||
{{ AUTOMATION_CONDITION_OP_LABELS[op] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="selectedStep.conditionField === 'deal_amount'">
|
||||
مبلغ
|
||||
<input v-model.number="selectedStep.conditionValue" type="number" min="0" />
|
||||
</label>
|
||||
<label v-if="selectedStep.conditionField === 'ticket_priority'">
|
||||
اولویت
|
||||
<select v-model="selectedStep.conditionValue">
|
||||
<option v-for="p in ticketPriorities" :key="p.value" :value="p.value">
|
||||
{{ p.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
:options="conditionFieldOptions"
|
||||
style="width: 100%"
|
||||
@change="onConditionFieldChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="selectedStep.conditionField === 'form_field' && selectedForm"
|
||||
label="فیلد فرم"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-select
|
||||
:value="selectedStep.conditionKey ?? undefined"
|
||||
:options="formFieldOptions"
|
||||
placeholder="انتخاب فیلد…"
|
||||
style="width: 100%"
|
||||
@change="onConditionKeyChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="مقایسه" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
v-model:value="selectedStep.conditionOp"
|
||||
:options="conditionOpOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="selectedStep.conditionField === 'deal_amount'"
|
||||
label="مبلغ"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-input-number v-model:value="dealAmountValue" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="selectedStep.conditionField === 'ticket_priority'"
|
||||
label="اولویت"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="selectedStep.conditionValue"
|
||||
:options="ticketPriorities"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="selectedStep.conditionField === 'form_field' && !selectedForm"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="ابتدا در گره محرک یک فرم مشخص انتخاب کنید."
|
||||
/>
|
||||
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
|
||||
<div v-if="conditionFormField?.options?.length" class="user-picks">
|
||||
<p class="field-label">گزینهها (یک یا چند مورد)</p>
|
||||
<label v-for="o in conditionFormField.options" :key="o" class="ch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="formConditionValues(selectedStep).includes(o)"
|
||||
@change="toggleConditionValue(selectedStep, o, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ o }}
|
||||
</label>
|
||||
<p v-if="formConditionValues(selectedStep).length > 1" class="hint">
|
||||
<a-form-item
|
||||
v-if="conditionFormField?.options?.length"
|
||||
label="گزینهها"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-checkbox-group
|
||||
:value="formConditionValues(selectedStep)"
|
||||
:options="conditionFormField.options.map((o) => ({ value: o, label: o }))"
|
||||
@change="onFormOptionValuesChange"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-if="formConditionValues(selectedStep).length > 1"
|
||||
type="secondary"
|
||||
:style="{ display: 'block', marginTop: `${token.marginXXS}px` }"
|
||||
>
|
||||
{{ multiValueHint(selectedStep.conditionOp) }}
|
||||
</p>
|
||||
</div>
|
||||
<label v-else>
|
||||
مقدار
|
||||
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
|
||||
</label>
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
<a-form-item v-else label="مقدار" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-input v-model:value="formTextValue" :maxlength="200" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedStep">
|
||||
<h2>گره عمل</h2>
|
||||
<p class="hint">بعد از محرک (و شرطها)، این کار به ترتیب انجام میشود.</p>
|
||||
<label>
|
||||
آنگاه
|
||||
<select
|
||||
<a-form-item label="آنگاه" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
:value="selectedStep.action"
|
||||
@change="
|
||||
setAction(
|
||||
selectedStep,
|
||||
($event.target as HTMLSelectElement).value as AutomationAction,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="a in AUTOMATION_ACTIONS" :key="a" :value="a">
|
||||
{{ AUTOMATION_ACTION_LABELS[a] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
عنوان
|
||||
<input
|
||||
v-model="selectedStep.title"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
:options="actionOptions"
|
||||
style="width: 100%"
|
||||
@change="onActionChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عنوان" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-input
|
||||
v-model:value="selectedStep.title"
|
||||
:maxlength="200"
|
||||
placeholder="اختیاری"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
توضیح / متن پیام
|
||||
<textarea
|
||||
v-model="selectedStep.body"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیح / متن" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-textarea
|
||||
v-model:value="selectedStep.body"
|
||||
:rows="2"
|
||||
:maxlength="1000"
|
||||
placeholder="اختیاری"
|
||||
/>
|
||||
</label>
|
||||
</a-form-item>
|
||||
|
||||
<label v-if="selectedStep.action === 'create_activity'">
|
||||
نوع فعالیت
|
||||
<select v-model="selectedStep.activityType">
|
||||
<option v-for="t in activityTypes" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="selectedStep.action === 'create_activity'">
|
||||
<label v-if="activityTypeDefs.length">
|
||||
نوع سفارشی (اختیاری)
|
||||
<select v-model="selectedStep.activityTypeDefId">
|
||||
<option :value="null">— بدون نوع سفارشی —</option>
|
||||
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
تخصیص به
|
||||
<select v-model="selectedStep.assignMode">
|
||||
<option v-for="m in assignModes" :key="m" :value="m">
|
||||
{{ AUTOMATION_ASSIGN_MODE_LABELS[m] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="selectedStep.assignMode === 'user'">
|
||||
کاربر
|
||||
<select v-model="selectedStep.assigneeId">
|
||||
<option :value="null" disabled>انتخاب کاربر…</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-else-if="selectedStep.assignMode === 'team_round_robin'">
|
||||
<label>
|
||||
تیم
|
||||
<select v-model="selectedStep.assigneeTeamId">
|
||||
<option :value="null" disabled>انتخاب تیم…</option>
|
||||
<option v-for="t in teams" :key="t.id" :value="t.id">
|
||||
{{ t.name }} ({{ t.memberCount }} نفر)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="!teams.length" class="hint warn">
|
||||
هنوز تیمی تعریف نشده؛ از «تنظیمات ← تیمها و سطح دسترسی» بسازید.
|
||||
</p>
|
||||
</template>
|
||||
<div v-else-if="selectedStep.assignMode === 'users_round_robin'" class="user-picks">
|
||||
<p class="field-label">کاربران</p>
|
||||
<label v-for="u in users" :key="u.id" class="ch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
|
||||
@change="toggleUser(selectedStep, u.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ u.fullName }}
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="selectedStep.assignMode === 'team_round_robin' || selectedStep.assignMode === 'users_round_robin'"
|
||||
class="hint"
|
||||
<a-form-item label="نوع فعالیت" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
v-model:value="selectedStep.activityType"
|
||||
:options="activityTypeOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="activityTypeDefs.length"
|
||||
label="نوع سفارشی"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
هر بار نفر بعدی به نوبت انتخاب میشود تا کارها مساوی تقسیم شوند.
|
||||
</p>
|
||||
|
||||
<div class="due">
|
||||
<label>
|
||||
موعد
|
||||
<select
|
||||
:value="dueUnitOf(selectedStep)"
|
||||
@change="
|
||||
setDue(
|
||||
selectedStep,
|
||||
($event.target as HTMLSelectElement).value as DueUnit,
|
||||
dueAmountOf(selectedStep) || 1,
|
||||
)
|
||||
"
|
||||
<a-select
|
||||
:value="nullableId(selectedStep.activityTypeDefId)"
|
||||
:options="activityTypeDefOptions"
|
||||
style="width: 100%"
|
||||
@change="onActivityTypeDefChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="تخصیص به" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
v-model:value="selectedStep.assignMode"
|
||||
:options="assignModeOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="selectedStep.assignMode === 'user'"
|
||||
label="کاربر"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-select
|
||||
:value="nullableId(selectedStep.assigneeId)"
|
||||
:options="userOptions"
|
||||
placeholder="انتخاب کاربر…"
|
||||
style="width: 100%"
|
||||
@change="onAssigneeIdChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-else-if="selectedStep.assignMode === 'team_round_robin'"
|
||||
label="تیم"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-select
|
||||
:value="nullableId(selectedStep.assigneeTeamId)"
|
||||
:options="teamOptions"
|
||||
placeholder="انتخاب تیم…"
|
||||
style="width: 100%"
|
||||
@change="onAssigneeTeamIdChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="selectedStep.assignMode === 'users_round_robin'"
|
||||
label="کاربران"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-flex vertical :gap="token.marginXXS">
|
||||
<a-checkbox
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
|
||||
@change="onUserCheck(selectedStep, u.id, $event)"
|
||||
>
|
||||
<option v-for="u in dueUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="dueUnitOf(selectedStep) !== 'now'">
|
||||
تعداد
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
:value="dueAmountOf(selectedStep)"
|
||||
@input="
|
||||
setDue(
|
||||
selectedStep,
|
||||
dueUnitOf(selectedStep),
|
||||
Number(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{{ u.fullName }}
|
||||
</a-checkbox>
|
||||
</a-flex>
|
||||
</a-form-item>
|
||||
<a-form-item label="موعد" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
:value="dueUnitOf(selectedStep)"
|
||||
:options="dueUnitOptions"
|
||||
style="width: 100%"
|
||||
@change="onDueUnitChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="dueUnitOf(selectedStep) !== 'now'"
|
||||
label="تعداد"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-input-number
|
||||
:min="1"
|
||||
:value="dueAmountOf(selectedStep)"
|
||||
style="width: 100%"
|
||||
@change="onDueAmountChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="selectedStep.assignMode === 'team_round_robin' && !teams.length"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="هنوز تیمی تعریف نشده."
|
||||
/>
|
||||
</template>
|
||||
<p v-if="selectedStep.action === 'create_activity' || selectedStep.action === 'create_notification'" class="hint">
|
||||
در عنوان و متن میتوانید از
|
||||
<code v-pre>{{title}}</code>، <code v-pre>{{mobile}}</code>، <code v-pre>{{form}}</code> و
|
||||
<code v-pre>{{field.کلید}}</code> استفاده کنید.
|
||||
<template v-if="formFields.length">
|
||||
کلیدها:
|
||||
<span v-for="(f, i) in formFields" :key="f.key">
|
||||
<code>{{ f.key }}</code> ({{ f.label }})<template v-if="i < formFields.length - 1">، </template>
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<template v-if="selectedStep.action === 'create_notification'">
|
||||
<p class="field-label">نحوه ارسال</p>
|
||||
<div class="channels">
|
||||
<label
|
||||
v-for="ch in AUTOMATION_NOTIFY_CHANNELS"
|
||||
:key="ch"
|
||||
class="ch"
|
||||
>
|
||||
<input
|
||||
v-model="selectedStep.notifyChannel"
|
||||
type="radio"
|
||||
:value="ch"
|
||||
/>
|
||||
{{ AUTOMATION_NOTIFY_CHANNEL_LABELS[ch] }}
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
گیرنده
|
||||
<select v-model="selectedStep.notifyTarget">
|
||||
<option value="staff">کاربر تیم</option>
|
||||
<option value="person">مخاطب معامله / تیکت</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="selectedStep.notifyTarget !== 'person'">
|
||||
کاربر
|
||||
<select v-model="selectedStep.notifyUserId">
|
||||
<option :value="null">مسئول معامله / تیکت</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="selectedStep.notifyTarget === 'person'" class="hint">
|
||||
پیامک به موبایل مخاطب ارسال میشود. ناتیفیکیشن داخلی برای مخاطب نیست.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="selectedStep.notifyChannel === 'sms' || selectedStep.notifyChannel === 'both'"
|
||||
class="hint"
|
||||
<a-form-item label="نحوه ارسال" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-radio-group
|
||||
v-model:value="selectedStep.notifyChannel"
|
||||
:options="notifyChannelOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="گیرنده" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-select
|
||||
v-model:value="selectedStep.notifyTarget"
|
||||
:options="[
|
||||
{ value: 'staff', label: 'کاربر تیم' },
|
||||
{ value: 'person', label: 'مخاطب معامله / تیکت' },
|
||||
]"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="selectedStep.notifyTarget !== 'person'"
|
||||
label="کاربر"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
پیامک به موبایل همان کاربر ارسال میشود.
|
||||
</p>
|
||||
<a-select
|
||||
:value="nullableId(selectedStep.notifyUserId)"
|
||||
:options="notifyUserOptions"
|
||||
style="width: 100%"
|
||||
@change="onNotifyUserIdChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<label v-if="selectedStep.action === 'add_loyalty_points'">
|
||||
امتیاز
|
||||
<input v-model.number="selectedStep.points" type="number" min="1" />
|
||||
</label>
|
||||
<a-form-item
|
||||
v-if="selectedStep.action === 'add_loyalty_points'"
|
||||
label="امتیاز"
|
||||
:style="{ marginBottom: `${token.marginSM}px` }"
|
||||
>
|
||||
<a-input-number v-model:value="selectedStep.points" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<p v-else class="hint">یک گره را از بوم انتخاب کنید.</p>
|
||||
</aside>
|
||||
<a-typography-text v-else type="secondary">یک گره را از بوم انتخاب کنید.</a-typography-text>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inspector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
label,
|
||||
.field-label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.84rem;
|
||||
color: #64748b;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 0.85rem;
|
||||
}
|
||||
|
||||
.ch {
|
||||
display: inline-flex !important;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ch input {
|
||||
accent-color: #14b8a6;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint.warn {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
font-size: 0.76rem;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
padding: 0 0.25rem;
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
}
|
||||
|
||||
.user-picks {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.due {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.due input {
|
||||
width: 5.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { computed, type Component } from 'vue';
|
||||
import {
|
||||
BellOutlined,
|
||||
DeleteOutlined,
|
||||
FilterOutlined,
|
||||
FlagOutlined,
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
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,25 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { RobotOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Col as ACol,
|
||||
Form as AForm,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-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';
|
||||
|
||||
const props = defineProps<{
|
||||
askQuery: string;
|
||||
@@ -26,11 +38,23 @@ const emit = defineEmits<{
|
||||
notice: [value: { error: string; done: string }];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
const AFormItem = AForm.Item;
|
||||
const ATextarea = AInput.TextArea;
|
||||
|
||||
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 +69,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 +77,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 +296,36 @@ 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="bg-white rounded-2xl border border-slate-200/80 p-3 shadow-sm flex flex-col justify-between h-[330px] gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
|
||||
<RobotOutlined />
|
||||
</span>
|
||||
<h3 class="font-bold text-slate-800 text-base truncate">{{ brand.aiNameFa }}</h3>
|
||||
</div>
|
||||
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50/50 border border-emerald-200/60 rounded-xl p-3 text-xs text-slate-600 leading-relaxed">
|
||||
<template v-if="answer">{{ answer }}</template>
|
||||
<template v-else-if="error">{{ error }}</template>
|
||||
<template v-else>
|
||||
<strong class="text-slate-800">پیشنهاد هوشمند امروز:</strong>
|
||||
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200/90 rounded-xl p-1.5 flex items-center gap-2">
|
||||
<a-input
|
||||
:value="askQuery"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:bordered="false"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
class="flex-1"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="onTypedSubmit"
|
||||
/>
|
||||
<VoiceToTextButton
|
||||
variant="toolbar"
|
||||
hide-error
|
||||
@@ -286,322 +334,181 @@ watch(
|
||||
@recording="onVoiceRecording"
|
||||
@error="voiceError = $event"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<p class="ai-lead">چه کمکی میتونم بکنم؟</p>
|
||||
|
||||
<div class="ai-shortcuts">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
class="ai-chip"
|
||||
type="button"
|
||||
:disabled="asking"
|
||||
@click="pick(item.label)"
|
||||
>
|
||||
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="voiceBusy" class="ai-answer"><p>در حال فهمیدن دستور…</p></div>
|
||||
<div v-else-if="confirming" class="ai-answer"><p>در حال ثبت…</p></div>
|
||||
<div v-else-if="voiceDone" class="ai-answer"><p>{{ voiceDone }}</p></div>
|
||||
<p v-else-if="voiceError" class="ai-error">{{ voiceError }}</p>
|
||||
<p v-else-if="error" class="ai-error">{{ error }}</p>
|
||||
<div v-else-if="asking" class="ai-answer">
|
||||
<p>در حال پاسخ…</p>
|
||||
</div>
|
||||
|
||||
<form class="ai-input" @submit.prevent="onTypedSubmit">
|
||||
<input
|
||||
:value="askQuery"
|
||||
type="search"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
|
||||
<UiIcon name="send" :size="15" />
|
||||
</button>
|
||||
</form>
|
||||
:loading="asking || voiceBusy"
|
||||
aria-label="ارسال"
|
||||
@click="onTypedSubmit"
|
||||
>
|
||||
بپرس
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
:open="Boolean(draft && draft.intent !== 'none')"
|
||||
<div class="pt-3 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2">
|
||||
<span class="text-[11px] font-semibold text-slate-400">پیشنهادهای سریع</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="!text-[11px] !leading-4 !font-medium bg-emerald-50/70 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md disabled:opacity-50"
|
||||
:disabled="asking"
|
||||
@click="pick(item.prompt)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</section>
|
||||
|
||||
<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" title="پاسخ دستیار" destroy-on-close @cancel="closeAnswer">
|
||||
<a-typography-paragraph style="white-space: pre-wrap">
|
||||
{{ rewriteGregorianDates(answer || '') }}
|
||||
</a-typography-paragraph>
|
||||
<a-space v-if="sources?.length" wrap>
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
@click="closeAnswer"
|
||||
>
|
||||
{{ source.label }}
|
||||
<a-tag color="processing">{{ source.label }}</a-tag>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</a-space>
|
||||
<template #footer>
|
||||
<UiButton variant="primary" @click="closeAnswer">بستن</UiButton>
|
||||
<a-button type="primary" @click="closeAnswer">بستن</a-button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</article>
|
||||
</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;
|
||||
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%;
|
||||
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;
|
||||
}
|
||||
|
||||
.modal-fields textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.warn {
|
||||
display: inline-block;
|
||||
margin-inline-start: 0.35rem;
|
||||
color: #b45309;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.spark {
|
||||
color: #eab308;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ai-lead {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
background: #d1fae5;
|
||||
color: #115e59;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
}
|
||||
|
||||
.ai-shortcuts {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
color: #334155;
|
||||
font-size: 0.76rem;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.ai-chip:hover:not(:disabled) {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
}
|
||||
|
||||
.ai-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.chip-ico {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #14b8a6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-input {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.ai-input input {
|
||||
flex: 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.58rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ai-input button {
|
||||
width: 40px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ai-input button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ai-answer {
|
||||
background: #f0fdfa;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ai-answer p {
|
||||
margin: 0;
|
||||
color: #134e4a;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sources a {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
margin: 0 0 0.7rem;
|
||||
color: #b91c1c;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 '@ant-design/icons-vue';
|
||||
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 '@ant-design/icons-vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
||||
|
||||
type BirthdayItem = {
|
||||
@@ -28,6 +36,10 @@ type BirthdayMonth = {
|
||||
const data = ref<BirthdayMonth | null>(null);
|
||||
const listOpen = ref(false);
|
||||
const smsTarget = ref<BirthdayItem | null>(null);
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<number, BirthdayItem[]>();
|
||||
@@ -54,63 +66,78 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="birthdays">
|
||||
<div class="head">
|
||||
<span class="ico"><UiIcon name="gift" :size="18" /></span>
|
||||
<div>
|
||||
<strong>تولدهای {{ data.monthName }}</strong>
|
||||
<p>{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند</p>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="data" class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm">
|
||||
<a-flex align="center" wrap="wrap" gap="middle" justify="space-between">
|
||||
<a-flex align="center" gap="middle">
|
||||
<a-typography-text type="secondary"><GiftOutlined /></a-typography-text>
|
||||
<div>
|
||||
<a-typography-text strong>تولدهای {{ data.monthName }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<div class="chips">
|
||||
<RouterLink
|
||||
v-for="person in data.preview"
|
||||
:key="person.id"
|
||||
class="chip"
|
||||
:title="person.fullName"
|
||||
:to="{ name: 'person-detail', params: { id: person.id } }"
|
||||
>
|
||||
<b>{{ toFaDigits(person.day) }}</b>
|
||||
<span>{{ person.fullName }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button type="button" class="more" @click="listOpen = true">
|
||||
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
|
||||
</button>
|
||||
|
||||
<UiModal :open="listOpen" :title="`تولدهای ${data.monthName}`" @close="listOpen = false">
|
||||
<div class="days">
|
||||
<section
|
||||
v-for="[day, people] in groups"
|
||||
:key="day"
|
||||
class="day"
|
||||
:class="{ past: day < data.today, today: day === data.today }"
|
||||
<a-space wrap>
|
||||
<RouterLink
|
||||
v-for="person in data.preview"
|
||||
:key="person.id"
|
||||
:to="{ name: 'person-detail', params: { id: person.id } }"
|
||||
>
|
||||
<h3>
|
||||
<b>{{ toFaDigits(day) }}</b>
|
||||
<span>{{ data.monthName }}</span>
|
||||
<em v-if="day < data.today">گذشته</em>
|
||||
<em v-else-if="day === data.today">امروز</em>
|
||||
</h3>
|
||||
<ul>
|
||||
<li v-for="person in people" :key="person.id">
|
||||
<RouterLink :to="{ name: 'person-detail', params: { id: person.id } }" @click="listOpen = false">
|
||||
{{ person.fullName }}
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="sms"
|
||||
:disabled="!person.mobile"
|
||||
@click="openSms(person)"
|
||||
>
|
||||
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</UiModal>
|
||||
<a-tag>
|
||||
{{ toFaDigits(person.day) }} — {{ person.fullName }}
|
||||
</a-tag>
|
||||
</RouterLink>
|
||||
<a-button type="link" @click="listOpen = true">
|
||||
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<a-modal
|
||||
v-model:open="listOpen"
|
||||
:title="`تولدهای ${data.monthName}`"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
>
|
||||
<a-list item-layout="vertical" :data-source="groups">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="item[0] < data!.today">گذشته</a-tag>
|
||||
<a-tag v-else-if="item[0] === data!.today" color="processing">امروز</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ toFaDigits(item[0]) }} {{ data!.monthName }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<a-list :data-source="item[1]">
|
||||
<template #renderItem="{ item: person }">
|
||||
<a-list-item>
|
||||
<RouterLink
|
||||
:to="{ name: 'person-detail', params: { id: person.id } }"
|
||||
@click="listOpen = false"
|
||||
>
|
||||
{{ person.fullName }}
|
||||
</RouterLink>
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="link"
|
||||
:disabled="!person.mobile"
|
||||
@click="openSms(person)"
|
||||
>
|
||||
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
|
||||
<SmsComposeModal
|
||||
nest
|
||||
@@ -126,289 +153,3 @@ onMounted(async () => {
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.birthdays {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.35), transparent 42%),
|
||||
linear-gradient(100deg, var(--accent-subtle) 0%, #fff 58%, #fff7f4 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, #fff);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 18px rgba(15, 118, 110, 0.05);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
color: #e11d48;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, #fff);
|
||||
}
|
||||
|
||||
.head strong {
|
||||
display: block;
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0.05rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: 11.5rem;
|
||||
min-width: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 24%, #fff);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #134e4a;
|
||||
padding: 0.22rem 0.55rem 0.22rem 0.28rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.chip b {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.38rem;
|
||||
}
|
||||
|
||||
.chip span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 0.32rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.days {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
padding-inline-end: 0.15rem;
|
||||
}
|
||||
|
||||
.day {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.day h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--accent-subtle);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--accent) 16%, #fff);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.day h3 b {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.day em {
|
||||
margin-inline-start: auto;
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.day.today h3 {
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
|
||||
}
|
||||
|
||||
.day.today em {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.day.past {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.day ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.day li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.62rem 0.85rem;
|
||||
border-top: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.day li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.day a {
|
||||
flex: 1 1 auto;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.day a:hover {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.sms {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, #fff);
|
||||
background: #fff;
|
||||
color: var(--accent-deep);
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sms:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.sms:disabled {
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.err,
|
||||
.ok {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.err {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.birthdays {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.chips {
|
||||
grid-column: 1 / -1;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.day li {
|
||||
gap: 0.45rem;
|
||||
padding-inline: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSeriesPointDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
series: HomeDashboardSeriesPointDto[];
|
||||
}>();
|
||||
|
||||
const points = computed(() => props.series);
|
||||
const hasData = computed(() => points.value.some((p) => p.value > 0));
|
||||
const total = computed(() => points.value.reduce((a, p) => a + p.value, 0));
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (params) => {
|
||||
const row = Array.isArray(params) ? params[0] : params;
|
||||
const point = points.value[row.dataIndex ?? 0];
|
||||
if (!point) return '';
|
||||
return `${point.monthLabel}<br/><strong>${toFaDigits(point.value)}</strong> مشتری جدید`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: points.value.map((p) => p.monthLabel),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
...(hasData.value ? {} : { max: 1 }),
|
||||
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: points.value.map((p) => p.value),
|
||||
barMaxWidth: 32,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#34d399' },
|
||||
{ offset: 1, color: '#059669' },
|
||||
],
|
||||
},
|
||||
borderRadius: [8, 8, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#64748b',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
fontSize: 11,
|
||||
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
data: points.value.map((p) => p.value),
|
||||
smooth: 0.35,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#0f766e', width: 2, opacity: 0.55 },
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">روند رشد مشتریان</h3>
|
||||
<span v-if="hasData" class="text-xs font-bold bg-emerald-50 text-emerald-700 px-2.5 py-1 rounded-lg">
|
||||
{{ toFaDigits(total) }} در ۱۲ ماه
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-56">
|
||||
<UiChart :option="option" height="224px" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSalesSummaryDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
summary: HomeDashboardSalesSummaryDto;
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const slices = computed(() => [
|
||||
{ name: 'باز', value: props.summary.openCount, color: '#3b82f6' },
|
||||
{ name: 'برنده', value: props.summary.wonCount, color: '#10b981' },
|
||||
{ name: 'باخته', value: props.summary.lostCount, color: '#f43f5e' },
|
||||
]);
|
||||
|
||||
const total = computed(() => slices.value.reduce((a, s) => a + s.value, 0));
|
||||
const hasData = computed(() => total.value > 0);
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
color: slices.value.map((s) => s.color),
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (p) => {
|
||||
const item = Array.isArray(p) ? p[0] : p;
|
||||
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['58%', '82%'],
|
||||
center: ['50%', '50%'],
|
||||
silent: !hasData.value,
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data: hasData.value
|
||||
? slices.value.map((s) => ({ name: s.name, value: s.value }))
|
||||
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="!m-0 !leading-6 font-bold text-slate-800 text-base">وضعیت معاملات</h3>
|
||||
<span class="text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-200/60 px-2.5 py-1 rounded-lg">
|
||||
نرخ برد {{ toFaDigits(summary.winRate) }}٪
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div class="w-full sm:w-[48%] sm:max-w-[240px] shrink-0">
|
||||
<div class="relative w-full aspect-square">
|
||||
<UiChart :option="option" height="100%" />
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-2xl font-black text-slate-800 leading-none">{{ toFaDigits(total) }}</span>
|
||||
<span class="text-xs text-slate-400 mt-1">معامله کل</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:flex-1 min-w-0 space-y-2.5">
|
||||
<div class="p-3 rounded-xl bg-slate-50/80 border border-slate-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-slate-700">باز</div>
|
||||
<div class="text-[11px] text-slate-400">در جریان</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800">{{ toFaDigits(summary.openCount) }}</div>
|
||||
<div class="text-xs text-slate-500">{{ formatAmount(summary.openAmount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-emerald-900">برنده (۱۲ ماه)</div>
|
||||
<div class="text-[11px] text-emerald-600/80">تکمیل موفق</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-emerald-700">{{ toFaDigits(summary.wonCount) }}</div>
|
||||
<div class="text-xs text-emerald-600">{{ formatAmount(summary.wonAmount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-rose-50/40 border border-rose-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-rose-900">باخته (۱۲ ماه)</div>
|
||||
<div class="text-[11px] text-rose-500">لغو یا عدم توافق</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-rose-600">{{ toFaDigits(summary.lostCount) }}</div>
|
||||
<div class="text-xs text-rose-500">{{ formatAmount(summary.lostAmount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty, Progress as AProgress } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardFunnelStageDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
funnel: HomeDashboardFunnelStageDto[];
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.funnel);
|
||||
const hasData = computed(() => rows.value.some((r) => r.count > 0));
|
||||
const openTotal = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
|
||||
|
||||
function share(count: number) {
|
||||
if (!openTotal.value) return 0;
|
||||
return Math.round((count / openTotal.value) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">قیف فروش باز</h3>
|
||||
<span v-if="hasData" class="text-xs font-semibold text-slate-500">
|
||||
{{ toFaDigits(openTotal) }} معامله باز
|
||||
</span>
|
||||
</div>
|
||||
<a-empty v-if="!hasData" description="معاملهٔ بازی در مسیر فروش نیست" />
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="row in rows" :key="row.stageId ?? row.stageName">
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<span class="text-xs font-bold text-slate-700">{{ row.stageName }}</span>
|
||||
<span class="text-xs font-semibold text-slate-500">
|
||||
{{ toFaDigits(row.count) }} · {{ toFaDigits(share(row.count)) }}٪
|
||||
</span>
|
||||
</div>
|
||||
<a-progress :percent="share(row.count)" :show-info="false" stroke-color="#10b981" />
|
||||
<div class="text-[11px] text-slate-400">{{ formatAmount(row.amount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
||||
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
|
||||
import { SearchOutlined, SendOutlined } from '@ant-design/icons-vue';
|
||||
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,218 @@ 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 relative overflow-hidden rounded-2xl border border-emerald-300/50 shadow-sm">
|
||||
<!-- base gradient -->
|
||||
<div class="home-hero__gradient pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- geometric pattern -->
|
||||
<div class="home-hero__pattern pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- soft orbs -->
|
||||
<div
|
||||
class="pointer-events-none absolute -left-20 -top-24 h-52 w-52 rounded-full bg-emerald-400/20 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-16 -bottom-20 h-48 w-48 rounded-full bg-teal-500/15 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="hero-body">
|
||||
<div class="hero-greet">
|
||||
<span class="hero-avatar">
|
||||
<img :src="auth.avatarSrc" :alt="firstName" width="72" height="72" />
|
||||
<i class="online" />
|
||||
</span>
|
||||
<div class="greet-text">
|
||||
<h1>{{ greeting }} {{ firstName }} 👋</h1>
|
||||
<p v-if="chips.length">{{ brand.nameFa }} اینا رو برات پیدا کرده</p>
|
||||
<p v-else>از {{ brand.nameFa }} بپرس یا کار امروز را شروع کن</p>
|
||||
<div
|
||||
class="relative flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<div class="home-hero__identity relative shrink-0" :aria-label="firstName">
|
||||
<div class="home-hero__frame">
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
:src="auth.avatarSrc"
|
||||
:alt="firstName"
|
||||
class="home-hero__photo"
|
||||
/>
|
||||
<span v-else class="home-hero__monogram" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] font-semibold tracking-wide text-emerald-700/80">
|
||||
{{ brand.nameFa }}
|
||||
</p>
|
||||
<a-typography-title
|
||||
:level="4"
|
||||
class="!m-0 !mt-0.5 !text-slate-800 !text-lg sm:!text-xl !font-extrabold !leading-7 truncate"
|
||||
>
|
||||
{{ greeting }}، {{ firstName }}
|
||||
</a-typography-title>
|
||||
<a-typography-text class="!text-slate-500 !text-sm !mt-0.5 block">
|
||||
از دادهها بپرسید یا کار امروز را از همینجا شروع کنید
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chips.length" class="hero-chips">
|
||||
<RouterLink
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
class="chip"
|
||||
:class="`chip-${chip.tone}`"
|
||||
:to="chip.link"
|
||||
<div
|
||||
class="flex items-center gap-2 w-full lg:max-w-xl bg-white border border-white/80 rounded-2xl px-3 py-2 shadow-md shadow-emerald-900/5 focus-within:border-emerald-300 focus-within:ring-2 focus-within:ring-emerald-200/70 transition"
|
||||
>
|
||||
<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
|
||||
class="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="hero-ask-wrap">
|
||||
<form class="hero-ask" @submit.prevent="emit('ask')">
|
||||
<UiIcon name="sparkle" :size="18" class="ask-ico" />
|
||||
<input
|
||||
<a-input
|
||||
id="home-hero-ask"
|
||||
:value="askQuery"
|
||||
type="search"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:disabled="asking"
|
||||
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
|
||||
:bordered="false"
|
||||
class="flex-1 !bg-transparent"
|
||||
@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="hidden sm:inline-flex items-center text-[11px] font-sans text-slate-400 bg-slate-50 px-2 py-1 rounded-lg border border-slate-200 shrink-0"
|
||||
>
|
||||
{{ shortcutLabel }}
|
||||
</kbd>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="round"
|
||||
:disabled="asking"
|
||||
:loading="asking"
|
||||
aria-label="ارسال"
|
||||
@click="emit('ask')"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
<span class="hidden sm:inline">بپرس</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 {
|
||||
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);
|
||||
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%),
|
||||
.home-hero__gradient {
|
||||
background:
|
||||
linear-gradient(
|
||||
105deg,
|
||||
#0b3d3a 0%,
|
||||
#115e59 22%,
|
||||
#0f766e 48%,
|
||||
#134e4a 72%,
|
||||
#0f3f3c 100%
|
||||
115deg,
|
||||
#ecfdf5 0%,
|
||||
#d1fae5 28%,
|
||||
#ccfbf1 55%,
|
||||
#f0fdfa 78%,
|
||||
#ffffff 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 16px 36px rgba(15, 118, 110, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
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);
|
||||
pointer-events: none;
|
||||
.home-hero__pattern {
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.28) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(20, 184, 166, 0.08) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(20, 184, 166, 0.08) 50%,
|
||||
rgba(20, 184, 166, 0.08) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size:
|
||||
18px 18px,
|
||||
28px 28px;
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.hero-mist {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 40% 35% at 25% 20%, rgba(255, 255, 255, 0.14) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 35% 40% at 70% 75%, rgba(204, 251, 241, 0.12) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 45% at 50% 50%, rgba(15, 118, 110, 0.35) 0%, transparent 75%);
|
||||
pointer-events: none;
|
||||
.home-hero__identity {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.hero-body,
|
||||
.hero-bot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.hero-greet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.greet-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.hero-avatar img {
|
||||
.home-hero__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
padding: 2px;
|
||||
background: linear-gradient(145deg, #34d399 0%, #0d9488 48%, #059669 100%);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.85),
|
||||
0 8px 18px -10px rgba(4, 120, 87, 0.55);
|
||||
}
|
||||
|
||||
.home-hero__photo,
|
||||
.home-hero__monogram {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(1rem - 2px);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.online {
|
||||
.home-hero__monogram {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: #ecfdf5;
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.22), transparent 45%),
|
||||
linear-gradient(160deg, #10b981 0%, #0f766e 100%);
|
||||
letter-spacing: -0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.home-hero__online {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
inset-inline-start: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
left: -1px;
|
||||
bottom: -1px;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
background: #10b981;
|
||||
border: 2px solid #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.greet-text h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.greet-text p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
max-width: min(580px, 100%);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
display: grid;
|
||||
gap: 0.04rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip-text strong {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-text small {
|
||||
font-size: 0.6rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-urgent .chip-ico {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.chip-warning .chip-ico {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.chip-success .chip-ico,
|
||||
.chip-info .chip-ico {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.hero-ask-wrap {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-width: min(520px, 100%);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.45rem 0.45rem 0.9rem;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.ask-ico {
|
||||
color: #14b8a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-ask input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-ask input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.hero-ask kbd {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.14rem 0.38rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.ask-send:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.hero-ask-status {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hero-ask-status.err {
|
||||
background: rgba(185, 28, 28, 0.28);
|
||||
}
|
||||
|
||||
.hero-bot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* در RTL ستون ربات سمت چپ است؛ end = لبه بیرونی چپ */
|
||||
justify-self: end;
|
||||
align-self: center;
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 0.35rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-ask kbd {
|
||||
display: none;
|
||||
}
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,125 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
FundOutlined,
|
||||
RiseOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import type { Component } from 'vue';
|
||||
import type { HomeDashboardKpiDto } from '@mento/shared';
|
||||
import { currencySuffix } from '@/lib/money';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
kpis: HomeDashboardKpiDto[];
|
||||
currency: string;
|
||||
formatValue: (key: string, value: number) => string;
|
||||
formatChange: (value: number | null) => string;
|
||||
}>();
|
||||
|
||||
const icons: Record<string, 'calendar' | 'users' | 'briefcase' | 'check'> = {
|
||||
sales: 'calendar',
|
||||
customers: 'users',
|
||||
'open-deals': 'briefcase',
|
||||
followups: 'check',
|
||||
function salesAmount(value: number) {
|
||||
return new Intl.NumberFormat('fa-IR').format(value);
|
||||
}
|
||||
|
||||
function salesUnit() {
|
||||
return currencySuffix(props.currency);
|
||||
}
|
||||
|
||||
const icons: Record<string, Component> = {
|
||||
sales: FundOutlined,
|
||||
customers: TeamOutlined,
|
||||
'open-deals': CalendarOutlined,
|
||||
followups: CheckCircleOutlined,
|
||||
'win-rate': RiseOutlined,
|
||||
overdue: WarningOutlined,
|
||||
};
|
||||
|
||||
const iconTones: Record<string, string> = {
|
||||
sales: '#14b8a6',
|
||||
customers: '#38bdf8',
|
||||
'open-deals': '#fb923c',
|
||||
followups: '#a78bfa',
|
||||
};
|
||||
function pillClass(kpi: HomeDashboardKpiDto) {
|
||||
if (kpi.key === 'overdue') return 'bg-rose-100/70 text-rose-600';
|
||||
if (kpi.changePercent == null) return 'text-slate-400';
|
||||
return kpi.changePercent < 0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kpi-row">
|
||||
<article v-for="kpi in kpis" :key="kpi.key" class="kpi-card">
|
||||
<div class="kpi-icon" :style="{ color: iconTones[kpi.key] ?? '#14b8a6' }">
|
||||
<UiIcon :name="icons[kpi.key] ?? 'chart'" :size="20" />
|
||||
</div>
|
||||
<div class="kpi-text">
|
||||
<p class="kpi-label">{{ kpi.label }}</p>
|
||||
<strong class="kpi-value">{{ formatValue(kpi.key, kpi.value) }}</strong>
|
||||
<section class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<div
|
||||
v-for="kpi in kpis"
|
||||
:key="kpi.key"
|
||||
class="rounded-2xl p-4 border shadow-sm flex flex-col justify-between transition duration-200 hover:-translate-y-0.5"
|
||||
:class="
|
||||
kpi.key === 'overdue'
|
||||
? 'border-rose-200/70 bg-gradient-to-b from-white to-rose-50/20'
|
||||
: 'bg-white border-slate-200/80'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-semibold" :class="kpi.key === 'overdue' ? 'text-rose-700' : 'text-slate-500'">
|
||||
{{ kpi.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="kpi.changePercent != null"
|
||||
class="kpi-change"
|
||||
:class="{ down: kpi.changePercent < 0 }"
|
||||
class="w-8 h-8 rounded-xl flex items-center justify-center"
|
||||
:class="kpi.key === 'overdue' ? 'bg-rose-100/80 text-rose-600' : 'bg-emerald-50 text-emerald-600'"
|
||||
>
|
||||
<span class="arrow">{{ kpi.changePercent >= 0 ? '↑' : '↓' }}</span>
|
||||
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
|
||||
<component :is="icons[kpi.key] ?? FundOutlined" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="!font-bold !text-[24px] !leading-8 tracking-tight"
|
||||
:class="kpi.key === 'overdue' ? 'text-rose-600' : 'text-slate-800'"
|
||||
>
|
||||
<template v-if="kpi.key === 'sales'">
|
||||
<span>{{ salesAmount(kpi.value) }}</span><span class="text-[16px] leading-6 font-semibold ms-1.5">{{ salesUnit() }}</span>
|
||||
</template>
|
||||
<template v-else>{{ formatValue(kpi.key, kpi.value) }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="kpi.changePercent != null"
|
||||
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md"
|
||||
:class="pillClass(kpi)"
|
||||
>
|
||||
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="kpi.key === 'overdue'"
|
||||
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md bg-rose-100/70 text-rose-600"
|
||||
>
|
||||
نیازمند اقدام فوری
|
||||
</div>
|
||||
<div v-else class="mt-2.5 text-[11px] font-medium text-slate-400">
|
||||
{{ kpi.changeLabel || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.1rem 1.15rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.18rem;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.kpi-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.45rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-change.down {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSliceDto } from '@mento/shared';
|
||||
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
reasons: HomeDashboardSliceDto[];
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.reasons);
|
||||
const hasData = computed(() => rows.value.some((r) => r.count > 0));
|
||||
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0));
|
||||
|
||||
function share(count: number) {
|
||||
if (!total.value) return 0;
|
||||
return Math.round((count / total.value) * 100);
|
||||
}
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
color: CHART_PALETTE,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (p) => {
|
||||
const item = Array.isArray(p) ? p[0] : p;
|
||||
const row = rows.value.find((r) => r.label === item.name);
|
||||
const amount =
|
||||
row?.amount != null ? `<br/>${props.formatAmount(row.amount)}` : '';
|
||||
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪${amount}`;
|
||||
},
|
||||
},
|
||||
legend: { show: false },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['58%', '78%'],
|
||||
center: ['50%', '50%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: rows.value.map((r) => ({ name: r.label, value: r.count })),
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-slate-800 text-base">دلایل باخت</h3>
|
||||
<span v-if="hasData" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
|
||||
</div>
|
||||
<a-empty v-if="!hasData" description="معاملهٔ باختهای ثبت نشده" />
|
||||
<template v-else>
|
||||
<div class="relative w-36 h-36 mx-auto">
|
||||
<UiChart :option="option" height="144px" />
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
|
||||
<span class="text-[11px] text-slate-400">معامله باخته</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div
|
||||
v-for="(item, index) in rows"
|
||||
:key="item.label"
|
||||
class="rounded-xl border border-slate-200/70 bg-slate-50 px-2.5 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
|
||||
></span>
|
||||
<span class="text-[11px] text-slate-600 truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="text-sm font-black text-slate-800 mt-1">{{ toFaDigits(share(item.count)) }}٪</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,8 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import {
|
||||
FundOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { PipelineBoardDto } from '@mento/shared';
|
||||
import {
|
||||
PIPELINE_ACCENT,
|
||||
type PipelineAccent,
|
||||
} from '@/lib/pipeline-board';
|
||||
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -10,41 +19,100 @@ const props = defineProps<{
|
||||
formatMoney: (amount: number, currency: string) => string;
|
||||
}>();
|
||||
|
||||
type Card = { id: string; name: string; amount: string; when: string };
|
||||
type Col = { id: string; name: string; count: number; cards: Card[] };
|
||||
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'blue', 'orange'];
|
||||
|
||||
function relativeWhen(iso: string | null | undefined) {
|
||||
if (!iso) return '';
|
||||
type Card = {
|
||||
id: string;
|
||||
name: string;
|
||||
initial: string;
|
||||
amount: string;
|
||||
when: string;
|
||||
isToday: boolean;
|
||||
};
|
||||
|
||||
type Col = {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
totalLabel: string;
|
||||
accent: PipelineAccent;
|
||||
cards: Card[];
|
||||
};
|
||||
|
||||
function relativeWhen(iso: string | null | undefined): { label: string; isToday: boolean } {
|
||||
if (!iso) return { label: '', isToday: false };
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days <= 0) return 'امروز';
|
||||
if (days === 1) return 'دیروز';
|
||||
return `${toFaDigits(days)} روز پیش`;
|
||||
if (days <= 0) return { label: 'امروز', isToday: true };
|
||||
if (days === 1) return { label: 'دیروز', isToday: false };
|
||||
return { label: `${toFaDigits(days)} روز پیش`, isToday: false };
|
||||
}
|
||||
|
||||
function initialOf(name: string | undefined | null) {
|
||||
const t = (name ?? '').trim();
|
||||
return t ? t.slice(0, 1) : '؟';
|
||||
}
|
||||
|
||||
const cols = computed<Col[]>(() => {
|
||||
const stages = props.board?.stages ?? [];
|
||||
const hasDeals = stages.some((s) => s.deals.length);
|
||||
if (!hasDeals) return [];
|
||||
return stages.slice(0, 3).map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
count: stage.deals.length,
|
||||
cards: stage.deals.slice(0, 2).map((deal) => ({
|
||||
id: deal.id,
|
||||
name: deal.company?.name || deal.person?.fullName || deal.title,
|
||||
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
|
||||
when: relativeWhen(deal.updatedAt || deal.createdAt),
|
||||
})),
|
||||
}));
|
||||
|
||||
return stages.slice(0, 3).map((stage, index) => {
|
||||
const accent = ACCENT_CYCLE[index % ACCENT_CYCLE.length]!;
|
||||
const currency = stage.deals[0]?.currency ?? 'IRR';
|
||||
return {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
count: stage.deals.length,
|
||||
totalLabel:
|
||||
stage.totalAmount > 0 ? props.formatMoney(stage.totalAmount, currency) : '',
|
||||
accent,
|
||||
cards: stage.deals.slice(0, 2).map((deal) => {
|
||||
const name = deal.company?.name || deal.person?.fullName || deal.title;
|
||||
const when = relativeWhen(deal.updatedAt || deal.createdAt);
|
||||
return {
|
||||
id: deal.id,
|
||||
name,
|
||||
initial: initialOf(name),
|
||||
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
|
||||
when: when.label,
|
||||
isToday: when.isToday,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function accentOf(key: PipelineAccent) {
|
||||
return PIPELINE_ACCENT[key];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>جریان فروش</h2>
|
||||
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-5 sm:p-6 shadow-sm">
|
||||
<header class="flex items-center justify-between gap-3 mb-5">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FundOutlined />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-bold text-slate-800 text-base m-0">جریان فروش</h3>
|
||||
<p class="text-xs text-slate-400 m-0 mt-0.5">
|
||||
نمای سریع معاملات در مراحل کلیدی
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="{ name: 'pipeline' }"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-3 py-1.5 rounded-xl transition-colors"
|
||||
>
|
||||
<span>نمایش همه</span>
|
||||
<LeftOutlined />
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
<HomePanelEmpty
|
||||
@@ -54,132 +122,85 @@ const cols = computed<Col[]>(() => {
|
||||
action-label="معامله جدید"
|
||||
:action-to="{ name: 'deal-new' }"
|
||||
/>
|
||||
<div v-else class="mini-pipeline">
|
||||
<div v-for="col in cols" :key="col.id" class="pipe-col">
|
||||
<header>
|
||||
<strong>{{ col.name }} ({{ toFaDigits(col.count) }})</strong>
|
||||
</header>
|
||||
<div class="pipe-cards">
|
||||
<RouterLink
|
||||
v-for="card in col.cards"
|
||||
:key="card.id"
|
||||
class="pipe-card"
|
||||
:to="{ name: 'deal-detail', params: { id: card.id } }"
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
|
||||
<article
|
||||
v-for="col in cols"
|
||||
:key="col.id"
|
||||
class="rounded-2xl border border-slate-200/70 bg-slate-50/90 p-4 flex flex-col gap-3 min-w-0"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 pb-2 border-b border-slate-200/80">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
:style="{ background: accentOf(col.accent).solid }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h4 class="font-bold text-sm text-slate-700 m-0 truncate">{{ col.name }}</h4>
|
||||
</div>
|
||||
<p v-if="col.totalLabel" class="text-[11px] text-slate-400 m-0 mt-1 truncate">
|
||||
مجموع {{ col.totalLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 bg-white border border-slate-200 text-slate-700 text-xs font-bold px-2.5 py-0.5 rounded-full"
|
||||
>
|
||||
<span class="deal-mark">{{ card.name.slice(0, 1) }}</span>
|
||||
<div>
|
||||
<strong>{{ card.name }}</strong>
|
||||
<span v-if="card.amount">{{ card.amount }}</span>
|
||||
<em>{{ card.when }}</em>
|
||||
{{ toFaDigits(col.count) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 flex-1">
|
||||
<RouterLink
|
||||
v-for="item in col.cards"
|
||||
:key="item.id"
|
||||
:to="{ name: 'deal-detail', params: { id: item.id } }"
|
||||
class="group bg-white p-3 rounded-xl border border-slate-200/80 shadow-sm hover:border-emerald-400 hover:shadow transition-all flex items-center gap-3 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-9 h-9 rounded-full font-bold text-xs flex items-center justify-center shrink-0 border"
|
||||
:style="{
|
||||
background: accentOf(col.accent).soft,
|
||||
color: accentOf(col.accent).text,
|
||||
borderColor: accentOf(col.accent).border,
|
||||
}"
|
||||
>
|
||||
{{ item.initial }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-bold text-sm text-slate-800 truncate">{{ item.name }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5 truncate">
|
||||
<template v-if="item.amount">
|
||||
<span class="font-medium">{{ item.amount }}</span>
|
||||
<span v-if="item.when" class="text-slate-300 mx-1">·</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="item.when"
|
||||
:class="item.isToday ? 'text-emerald-600 font-semibold' : 'text-slate-400'"
|
||||
>
|
||||
{{ item.when }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div
|
||||
v-if="!col.cards.length"
|
||||
class="border border-dashed border-slate-200 rounded-xl px-3 py-5 text-center text-xs text-slate-400"
|
||||
>
|
||||
معاملهای در این مرحله نیست
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-else-if="col.cards.length < 2"
|
||||
:to="{ name: 'deal-new', query: { stageId: col.id } }"
|
||||
class="mt-auto border border-dashed border-slate-200 hover:border-emerald-300 hover:bg-white rounded-xl px-3 py-3 text-center text-xs text-slate-400 hover:text-emerald-700 transition-colors inline-flex items-center justify-center gap-1"
|
||||
>
|
||||
<PlusOutlined />
|
||||
افزودن معامله
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #14b8a6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mini-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
gap: 0.65rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pipe-col > header {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pipe-cards {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pipe-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
color: inherit;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pipe-card div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pipe-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pipe-card span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.pipe-card em {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.66rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.deal-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.mini-pipeline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import { Button as AButton, Empty as AEmpty, Typography } from 'ant-design-vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -8,70 +9,19 @@ defineProps<{
|
||||
actionLabel: string;
|
||||
actionTo: RouteLocationRaw;
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-card">
|
||||
<div class="art" aria-hidden="true">
|
||||
<svg viewBox="0 0 168 148" fill="none">
|
||||
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
|
||||
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
|
||||
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
|
||||
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
|
||||
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<strong>{{ title }}</strong>
|
||||
<p>{{ description }}</p>
|
||||
<RouterLink class="cta" :to="actionTo">{{ actionLabel }}</RouterLink>
|
||||
</div>
|
||||
<a-empty :description="null">
|
||||
<template #description>
|
||||
<a-typography-text strong>{{ title }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">{{ description }}</a-typography-text>
|
||||
</template>
|
||||
<RouterLink :to="actionTo">
|
||||
<a-button type="primary">{{ actionLabel }}</a-button>
|
||||
</RouterLink>
|
||||
</a-empty>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.4rem 0.2rem 0.15rem;
|
||||
}
|
||||
|
||||
.art svg {
|
||||
width: 92px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 0.88rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 16rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cta {
|
||||
margin-top: 0.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 0 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, type Component } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
Tag as ATag,
|
||||
Timeline as ATimeline,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
|
||||
import type { ActivityDto } from '@mento/shared';
|
||||
|
||||
@@ -10,16 +22,21 @@ const props = defineProps<{
|
||||
formatWhen: (value: string | null) => string;
|
||||
}>();
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const ATimelineItem = ATimeline.Item;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
title: string;
|
||||
when: string;
|
||||
typeLabel: string;
|
||||
type: string;
|
||||
tone: 'done' | 'pending' | 'held' | 'late';
|
||||
label: string;
|
||||
};
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
@@ -31,23 +48,50 @@ function activityStatus(act: ActivityDto) {
|
||||
if (act.dueAt && new Date(act.dueAt) < new Date()) {
|
||||
return { label: 'عقبافتاده', tone: 'late' as const };
|
||||
}
|
||||
return { label: 'در انتظار پاسخ', tone: 'pending' as const };
|
||||
return { label: 'در انتظار', tone: 'pending' as const };
|
||||
}
|
||||
|
||||
function activityIcon(type: string) {
|
||||
if (type === 'call') return 'phone' as const;
|
||||
if (type === 'meeting') return 'calendar' as const;
|
||||
if (type === 'task') return 'check' as const;
|
||||
return 'mail' as const;
|
||||
function activityIcon(type: string): Component {
|
||||
if (type === 'call') return PhoneOutlined;
|
||||
if (type === 'meeting') return CalendarOutlined;
|
||||
if (type === 'task') return CheckOutlined;
|
||||
return MailOutlined;
|
||||
}
|
||||
|
||||
function tagColor(tone: Row['tone']) {
|
||||
if (tone === 'late') return 'error';
|
||||
if (tone === 'done' || tone === 'held') return 'success';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
function timelineColor(tone: Row['tone']) {
|
||||
if (tone === 'late') return 'red';
|
||||
if (tone === 'done' || tone === 'held') return 'green';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
function typeBg(type: string) {
|
||||
if (type === 'call') return token.value.colorInfoBg;
|
||||
if (type === 'meeting') return token.value.colorWarningBg;
|
||||
if (type === 'task') return token.value.colorSuccessBg;
|
||||
return token.value.colorFillAlter;
|
||||
}
|
||||
|
||||
function typeFg(type: string) {
|
||||
if (type === 'call') return token.value.colorInfo;
|
||||
if (type === 'meeting') return token.value.colorWarning;
|
||||
if (type === 'task') return token.value.colorSuccess;
|
||||
return token.value.colorTextSecondary;
|
||||
}
|
||||
|
||||
const rows = computed<Row[]>(() =>
|
||||
props.activities.slice(0, 3).map((act) => {
|
||||
props.activities.slice(0, 5).map((act) => {
|
||||
const st = activityStatus(act);
|
||||
return {
|
||||
id: act.id,
|
||||
title: act.title,
|
||||
when: `${typeLabel[act.type] ?? act.type} · ${props.formatWhen(act.dueAt || act.createdAt)}`,
|
||||
when: props.formatWhen(act.dueAt || act.createdAt),
|
||||
typeLabel: typeLabelMap[act.type] ?? act.type,
|
||||
type: act.type,
|
||||
tone: st.tone,
|
||||
label: st.label,
|
||||
@@ -57,11 +101,16 @@ const rows = computed<Row[]>(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>فعالیتهای اخیر</h2>
|
||||
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink>
|
||||
</header>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-bold text-slate-800 text-base">فعالیتهای اخیر</h3>
|
||||
<a-tag v-if="rows.length" :bordered="false">{{ rows.length }}</a-tag>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'activities' }" class="text-sm font-bold text-emerald-600">
|
||||
مشاهده همه
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<HomePanelEmpty
|
||||
v-if="!rows.length"
|
||||
@@ -70,117 +119,44 @@ const rows = computed<Row[]>(() =>
|
||||
action-label="فعالیت جدید"
|
||||
:action-to="{ name: 'activity-new' }"
|
||||
/>
|
||||
<ul v-else class="activity-list">
|
||||
<li v-for="act in rows" :key="act.id">
|
||||
<span class="act-icon">
|
||||
<UiIcon :name="activityIcon(act.type)" :size="16" />
|
||||
</span>
|
||||
<div class="act-body">
|
||||
<strong>{{ act.title }}</strong>
|
||||
<span>{{ act.when }}</span>
|
||||
</div>
|
||||
<span class="status-pill" :class="act.tone">{{ act.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<a-timeline v-else>
|
||||
<a-timeline-item v-for="item in rows" :key="item.id" :color="timelineColor(item.tone)">
|
||||
<a-flex
|
||||
align="flex-start"
|
||||
justify="space-between"
|
||||
:gap="token.marginSM"
|
||||
:style="{
|
||||
padding: `${token.paddingXS}px ${token.paddingSM}px`,
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
background: token.colorFillAlter,
|
||||
minWidth: 0,
|
||||
}"
|
||||
>
|
||||
<a-flex :gap="token.marginSM" :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-flex
|
||||
align="center"
|
||||
justify="center"
|
||||
:style="{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
background: typeBg(item.type),
|
||||
color: typeFg(item.type),
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<component :is="activityIcon(item.type)" />
|
||||
</a-flex>
|
||||
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text strong ellipsis>{{ item.title }}</a-typography-text>
|
||||
<a-typography-text type="secondary" ellipsis>
|
||||
{{ item.typeLabel }} · {{ item.when }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<a-tag :color="tagColor(item.tone)" style="flex-shrink: 0">{{ item.label }}</a-tag>
|
||||
</a-flex>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #14b8a6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.activity-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.act-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #14b8a6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.act-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.act-body strong {
|
||||
font-size: 0.84rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.act-body span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill.done,
|
||||
.status-pill.held {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-pill.pending {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-pill.late {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||
import type { HomeDashboardTrendPointDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
@@ -10,26 +11,57 @@ const props = defineProps<{
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const trend = computed(() => props.trend);
|
||||
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
|
||||
const change = computed(() => props.changePercent);
|
||||
type PeriodMonths = 1 | 3 | 6 | 12;
|
||||
|
||||
const period = ref<PeriodMonths>(6);
|
||||
|
||||
const periodOptions = [
|
||||
{ value: 1, label: 'این ماه' },
|
||||
{ value: 3, label: '۳ ماه' },
|
||||
{ value: 6, label: '۶ ماه' },
|
||||
{ value: 12, label: '۱۲ ماه' },
|
||||
];
|
||||
|
||||
const filteredTrend = computed(() => {
|
||||
const all = props.trend;
|
||||
if (!all.length) return [];
|
||||
const n = period.value;
|
||||
return all.slice(-Math.min(n, all.length));
|
||||
});
|
||||
|
||||
const hasTrend = computed(() => filteredTrend.value.some((p) => p.amount > 0));
|
||||
|
||||
/** تغییر آخرین ماه نسبت به ماه قبلِ همان بازه */
|
||||
const periodChange = computed(() => {
|
||||
const rows = filteredTrend.value;
|
||||
if (rows.length < 2) return props.changePercent;
|
||||
const last = rows[rows.length - 1]!.amount;
|
||||
const prev = rows[rows.length - 2]!.amount;
|
||||
if (prev === 0) return last === 0 ? 0 : 100;
|
||||
return Math.round(((last - prev) / Math.abs(prev)) * 1000) / 10;
|
||||
});
|
||||
|
||||
const changeHint = computed(() => {
|
||||
if (period.value === 1) return 'نسبت به ماه قبل';
|
||||
return 'آخرین ماه نسبت به ماه قبل';
|
||||
});
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
|
||||
animationDuration: 450,
|
||||
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (params) => {
|
||||
const row = Array.isArray(params) ? params[0] : params;
|
||||
const point = trend.value[row.dataIndex ?? 0];
|
||||
const point = filteredTrend.value[row.dataIndex ?? 0];
|
||||
if (!point) return '';
|
||||
return `${point.monthLabel}<br/>${props.formatAmount(point.amount)}`;
|
||||
return `${point.monthLabel}<br/><strong>${props.formatAmount(point.amount)}</strong>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: trend.value.map((p) => p.monthLabel),
|
||||
data: filteredTrend.value.map((p) => p.monthLabel),
|
||||
boundaryGap: false,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -43,18 +75,19 @@ const option = computed<UiChartOption>(() => ({
|
||||
type: 'value',
|
||||
min: 0,
|
||||
...(hasTrend.value ? {} : { max: 1 }),
|
||||
splitLine: { lineStyle: { color: '#eef2f7' } },
|
||||
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: trend.value.map((p) => p.amount),
|
||||
smooth: 0.35,
|
||||
name: 'فروش',
|
||||
data: filteredTrend.value.map((p) => p.amount),
|
||||
smooth: 0.4,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
itemStyle: { color: '#14b8a6', borderColor: '#fff', borderWidth: 2 },
|
||||
lineStyle: { color: '#14b8a6', width: 3 },
|
||||
symbolSize: period.value === 1 ? 10 : 8,
|
||||
itemStyle: { color: '#10b981', borderColor: '#fff', borderWidth: 2 },
|
||||
lineStyle: { color: '#10b981', width: 3 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
@@ -63,8 +96,8 @@ const option = computed<UiChartOption>(() => ({
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(20, 184, 166, 0.28)' },
|
||||
{ offset: 1, color: 'rgba(20, 184, 166, 0)' },
|
||||
{ offset: 0, color: 'rgba(16, 185, 129, 0.28)' },
|
||||
{ offset: 1, color: 'rgba(16, 185, 129, 0)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -74,56 +107,22 @@ const option = computed<UiChartOption>(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>روند فروش</h2>
|
||||
<span v-if="hasTrend && change != null" class="mini-badge" :class="{ down: change < 0 }">
|
||||
{{ formatChange(change) }} نسبت به ماه قبل
|
||||
</span>
|
||||
</header>
|
||||
<UiChart :option="option" height="210px" />
|
||||
</article>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-800 text-base">روند فروش</h3>
|
||||
<span
|
||||
v-if="hasTrend && periodChange != null"
|
||||
class="mt-1 inline-flex text-xs font-bold px-2 py-0.5 rounded-md"
|
||||
:class="periodChange < 0 ? 'text-rose-600 bg-rose-50' : 'text-emerald-600 bg-emerald-50'"
|
||||
>
|
||||
{{ formatChange(periodChange) }} {{ changeHint }}
|
||||
</span>
|
||||
</div>
|
||||
<a-segmented v-model:value="period" :options="periodOptions" />
|
||||
</div>
|
||||
<div class="h-56">
|
||||
<UiChart :option="option" height="224px" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mini-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.18rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-badge.down {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSourceDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
sources: HomeDashboardSourceDto[];
|
||||
}>();
|
||||
|
||||
const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8'];
|
||||
|
||||
const rows = computed(() => props.sources);
|
||||
const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0));
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
color: colors,
|
||||
color: CHART_PALETTE,
|
||||
tooltip: {
|
||||
show: total.value > 0,
|
||||
trigger: 'item',
|
||||
@@ -28,147 +27,51 @@ const option = computed<UiChartOption>(() => ({
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['58%', '82%'],
|
||||
radius: ['55%', '78%'],
|
||||
center: ['50%', '50%'],
|
||||
silent: total.value === 0,
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data:
|
||||
total.value > 0
|
||||
? rows.value.map((s) => ({ name: s.label, value: s.count }))
|
||||
: [{ name: '', value: 1, itemStyle: { color: '#e2e8f0' } }],
|
||||
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>منابع مشتریان</h2>
|
||||
</header>
|
||||
|
||||
<div class="donut-wrap">
|
||||
<div class="donut-chart-box">
|
||||
<UiChart :option="option" height="132px" />
|
||||
<div class="donut-center">
|
||||
<strong>{{ toFaDigits(total) }}</strong>
|
||||
<span>مشتری</span>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">منابع ورود مشتریان</h3>
|
||||
<span v-if="total" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div class="w-full sm:w-[48%] sm:max-w-[220px] shrink-0">
|
||||
<div class="relative w-full aspect-square">
|
||||
<UiChart :option="option" height="100%" />
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
|
||||
<span class="text-xs text-slate-400">مشتری</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:flex-1 min-w-0">
|
||||
<a-empty v-if="!total" description="منبعی ثبت نشده" />
|
||||
<div v-else class="divide-y divide-slate-100">
|
||||
<div v-for="(item, index) in rows" :key="item.label" class="flex items-center gap-2 py-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
|
||||
></span>
|
||||
<span class="flex-1 text-sm text-slate-700 truncate">{{ item.label }}</span>
|
||||
<span class="text-sm font-bold text-slate-800">{{ toFaDigits(item.percent) }}٪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!total" class="legend-empty">منبعی ثبت نشده</p>
|
||||
<ul v-else class="donut-legend">
|
||||
<li v-for="(row, i) in rows" :key="row.label">
|
||||
<span class="dot" :style="{ background: colors[i % colors.length] }" />
|
||||
<span class="lbl">{{ row.label }}</span>
|
||||
<em>{{ toFaDigits(row.percent) }}٪</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.donut-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.donut-chart-box {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
gap: 0.05rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.donut-center strong {
|
||||
font-size: 1.15rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.donut-center span {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.donut-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.48rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.donut-legend li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
flex: 1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.legend-empty {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.donut-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { TrophyOutlined } from '@ant-design/icons-vue';
|
||||
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>
|
||||
|
||||
@@ -102,7 +102,7 @@ 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;
|
||||
|
||||
@@ -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 '@ant-design/icons-vue';
|
||||
|
||||
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 '@ant-design/icons-vue';
|
||||
|
||||
export type ProfileMoreItem = {
|
||||
key: string;
|
||||
@@ -15,207 +20,40 @@ const open = defineModel<boolean>({ default: false });
|
||||
defineProps<{ items: ProfileMoreItem[] }>();
|
||||
const emit = defineEmits<{ select: [key: string] }>();
|
||||
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const top = ref(0);
|
||||
const left = ref(0);
|
||||
|
||||
function place() {
|
||||
const trigger = triggerRef.value;
|
||||
const panel = panelRef.value;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = panel?.offsetWidth || 220;
|
||||
const height = panel?.offsetHeight || 0;
|
||||
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
|
||||
let nextLeft = rtl ? rect.right - width : rect.left;
|
||||
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
|
||||
let nextTop = rect.bottom + 8;
|
||||
if (nextTop + height > window.innerHeight - 8) {
|
||||
nextTop = Math.max(8, rect.top - height - 8);
|
||||
}
|
||||
left.value = nextLeft;
|
||||
top.value = nextTop;
|
||||
}
|
||||
|
||||
function onWindowChange() {
|
||||
if (open.value) place();
|
||||
}
|
||||
|
||||
watch(open, async (value) => {
|
||||
if (!value) {
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
place();
|
||||
await nextTick();
|
||||
place();
|
||||
window.addEventListener('resize', onWindowChange);
|
||||
window.addEventListener('scroll', onWindowChange, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
function close() {
|
||||
function onSelect(key: string) {
|
||||
emit('select', key);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
window.setTimeout(() => {
|
||||
close();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onSelect(key: string) {
|
||||
emit('select', key);
|
||||
nextTick(() => {
|
||||
close();
|
||||
});
|
||||
function onLinkClick() {
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap" @click.stop>
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
aria-label="منوی بیشتر"
|
||||
:aria-expanded="open"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="18" />
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="layer">
|
||||
<button class="backdrop" type="button" aria-label="بستن منو" @click="close" />
|
||||
<ul
|
||||
ref="panelRef"
|
||||
class="panel"
|
||||
role="menu"
|
||||
:style="{ top: `${top}px`, left: `${left}px` }"
|
||||
@click.stop
|
||||
<a-dropdown v-model:open="open" :trigger="['click']" placement="bottomLeft">
|
||||
<a-button aria-label="منوی بیشتر" @click.stop>
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click.stop>
|
||||
<a-menu-item
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:danger="item.danger"
|
||||
@click="item.to ? onLinkClick() : onSelect(item.key)"
|
||||
>
|
||||
<li v-for="item in items" :key="item.key" role="none">
|
||||
<RouterLink
|
||||
v-if="item.to"
|
||||
class="item"
|
||||
:class="{ danger: item.danger }"
|
||||
role="menuitem"
|
||||
:to="item.to"
|
||||
@click.stop="scheduleClose"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else
|
||||
class="item"
|
||||
:class="{ danger: item.danger }"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click.stop.prevent="onSelect(item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="item.to"
|
||||
:to="item.to"
|
||||
:style="{ color: 'inherit', display: 'block' }"
|
||||
@click="onLinkClick"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #334155;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0.4rem;
|
||||
list-style: none;
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.panel > li {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 0.7rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.item.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.item.danger:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
روز پیاپی کامل
|
||||
</span>
|
||||
</header>
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">امتیاز روزانه</a-typography-title>
|
||||
<a-typography-text type="secondary" title="روزهای متوالی با انجام همه کارهای اجباری">
|
||||
<a-typography-text strong :style="{ color: 'var(--ant-color-primary)' }">
|
||||
{{ toFaDigits(score?.streak ?? 0) }}
|
||||
</a-typography-text>
|
||||
روز پیاپی کامل
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<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
|
||||
v-for="d in strip"
|
||||
:key="d.date"
|
||||
role="listitem"
|
||||
class="cell"
|
||||
:class="`s-${d.state}`"
|
||||
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
|
||||
>
|
||||
<em>{{ DAY_STATE_SYMBOLS[d.state] }}</em>
|
||||
<small>{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="s-complete">✓</i> کامل</span>
|
||||
<span><i class="s-partial">△</i> ناقص</span>
|
||||
<span><i class="s-missed">×</i> از دست رفته</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-if="strip.length">
|
||||
<a-flex :gap="4" role="list" aria-label="وضعیت روزهای اخیر">
|
||||
<a-flex
|
||||
v-for="d in strip"
|
||||
:key="d.date"
|
||||
vertical
|
||||
align="center"
|
||||
:gap="2"
|
||||
role="listitem"
|
||||
:style="{ flex: 1, minWidth: 0 }"
|
||||
>
|
||||
<a-tooltip
|
||||
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
|
||||
>
|
||||
<a-flex
|
||||
justify="center"
|
||||
align="center"
|
||||
:style="{
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
flexShrink: 0,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
background: stateStyle(d.state).bg,
|
||||
color: stateStyle(d.state).fg,
|
||||
}"
|
||||
>
|
||||
<a-typography-text
|
||||
strong
|
||||
:style="{ color: stateStyle(d.state).fg, lineHeight: 1 }"
|
||||
>
|
||||
{{ DAY_STATE_SYMBOLS[d.state] }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-tooltip>
|
||||
<a-typography-text type="secondary">
|
||||
{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<a-flex wrap="wrap" :gap="8">
|
||||
<a-tag color="success" :bordered="false">✓ کامل</a-tag>
|
||||
<a-tag color="warning" :bordered="false">△ ناقص</a-tag>
|
||||
<a-tag color="error" :bordered="false">× از دست رفته</a-tag>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</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>
|
||||
|
||||
@@ -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 '@ant-design/icons-vue';
|
||||
|
||||
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'">
|
||||
{{ 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-card :bordered="false" :style="{ height: '100%' }">
|
||||
<a-flex vertical :gap="6">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">{{ label }}</a-typography-text>
|
||||
<a-avatar
|
||||
v-if="iconComp"
|
||||
:style="{ background: toneStyle.bg, color: toneStyle.fg, flexShrink: 0 }"
|
||||
>
|
||||
<template #icon><component :is="iconComp" /></template>
|
||||
</a-avatar>
|
||||
</a-flex>
|
||||
<a-statistic
|
||||
:value="value"
|
||||
:value-style="
|
||||
tone === 'danger'
|
||||
? { color: 'var(--ant-color-error)', fontWeight: 700 }
|
||||
: { fontWeight: 700 }
|
||||
"
|
||||
/>
|
||||
<a-flex v-if="hint || delta != null" align="center" wrap="wrap" :gap="6">
|
||||
<a-typography-text
|
||||
v-if="delta != null && delta !== 0"
|
||||
:type="delta > 0 ? 'success' : 'danger'"
|
||||
strong
|
||||
>
|
||||
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
|
||||
</a-typography-text>
|
||||
<a-typography-text v-else-if="delta === 0" type="secondary">
|
||||
بدون تغییر
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="hint" type="secondary">{{ hint }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
<template v-for="c in cells" :key="c.key">
|
||||
<span v-if="c.blank" class="cell blank" />
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="cell"
|
||||
:class="[
|
||||
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
|
||||
{ sel: selected === c.key, today: c.key === today },
|
||||
]"
|
||||
:title="title(c)"
|
||||
@click="emit('select', c.key)"
|
||||
<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' }"
|
||||
>
|
||||
<span class="num">{{ toFaDigits(c.jd) }}</span>
|
||||
<template v-if="!compact && c.day?.total">
|
||||
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span>
|
||||
<i v-if="mode === 'state'" class="dot" />
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
{{ compact ? w.charAt(0) : w }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex wrap="wrap" :gap="6">
|
||||
<template v-for="c in cells" :key="c.key">
|
||||
<a-flex
|
||||
v-if="c.blank"
|
||||
:style="{ flex: '0 0 calc((100% - 36px) / 7)', minWidth: 0 }"
|
||||
/>
|
||||
<a-button
|
||||
v-else
|
||||
:title="title(c)"
|
||||
:style="{
|
||||
flex: '0 0 calc((100% - 36px) / 7)',
|
||||
minWidth: 0,
|
||||
height: compact ? '44px' : '74px',
|
||||
padding: '8px',
|
||||
background: cellBackground(c),
|
||||
borderColor:
|
||||
selected === c.key
|
||||
? token.colorPrimary
|
||||
: c.key === today
|
||||
? token.colorPrimaryBorder
|
||||
: undefined,
|
||||
boxShadow: selected === c.key ? `0 0 0 1px ${token.colorPrimary}` : undefined,
|
||||
}"
|
||||
@click="emit('select', c.key)"
|
||||
>
|
||||
<a-flex
|
||||
vertical
|
||||
align="start"
|
||||
justify="space-between"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
>
|
||||
<a-typography-text
|
||||
strong
|
||||
:style="c.key === today ? { color: 'var(--ant-color-primary)' } : undefined"
|
||||
>
|
||||
{{ toFaDigits(c.jd) }}
|
||||
</a-typography-text>
|
||||
<a-flex
|
||||
v-if="!compact && c.day?.total"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
:style="{ width: '100%' }"
|
||||
>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}
|
||||
</a-typography-text>
|
||||
<a-flex
|
||||
v-if="mode === 'state'"
|
||||
:style="{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
background: STATE_DOT[cellKey(c)],
|
||||
flexShrink: 0,
|
||||
}"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-button>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</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>
|
||||
|
||||
@@ -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 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);
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
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'"
|
||||
>
|
||||
<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>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="props.members"
|
||||
:pagination="false"
|
||||
:row-key="(r: RoutineMemberStatDto) => r.user.id"
|
||||
:custom-row="rowProps"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty description="دادهای برای این بازه نیست." />
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a-space :size="8">
|
||||
<UserAvatar :name="record.user.fullName" :user-id="record.user.id" :size="28" />
|
||||
<a-typography-text strong>{{ record.user.fullName }}</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'percent'">
|
||||
<a-flex align="center" :gap="8" :style="{ minWidth: '8rem' }">
|
||||
<a-typography-text strong>{{ formatPercent(record.percent) }}</a-typography-text>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, Math.round(record.percent ?? 0)))"
|
||||
:show-info="false"
|
||||
:stroke-color="TONE_COLORS[percentTone(record.percent)]"
|
||||
:style="{ flex: 1 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort.on {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.pct {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 80px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pct i {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pct b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<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 { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const canPayroll = computed(
|
||||
() =>
|
||||
auth.user?.role === 'owner' ||
|
||||
@@ -16,53 +16,45 @@ const canPayroll = computed(
|
||||
);
|
||||
|
||||
const links = computed(() => [
|
||||
{ value: '/finance', label: 'داشبورد', exact: true },
|
||||
{ value: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ value: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ value: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ value: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ value: '/finance/reports', label: 'گزارش' },
|
||||
{ value: '/finance/audit', label: 'سوابق' },
|
||||
{ value: '/finance/categories', label: 'دستهبندیها' },
|
||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/audit', label: 'سوابق' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
]);
|
||||
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<a-menu
|
||||
mode="horizontal"
|
||||
:selected-keys="selectedKeys"
|
||||
:items="items"
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+277
-234
@@ -19,11 +19,6 @@ import type {
|
||||
NotificationsListDto,
|
||||
} from '@mento/shared';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import {
|
||||
canOpenPath,
|
||||
hasSectionPermission,
|
||||
sectionPermissionForPath,
|
||||
} from '@/lib/section-permissions';
|
||||
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -46,41 +41,8 @@ const showOwnRecordsBanner = computed(
|
||||
);
|
||||
const todayLabel = computed(() => formatJalaliShort(new Date()));
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const n = Math.max(0, bytes);
|
||||
const units: Array<[number, string]> = [
|
||||
[1024 ** 3, 'گیگابایت'],
|
||||
[1024 ** 2, 'مگابایت'],
|
||||
[1024, 'کیلوبایت'],
|
||||
];
|
||||
for (const [size, label] of units) {
|
||||
if (n >= size) {
|
||||
const value = n / size;
|
||||
const text = value >= 10 ? value.toFixed(0) : value.toFixed(1);
|
||||
return `${toFaDigits(text)} ${label}`;
|
||||
}
|
||||
}
|
||||
return `${toFaDigits(Math.round(n))} بایت`;
|
||||
}
|
||||
|
||||
const storageBanner = computed(() => {
|
||||
const storage = auth.user?.storage;
|
||||
if (!storage || storage.level === 'ok' || storage.level === 'unlimited' || storage.quotaBytes == null) {
|
||||
return null;
|
||||
}
|
||||
return storage;
|
||||
});
|
||||
|
||||
const SIDEBAR_KEY = 'mento_sidebar_collapsed';
|
||||
|
||||
function readSidebarCollapsed() {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const collapsed = ref(readSidebarCollapsed());
|
||||
const collapsed = ref(false);
|
||||
|
||||
const openHubKey = ref<NavHubKey | null>(null);
|
||||
const hubStack = ref<NavHubKey[]>([]);
|
||||
@@ -111,9 +73,7 @@ function pathIn(prefixes: string[]) {
|
||||
|
||||
const dashboardActive = computed(() => route.path === '/');
|
||||
const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
||||
const reportsActive = computed(() =>
|
||||
pathIn(['/reports', '/reports/tahesab-sales']),
|
||||
);
|
||||
const reportsActive = computed(() => pathIn(['/reports']));
|
||||
const clubActive = computed(() => pathIn(['/club']));
|
||||
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
|
||||
const customersActive = computed(
|
||||
@@ -184,11 +144,15 @@ const moreSectionActive = computed(
|
||||
|
||||
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
|
||||
const subscription = computed(() => auth.user?.subscription ?? null);
|
||||
|
||||
/** لینکهای مستقیم منو (بیرون از هابها) با مجوز بخش */
|
||||
function canOpen(path: string) {
|
||||
return canOpenPath(auth.user?.role, auth.user?.permissions, path);
|
||||
}
|
||||
const subscriptionTone = computed(() => {
|
||||
const sub = subscription.value;
|
||||
if (!sub || sub.planCode === 'lifetime') return 'ok';
|
||||
if (sub.status === 'expired' || sub.status === 'suspended' || sub.daysRemaining <= 0) {
|
||||
return 'expired';
|
||||
}
|
||||
if (sub.warn) return 'warn';
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
function moduleAllowed(module?: string) {
|
||||
if (!module) return true;
|
||||
@@ -208,16 +172,7 @@ function cardAllowedForNav(card: {
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury'))
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
|
||||
if (card.to?.startsWith('/finance')) return canTreasury;
|
||||
const section = card.to ? sectionPermissionForPath(card.to) : null;
|
||||
const hasSection = section
|
||||
? hasSectionPermission(auth.user?.role, auth.user?.permissions, section)
|
||||
: true;
|
||||
if (!hasSection) return false;
|
||||
// لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز میشود
|
||||
const grantedBySection = section === 'audit' && hasSection;
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) {
|
||||
return false;
|
||||
}
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
|
||||
if (!moduleAllowed(card.module)) return false;
|
||||
if (
|
||||
card.admin &&
|
||||
@@ -343,6 +298,29 @@ function toggleCollapsed() {
|
||||
}
|
||||
}
|
||||
|
||||
const notifAnchor = ref<HTMLElement | null>(null);
|
||||
const notifPos = ref<{ top: string; left: string; width: string } | null>(null);
|
||||
|
||||
function placeNotif(anchor: HTMLElement) {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const width = Math.min(360, window.innerWidth - margin * 2);
|
||||
const left = Math.max(
|
||||
margin,
|
||||
Math.min(rect.right - width, window.innerWidth - width - margin),
|
||||
);
|
||||
const top = rect.bottom + margin;
|
||||
notifPos.value = {
|
||||
top: `${Math.round(top)}px`,
|
||||
left: `${Math.round(left)}px`,
|
||||
width: `${Math.round(width)}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function onNotifReflow() {
|
||||
if (notifOpen.value && notifAnchor.value) placeNotif(notifAnchor.value);
|
||||
}
|
||||
|
||||
function closeNotif() {
|
||||
notifOpen.value = false;
|
||||
}
|
||||
@@ -440,10 +418,15 @@ function toggleMore() {
|
||||
moreOpen.value = true;
|
||||
}
|
||||
|
||||
function toggleNotif() {
|
||||
function toggleNotif(event: MouseEvent) {
|
||||
moreOpen.value = false;
|
||||
notifOpen.value = !notifOpen.value;
|
||||
if (notifOpen.value) void refreshNotifs();
|
||||
const opening = !notifOpen.value;
|
||||
notifOpen.value = opening;
|
||||
if (!opening) return;
|
||||
const anchor = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
notifAnchor.value = anchor;
|
||||
if (anchor) placeNotif(anchor);
|
||||
void refreshNotifs();
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
@@ -460,12 +443,20 @@ function submitSearch() {
|
||||
|
||||
function syncBodyLock() {
|
||||
const overlay = moreOpen.value || Boolean(openHubKey.value);
|
||||
document.body.style.overflow =
|
||||
overlay || notifOpen.value ? 'hidden' : '';
|
||||
document.body.style.overflow = overlay ? 'hidden' : '';
|
||||
document.body.classList.toggle('mobile-nav-overlay', overlay);
|
||||
}
|
||||
watch(moreOpen, syncBodyLock);
|
||||
watch(notifOpen, syncBodyLock);
|
||||
watch(notifOpen, (open) => {
|
||||
syncBodyLock();
|
||||
if (open) {
|
||||
window.addEventListener('resize', onNotifReflow);
|
||||
window.addEventListener('scroll', onNotifReflow, true);
|
||||
} else {
|
||||
window.removeEventListener('resize', onNotifReflow);
|
||||
window.removeEventListener('scroll', onNotifReflow, true);
|
||||
}
|
||||
});
|
||||
watch(openHubKey, syncBodyLock);
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
@@ -501,11 +492,13 @@ onUnmounted(() => {
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('mobile-nav-overlay');
|
||||
mobileMq?.removeEventListener('change', onMobileMqChange);
|
||||
window.removeEventListener('resize', onNotifReflow);
|
||||
window.removeEventListener('scroll', onNotifReflow, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell app-frame" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
|
||||
<div class="shell" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
|
||||
|
||||
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
|
||||
<div class="brand-row">
|
||||
@@ -556,7 +549,14 @@ onUnmounted(() => {
|
||||
<div class="rail-stem">
|
||||
<nav class="side-nav">
|
||||
<div class="nav-primary">
|
||||
<RouterLink to="/" class="nav-item" title="داشبورد" @click="closeHub(); closeMenu()">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="nav-item"
|
||||
active-class=""
|
||||
exact-active-class="router-link-active"
|
||||
title="داشبورد"
|
||||
@click="closeHub(); closeMenu()"
|
||||
>
|
||||
<UiIcon name="home" :size="collapsed ? 20 : 18" />
|
||||
<span>داشبورد</span>
|
||||
</RouterLink>
|
||||
@@ -610,13 +610,13 @@ onUnmounted(() => {
|
||||
<button
|
||||
class="nav-item nav-fold-btn"
|
||||
type="button"
|
||||
title="فعالیتها"
|
||||
title="کارها"
|
||||
:class="{ 'router-link-active': activitiesActive || openHubKey === 'activities', 'hub-on': openHubKey === 'activities' }"
|
||||
:aria-expanded="openHubKey === 'activities'"
|
||||
@click="onHubClick('activities')"
|
||||
>
|
||||
<UiIcon name="check" :size="collapsed ? 20 : 18" />
|
||||
<span>فعالیتها</span>
|
||||
<span>کارها</span>
|
||||
<span v-if="activityBadge" class="nav-badge">{{ toFaDigits(activityBadge) }}</span>
|
||||
<i class="chev" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -661,7 +661,6 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="canOpen('/notes')"
|
||||
to="/notes"
|
||||
class="nav-item"
|
||||
title="یادداشتها"
|
||||
@@ -691,13 +690,7 @@ onUnmounted(() => {
|
||||
<UiIcon name="gift" :size="collapsed ? 20 : 18" />
|
||||
<span>باشگاه مشتریان</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="canOpen('/reports')"
|
||||
to="/reports"
|
||||
class="nav-item"
|
||||
title="گزارشها"
|
||||
@click="closeHub(); closeMenu()"
|
||||
>
|
||||
<RouterLink to="/reports" class="nav-item" title="گزارشها" @click="closeHub(); closeMenu()">
|
||||
<UiIcon name="chart" :size="collapsed ? 20 : 18" />
|
||||
<span>گزارشها</span>
|
||||
</RouterLink>
|
||||
@@ -868,52 +861,49 @@ onUnmounted(() => {
|
||||
</form>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<RouterLink
|
||||
v-if="canOpen('/notes')"
|
||||
class="round-btn accent"
|
||||
:to="{ name: 'notes' }"
|
||||
title="هوش مصنوعی"
|
||||
>
|
||||
<UiIcon name="sparkle" :size="18" />
|
||||
</RouterLink>
|
||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
<UiIcon name="bell" :size="18" />
|
||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||
</button>
|
||||
<RouterLink
|
||||
v-if="canOpen('/activities')"
|
||||
class="round-btn"
|
||||
:to="{ name: 'activities-calendar' }"
|
||||
title="تقویم"
|
||||
>
|
||||
<UiIcon name="calendar" :size="18" />
|
||||
</RouterLink>
|
||||
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
||||
<UiIcon name="mail" :size="18" />
|
||||
</RouterLink>
|
||||
<SmsHeaderWallet />
|
||||
<div
|
||||
v-if="subscription"
|
||||
class="sub-chip"
|
||||
:class="{ warn: subscription.warn }"
|
||||
:title="`انقضا: ${subscription.expiresAt}`"
|
||||
>
|
||||
<UiIcon name="clock" :size="15" />
|
||||
<span v-if="subscription.planCode === 'lifetime'">اشتراک دائمی</span>
|
||||
<span v-else>
|
||||
{{ toFaDigits(subscription.daysRemaining) }} روز باقیمانده
|
||||
</span>
|
||||
<div class="topbar-tools" role="toolbar" aria-label="میانبرها">
|
||||
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
|
||||
<UiIcon name="sparkle" :size="18" />
|
||||
</RouterLink>
|
||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
<UiIcon name="bell" :size="18" />
|
||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||
</button>
|
||||
<RouterLink class="round-btn" :to="{ name: 'activities-calendar' }" title="تقویم">
|
||||
<UiIcon name="calendar" :size="18" />
|
||||
</RouterLink>
|
||||
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
||||
<UiIcon name="mail" :size="18" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="date-chip">
|
||||
<UiIcon name="calendar" :size="16" />
|
||||
<span>{{ todayLabel }}</span>
|
||||
<div class="topbar-context" aria-label="وضعیت حساب">
|
||||
<span class="context-date" :title="todayLabel">{{ todayLabel }}</span>
|
||||
<span v-if="subscription" class="context-sep" aria-hidden="true" />
|
||||
<span
|
||||
v-if="subscription"
|
||||
class="context-sub"
|
||||
:class="subscriptionTone"
|
||||
:title="
|
||||
subscription.planCode === 'lifetime'
|
||||
? 'اشتراک دائمی'
|
||||
: `${toFaDigits(subscription.daysRemaining)} روز تا پایان اشتراک. انقضا: ${subscription.expiresAt}`
|
||||
"
|
||||
>
|
||||
<template v-if="subscription.planCode === 'lifetime'">اشتراک دائمی</template>
|
||||
<template v-else-if="subscriptionTone === 'expired'">
|
||||
{{ subscription.status === 'suspended' ? 'اشتراک معلق' : 'اشتراک منقضی' }}
|
||||
</template>
|
||||
<template v-else>{{ toFaDigits(subscription.daysRemaining) }} روز مانده</template>
|
||||
</span>
|
||||
<span class="context-sep" aria-hidden="true" />
|
||||
<SmsHeaderWallet />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="notifOpen" class="notif-layer">
|
||||
<div class="notif-backdrop" @click="closeNotif" />
|
||||
<div class="notif-panel" role="dialog" aria-label="اعلانها">
|
||||
<div class="notif-panel" role="dialog" aria-label="اعلانها" :style="notifPos ?? undefined">
|
||||
<header class="notif-head">
|
||||
<strong>اعلانها</strong>
|
||||
<div class="notif-head-actions">
|
||||
@@ -939,21 +929,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="storageBanner"
|
||||
class="storage-banner"
|
||||
:class="storageBanner.level === 'full' ? 'full' : 'warn'"
|
||||
>
|
||||
<template v-if="storageBanner.level === 'full'">
|
||||
سقف حجم فایل پر شده است ({{ formatBytes(storageBanner.usedBytes) }} از
|
||||
{{ formatBytes(storageBanner.quotaBytes ?? 0) }}). تا شارژ حجم، آپلود جدید ممکن نیست.
|
||||
</template>
|
||||
<template v-else>
|
||||
حجم فایل به {{ toFaDigits(storageBanner.percent ?? 80) }}٪ رسیده
|
||||
({{ formatBytes(storageBanner.usedBytes) }} از {{ formatBytes(storageBanner.quotaBytes ?? 0) }}).
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
<GamificationToaster v-if="moduleAllowed('gamification')" />
|
||||
@@ -983,7 +958,6 @@ onUnmounted(() => {
|
||||
<span>داشبورد</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="hubVisible.customers || hubVisible.leads"
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{
|
||||
@@ -1002,7 +976,6 @@ onUnmounted(() => {
|
||||
<span>مشتریان</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hubVisible.sales"
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{ on: !moreOpen && (salesActive || openHubKey === 'sales') }"
|
||||
@@ -1013,19 +986,17 @@ onUnmounted(() => {
|
||||
<span>فروش</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hubVisible.activities"
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{ on: !moreOpen && (activitiesActive || openHubKey === 'activities') }"
|
||||
:aria-expanded="openHubKey === 'activities'"
|
||||
title="فعالیتها"
|
||||
@click="openTabHub('activities')"
|
||||
>
|
||||
<span class="tab-ico">
|
||||
<UiIcon name="check" :size="20" />
|
||||
<span v-if="activityBadge" class="tab-badge">{{ toFaDigits(activityBadge) }}</span>
|
||||
</span>
|
||||
<span>فعالیت</span>
|
||||
<span>کارها</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
@@ -1074,7 +1045,7 @@ onUnmounted(() => {
|
||||
<UiIcon name="trophy" :size="18" />
|
||||
<span>باشگاه امتیازات</span>
|
||||
</RouterLink>
|
||||
<RouterLink v-if="canOpen('/notes')" to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="document" :size="18" />
|
||||
<span>یادداشتها</span>
|
||||
</RouterLink>
|
||||
@@ -1107,7 +1078,7 @@ onUnmounted(() => {
|
||||
<UiIcon name="gift" :size="18" />
|
||||
<span>باشگاه مشتریان</span>
|
||||
</RouterLink>
|
||||
<RouterLink v-if="canOpen('/reports')" to="/reports" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<RouterLink to="/reports" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="chart" :size="18" />
|
||||
<span>گزارشها</span>
|
||||
</RouterLink>
|
||||
@@ -1223,7 +1194,6 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@@ -1353,7 +1323,7 @@ onUnmounted(() => {
|
||||
.hub-layer :deep(.hub) {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
top: calc(16px + var(--control-h) + 12px);
|
||||
inset-inline-start: var(--hub-offset);
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
@@ -1450,36 +1420,17 @@ onUnmounted(() => {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.storage-banner {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.storage-banner.warn {
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.storage-banner.full {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.foot-actions {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prior-switch {
|
||||
@@ -1514,8 +1465,6 @@ onUnmounted(() => {
|
||||
.foot-meta {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
@@ -1742,30 +1691,12 @@ onUnmounted(() => {
|
||||
.sidebar:not(.collapsed) .foot-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .foot-meta :deep(.org-switch) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .foot-meta :deep(.org-switch),
|
||||
.sidebar:not(.collapsed) .foot-meta :deep(.org-btn) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .foot-meta :deep(.org-btn) {
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
padding: 0.2rem 0.15rem;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .foot-meta :deep(.org-btn span) {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .logout-link {
|
||||
@@ -2042,7 +1973,7 @@ onUnmounted(() => {
|
||||
|
||||
.desktop-topbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(12rem, 1fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
@@ -2058,12 +1989,165 @@ onUnmounted(() => {
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
/* مرکز عمودی نسبت به ارتفاع جستجو (~49px) */
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
|
||||
.topbar-tools {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: var(--control-h);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.topbar-tools .round-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
.topbar-tools .round-btn:hover {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.topbar-tools .round-btn.accent {
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.topbar-tools .round-btn.accent:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.topbar-context {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: var(--control-h);
|
||||
padding-inline: 12px;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-xs);
|
||||
color: var(--ink-secondary);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-date {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.context-sub {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.context-sub.ok {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.context-sub.warn {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.context-sub.expired {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.context-sep {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.topbar-context > .context-sep:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-context :deep(.sms-wallet) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-context :deep(.chip) {
|
||||
height: 32px;
|
||||
gap: 6px;
|
||||
padding-inline: 2px 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.topbar-context :deep(.chip.warning),
|
||||
.topbar-context :deep(.chip.critical),
|
||||
.topbar-context :deep(.chip.negative),
|
||||
.topbar-context :deep(.chip.healthy) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.topbar-context :deep(.label) {
|
||||
color: var(--muted);
|
||||
opacity: 1;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.topbar-context :deep(.amount) {
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.topbar-context :deep(.chip.warning .amount) {
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.topbar-context :deep(.chip.critical .amount),
|
||||
.topbar-context :deep(.chip.negative .amount) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.topbar-context :deep(.chip.is-debt) {
|
||||
height: 28px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topbar-context :deep(.chip.is-debt .label),
|
||||
.topbar-context :deep(.chip.is-debt .amount) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.global-search:focus-within {
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
@@ -2113,40 +2197,6 @@ onUnmounted(() => {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.sub-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-chip.warn {
|
||||
background: color-mix(in srgb, var(--danger) 12%, white);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
height: var(--control-h);
|
||||
box-sizing: border-box;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
@@ -2167,29 +2217,27 @@ onUnmounted(() => {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: start end;
|
||||
padding: 5rem 1rem 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notif-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.22);
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notif-panel {
|
||||
position: relative;
|
||||
width: min(360px, calc(100vw - 2rem));
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
width: min(360px, calc(100vw - 1rem));
|
||||
max-height: min(70vh, 480px);
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -2407,13 +2455,8 @@ onUnmounted(() => {
|
||||
padding: 16px 16px calc(16px + var(--mobile-tabbar-h));
|
||||
}
|
||||
|
||||
.notif-layer {
|
||||
padding: 4.25rem 0.65rem calc(1rem + var(--mobile-tabbar-h));
|
||||
place-items: start stretch;
|
||||
}
|
||||
|
||||
.notif-panel {
|
||||
width: 100%;
|
||||
max-height: min(70vh, calc(100dvh - var(--mobile-tabbar-h) - 5rem));
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,8 +7,9 @@ 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 = {
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+872
-2607
File diff suppressed because it is too large
Load Diff
+519
-2178
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+364
-248
@@ -1,16 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { CURRENCY_OPTIONS, formatMoney, parseMoneyNumber } from '@/lib/money';
|
||||
import { formatJalaliNumeric, formatLocalDateInput } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import type {
|
||||
ChequeDirection,
|
||||
ChequeDto,
|
||||
@@ -20,7 +18,51 @@ import type {
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const {
|
||||
Title: ATypographyTitle,
|
||||
Text: ATypographyText,
|
||||
Link: ATypographyLink,
|
||||
Paragraph: ATypographyParagraph,
|
||||
} = Typography;
|
||||
|
||||
const CHEQUE_COL_LABELS = [
|
||||
'شماره',
|
||||
'بانک',
|
||||
'سررسید',
|
||||
'مبلغ',
|
||||
'فاکتور',
|
||||
'طرفحساب',
|
||||
'وضعیت',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('cheques', [...CHEQUE_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'cheques');
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const items = ref<ChequeDto[]>([]);
|
||||
@@ -59,15 +101,65 @@ function statusLabelFor(direction: string): Record<ChequeStatus, string> {
|
||||
const formStatusLabel = computed(() => statusLabelFor(formDirection.value));
|
||||
const isIncomingForm = computed(() => formDirection.value !== 'out');
|
||||
|
||||
const listStatusOptions = computed(() => [
|
||||
{ value: '', label: 'همه وضعیتها' },
|
||||
{ value: 'pending', label: 'در جریان' },
|
||||
{
|
||||
value: 'cleared',
|
||||
label: tab.value === 'out' ? 'پرداخت شد' : 'وصول شد',
|
||||
},
|
||||
{ value: 'bounced', label: 'برگشت' },
|
||||
]);
|
||||
|
||||
const currencyOptions = computed(() => {
|
||||
const codes = enabledCurrencies.value.length ? [...enabledCurrencies.value] : [currency.value];
|
||||
if (currency.value && !codes.includes(currency.value)) codes.push(currency.value);
|
||||
return codes.map((code) => {
|
||||
const opt = CURRENCY_OPTIONS.find((c) => c.code === code);
|
||||
return { code, label: opt ? `${opt.suffix} - ${opt.code}` : code };
|
||||
return { value: code, label: opt ? `${opt.suffix} - ${opt.code}` : code };
|
||||
});
|
||||
});
|
||||
|
||||
const personOptions = computed(() =>
|
||||
people.value.map((p) => ({ value: p.id, label: personLabel(p) })),
|
||||
);
|
||||
|
||||
const formStatusOptions = computed(() => [
|
||||
{ value: 'pending', label: formStatusLabel.value.pending },
|
||||
{ value: 'cleared', label: formStatusLabel.value.cleared },
|
||||
{ value: 'bounced', label: formStatusLabel.value.bounced },
|
||||
]);
|
||||
|
||||
const clearAccountOptions = computed(() =>
|
||||
clearAccounts.value.map((a) => ({ value: a.id, label: a.name })),
|
||||
);
|
||||
|
||||
const columns = computed<TableColumnsType<ChequeDto>>(() => {
|
||||
const all: TableColumnsType<ChequeDto> = [
|
||||
{ title: 'شماره', key: 'شماره', dataIndex: 'number' },
|
||||
{ title: 'بانک', key: 'بانک', dataIndex: 'bank' },
|
||||
{ title: 'سررسید', key: 'سررسید' },
|
||||
{ title: 'مبلغ', key: 'مبلغ' },
|
||||
...(tab.value === 'in' ? [{ title: 'فاکتور', key: 'فاکتور' }] : []),
|
||||
{ title: 'طرفحساب', key: 'طرفحساب' },
|
||||
{ title: 'وضعیت', key: 'وضعیت', width: 160 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!CHEQUE_COL_LABELS.includes(key as (typeof CHEQUE_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
if (key === 'فاکتور' && tab.value !== 'in') return false;
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
const emptyDescription = computed(() =>
|
||||
tab.value === 'out'
|
||||
? 'چک پرداختی ثبت نشده. از «پرداخت چک» میتوانید به شخص دیگری چک بدهید.'
|
||||
: 'چک دریافتی ثبت نشده. از «دریافت چک» میتوانید بدون فاکتور ثبت کنید.',
|
||||
);
|
||||
|
||||
function axiosMessage(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -80,6 +172,15 @@ function personLabel(p: PersonDto) {
|
||||
return p.company?.name ? `${p.fullName} — ${p.company.name}` : p.fullName;
|
||||
}
|
||||
|
||||
function rowStatusOptions(row: ChequeDto) {
|
||||
const labels = statusLabelFor(row.direction);
|
||||
return [
|
||||
{ value: 'pending', label: labels.pending },
|
||||
{ value: 'cleared', label: labels.cleared },
|
||||
{ value: 'bounced', label: labels.bounced },
|
||||
];
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -228,6 +329,18 @@ async function submitForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
tab.value = String(key) as ChequeDirection;
|
||||
}
|
||||
|
||||
function goInvoice(id: string) {
|
||||
void router.push({ name: 'invoice-detail', params: { id } });
|
||||
}
|
||||
|
||||
function goPerson(id: string) {
|
||||
void router.push({ name: 'person-detail', params: { id } });
|
||||
}
|
||||
|
||||
watch(tab, () => {
|
||||
void load();
|
||||
});
|
||||
@@ -236,254 +349,257 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" dir="rtl">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>چکها</h1>
|
||||
<p class="sub">دفتر چکهای دریافتی و پرداختی. وضعیت فقط برای پیگیری است</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<UiButton type="button" @click="openForm('in')">دریافت چک</UiButton>
|
||||
<UiButton variant="ghost" type="button" @click="openForm('out')">پرداخت چک</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">چکها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر چکهای دریافتی و پرداختی. وضعیت فقط برای پیگیری است
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="8">
|
||||
<a-button type="primary" @click="openForm('in')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
دریافت چک
|
||||
</a-button>
|
||||
<a-button @click="openForm('out')">پرداخت چک</a-button>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</div>
|
||||
</header>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'in' }" @click="tab = 'in'">دریافتی</button>
|
||||
<button type="button" :class="{ on: tab === 'out' }" @click="tab = 'out'">پرداختی</button>
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-tabs :active-key="tab" @change="onTabChange">
|
||||
<a-tab-pane key="in" tab="دریافتی" />
|
||||
<a-tab-pane key="out" tab="پرداختی" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
v-model="q"
|
||||
type="search"
|
||||
placeholder="شماره چک، بانک، فاکتور یا شخص"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
<select v-model="status" @change="load">
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="pending">در جریان</option>
|
||||
<option value="cleared">{{ tab === 'out' ? 'پرداخت شد' : 'وصول شد' }}</option>
|
||||
<option value="bounced">برگشت</option>
|
||||
</select>
|
||||
<UiButton type="button" @click="load">اعمال</UiButton>
|
||||
</div>
|
||||
<a-form layout="vertical" @finish="load">
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="12" :lg="10">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="شماره چک، بانک، فاکتور یا شخص"
|
||||
style="width: 100%"
|
||||
@pressEnter="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="status"
|
||||
:options="listStatusOptions"
|
||||
style="width: 100%"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>اعمال</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<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">
|
||||
{{
|
||||
tab === 'out'
|
||||
? 'چک پرداختی ثبت نشده. از «پرداخت چک» میتوانید به شخص دیگری چک بدهید.'
|
||||
: 'چک دریافتی ثبت نشده. از «دریافت چک» میتوانید بدون فاکتور ثبت کنید.'
|
||||
}}
|
||||
</p>
|
||||
<div v-else class="table-wrap">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="cheques" align="end" />
|
||||
</div>
|
||||
<table v-table-cols="'cheques'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شماره</th>
|
||||
<th>بانک</th>
|
||||
<th>سررسید</th>
|
||||
<th>مبلغ</th>
|
||||
<th v-if="tab === 'in'">فاکتور</th>
|
||||
<th>طرفحساب</th>
|
||||
<th>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td>{{ row.number }}</td>
|
||||
<td>{{ row.bank }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td>
|
||||
<td>{{ formatMoney(row.amount, row.currency) }}</td>
|
||||
<td v-if="tab === 'in'">
|
||||
<RouterLink
|
||||
v-if="row.invoiceId"
|
||||
:to="{ name: 'invoice-detail', params: { id: row.invoiceId } }"
|
||||
>
|
||||
{{ row.invoiceNumber }}
|
||||
</RouterLink>
|
||||
<span v-else class="muted">بدون فاکتور</span>
|
||||
</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
v-if="row.personId"
|
||||
:to="{ name: 'person-detail', params: { id: row.personId } }"
|
||||
>
|
||||
{{ row.personName || '—' }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ row.personName || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
:value="row.status"
|
||||
:disabled="savingId === row.id"
|
||||
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
|
||||
>
|
||||
<option value="pending">{{ statusLabelFor(row.direction).pending }}</option>
|
||||
<option value="cleared">{{ statusLabelFor(row.direction).cleared }}</option>
|
||||
<option value="bounced">{{ statusLabelFor(row.direction).bounced }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<UiModal :open="clearOpen" title="حساب وصول چک" @close="clearOpen = false">
|
||||
<p class="hint">چک {{ clearRow?.number }} در کدام حساب صندوق بنشیند؟</p>
|
||||
<label class="field">
|
||||
<span>حساب</span>
|
||||
<select v-model="clearAccountId">
|
||||
<option v-for="account in clearAccounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<UiButton
|
||||
:loading="savingId === clearRow?.id"
|
||||
@click="confirmClear"
|
||||
>
|
||||
ثبت وصول
|
||||
</UiButton>
|
||||
</UiModal>
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
:description="emptyDescription"
|
||||
/>
|
||||
<template v-else-if="items.length">
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
|
||||
<TableColumnsMenu table-key="cheques" align="end" />
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 720 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'سررسید'">
|
||||
{{ formatJalaliNumeric(new Date((record as ChequeDto).dueDate)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ'">
|
||||
{{ formatMoney((record as ChequeDto).amount, (record as ChequeDto).currency) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'فاکتور'">
|
||||
<a-typography-link
|
||||
v-if="(record as ChequeDto).invoiceId"
|
||||
@click="goInvoice((record as ChequeDto).invoiceId!)"
|
||||
>
|
||||
{{ (record as ChequeDto).invoiceNumber }}
|
||||
</a-typography-link>
|
||||
<a-typography-text v-else type="secondary">بدون فاکتور</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'طرفحساب'">
|
||||
<a-typography-link
|
||||
v-if="(record as ChequeDto).personId"
|
||||
@click="goPerson((record as ChequeDto).personId!)"
|
||||
>
|
||||
{{ (record as ChequeDto).personName || '—' }}
|
||||
</a-typography-link>
|
||||
<a-typography-text v-else>
|
||||
{{ (record as ChequeDto).personName || '—' }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-select
|
||||
:value="(record as ChequeDto).status"
|
||||
:options="rowStatusOptions(record as ChequeDto)"
|
||||
:disabled="savingId === (record as ChequeDto).id"
|
||||
style="width: 100%"
|
||||
@update:value="(v) => setStatus(record as ChequeDto, v as ChequeStatus)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
|
||||
<UiModal
|
||||
:open="formOpen"
|
||||
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
|
||||
@close="formOpen = false"
|
||||
<a-modal
|
||||
v-model:open="clearOpen"
|
||||
title="حساب وصول چک"
|
||||
destroy-on-close
|
||||
:footer="null"
|
||||
>
|
||||
<form class="receive-form" @submit.prevent="submitForm">
|
||||
<p class="hint">
|
||||
{{
|
||||
isIncomingForm
|
||||
? 'چک را از یک شخص بگیرید؛ لازم نیست به فاکتور وصل باشد.'
|
||||
: 'چکی که به شخص دیگری میپردازید را ثبت کنید.'
|
||||
}}
|
||||
</p>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<label class="field">
|
||||
<span>{{ isIncomingForm ? 'شخص' : 'گیرنده' }} <em>*</em></span>
|
||||
<select v-model="personId" required>
|
||||
<option value="">انتخاب شخص</option>
|
||||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||||
{{ personLabel(p) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<span>مبلغ <em>*</em></span>
|
||||
<MoneyInput v-model="amount" :decimals="0" placeholder="مبلغ چک" />
|
||||
</label>
|
||||
<label v-if="currencyOptions.length > 1" class="field">
|
||||
<span>ارز</span>
|
||||
<select v-model="currency">
|
||||
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<span>شماره چک <em>*</em></span>
|
||||
<UiInput v-model="chequeNumber" placeholder="شماره چک" :maxlength="80" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>بانک <em>*</em></span>
|
||||
<UiInput v-model="chequeBank" placeholder="نام بانک" :maxlength="120" required />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<span>سررسید <em>*</em></span>
|
||||
<JalaliDateInput v-model="dueDate" required placeholder="سررسید چک" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>وضعیت</span>
|
||||
<select v-model="formStatus">
|
||||
<option value="pending">{{ formStatusLabel.pending }}</option>
|
||||
<option value="cleared">{{ formStatusLabel.cleared }}</option>
|
||||
<option value="bounced">{{ formStatusLabel.bounced }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>یادداشت</span>
|
||||
<textarea v-model="notes" rows="3" maxlength="2000" placeholder="اختیاری" />
|
||||
</label>
|
||||
</form>
|
||||
<template #footer>
|
||||
<UiButton type="button" :disabled="saving" @click="submitForm">
|
||||
{{ saving ? 'در حال ثبت…' : isIncomingForm ? 'ثبت دریافت' : 'ثبت پرداخت' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="formOpen = false">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
<a-typography-paragraph type="secondary">
|
||||
چک {{ clearRow?.number }} در کدام حساب صندوق بنشیند؟
|
||||
</a-typography-paragraph>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="حساب" required>
|
||||
<a-select
|
||||
v-model:value="clearAccountId"
|
||||
:options="clearAccountOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-flex justify="flex-end" :gap="8">
|
||||
<a-button @click="clearOpen = false">انصراف</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="savingId === clearRow?.id"
|
||||
:disabled="!clearAccountId"
|
||||
@click="confirmClear"
|
||||
>
|
||||
ثبت وصول
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
||||
.head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
|
||||
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; }
|
||||
.hint { margin: 0 0 0.75rem; color: var(--muted); font-size: 0.88rem; }
|
||||
.tabs {
|
||||
display: flex; gap: 0.35rem; overflow-x: auto; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tabs button {
|
||||
border: 0; background: transparent; padding: 0.65rem 0.85rem; cursor: pointer;
|
||||
color: #64748b; font-weight: 700; border-bottom: 2px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.tabs button.on { color: var(--teal); border-bottom-color: var(--teal); }
|
||||
.toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
||||
}
|
||||
.toolbar input, .toolbar select {
|
||||
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 0 0.75rem; background: #fff;
|
||||
}
|
||||
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; }
|
||||
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; }
|
||||
select {
|
||||
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 0 0.55rem; background: #fff;
|
||||
}
|
||||
a { color: #0f766e; font-weight: 700; }
|
||||
.receive-form { display: grid; gap: 0.85rem; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.field { display: grid; gap: 0.35rem; }
|
||||
.field > span { color: var(--muted); font-size: 0.86rem; font-weight: 600; }
|
||||
.field em { color: #ef4444; font-style: normal; }
|
||||
.field select,
|
||||
.field textarea,
|
||||
.field :deep(.money-input) {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field textarea { min-height: 5rem; resize: vertical; }
|
||||
@media (max-width: 640px) {
|
||||
.row-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<a-modal
|
||||
v-model:open="formOpen"
|
||||
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
|
||||
destroy-on-close
|
||||
width="640px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-typography-paragraph type="secondary">
|
||||
{{
|
||||
isIncomingForm
|
||||
? 'چک را از یک شخص بگیرید؛ لازم نیست به فاکتور وصل باشد.'
|
||||
: 'چکی که به شخص دیگری میپردازید را ثبت کنید.'
|
||||
}}
|
||||
</a-typography-paragraph>
|
||||
<a-alert
|
||||
v-if="formError"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="formError"
|
||||
:style="{ marginBottom: '12px' }"
|
||||
/>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item :label="isIncomingForm ? 'شخص' : 'گیرنده'" required>
|
||||
<a-select
|
||||
v-model:value="personId"
|
||||
:options="personOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="انتخاب شخص"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="currencyOptions.length > 1 ? 12 : 24">
|
||||
<a-form-item label="مبلغ" required>
|
||||
<MoneyInput v-model="amount" :decimals="0" placeholder="مبلغ چک" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="currencyOptions.length > 1" :xs="24" :md="12">
|
||||
<a-form-item label="ارز">
|
||||
<a-select
|
||||
v-model:value="currency"
|
||||
:options="currencyOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شماره چک" required>
|
||||
<a-input
|
||||
v-model:value="chequeNumber"
|
||||
:maxlength="80"
|
||||
placeholder="شماره چک"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="بانک" required>
|
||||
<a-input
|
||||
v-model:value="chequeBank"
|
||||
:maxlength="120"
|
||||
placeholder="نام بانک"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سررسید" required>
|
||||
<JalaliDateInput v-model="dueDate" required placeholder="سررسید چک" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="formStatus"
|
||||
:options="formStatusOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea
|
||||
v-model:value="notes"
|
||||
:rows="3"
|
||||
:maxlength="2000"
|
||||
placeholder="اختیاری"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-flex justify="flex-end" :gap="8">
|
||||
<a-button :disabled="saving" @click="formOpen = false">انصراف</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="submitForm">
|
||||
{{ isIncomingForm ? 'ثبت دریافت' : 'ثبت پرداخت' }}
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -5,18 +5,45 @@ import { RouterLink } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiChart,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
type UiChartOption,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Spin as ASpin,
|
||||
Statistic as AStatistic,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
RiseOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import type { CommissionEntryDto } from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
const COMMISSION_COL_LABELS = [
|
||||
'کارشناس فروش',
|
||||
'شماره و عنوان فاکتور',
|
||||
'درصد',
|
||||
'مبلغ پورسانت',
|
||||
'وضعیت',
|
||||
'تاریخ ثبت',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('commissions', [...COMMISSION_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'commissions');
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const items = ref<CommissionEntryDto[]>([]);
|
||||
@@ -25,62 +52,45 @@ const posted = computed(() => items.value.filter((r) => r.status === 'posted'));
|
||||
const total = computed(() => posted.value.reduce((sum, r) => sum + r.amount, 0));
|
||||
const avgCommission = computed(() => (posted.value.length ? Math.round(total.value / posted.value.length) : 0));
|
||||
|
||||
const chartOption = computed<UiChartOption>(() => {
|
||||
const chartData = computed(() => {
|
||||
const userMap = new Map<string, number>();
|
||||
for (const r of posted.value) {
|
||||
userMap.set(r.userName, (userMap.get(r.userName) || 0) + r.amount);
|
||||
}
|
||||
const names = Array.from(userMap.keys());
|
||||
const values = Array.from(userMap.values());
|
||||
const rows = Array.from(userMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const max = rows.length ? rows[0].value : 0;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
percent: max > 0 ? Math.round((row.value / max) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: unknown) => {
|
||||
const p = (params as Array<{ name: string; value: number }>)[0];
|
||||
if (!p) return '';
|
||||
return `${p.name}: <b>${formatMoney(p.value)}</b>`;
|
||||
},
|
||||
const columns = computed<TableColumnsType<CommissionEntryDto>>(() => {
|
||||
const all: TableColumnsType<CommissionEntryDto> = [
|
||||
{ title: 'کارشناس فروش', key: 'کارشناس فروش' },
|
||||
{ title: 'شماره و عنوان فاکتور', key: 'شماره و عنوان فاکتور' },
|
||||
{
|
||||
title: 'درصد',
|
||||
key: 'درصد',
|
||||
customRender: ({ record }) => `${toFaDigits(String(record.percent))}٪`,
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
left: 60,
|
||||
containLabel: true,
|
||||
{ title: 'مبلغ پورسانت', key: 'مبلغ پورسانت' },
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{
|
||||
title: 'تاریخ ثبت',
|
||||
key: 'تاریخ ثبت',
|
||||
customRender: ({ record }) => formatJalaliNumeric(new Date(record.createdAt)),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
color: '#64748b',
|
||||
formatter: (val: number) => (val >= 1000000 ? `${toFaDigits(String(val / 1000000))}M` : toFaDigits(String(val))),
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'پورسانت',
|
||||
type: 'bar',
|
||||
barWidth: '36%',
|
||||
data: values,
|
||||
itemStyle: {
|
||||
color: '#0f766e',
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!COMMISSION_COL_LABELS.includes(key as (typeof COMMISSION_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
@@ -100,209 +110,130 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page commissions-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="پورسانت فروش"
|
||||
description="گزارش پورسانت ثبتشده برای کارشناسان فروش متصل به فاکتورهای صادره"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" :disabled="loading" @click="load">
|
||||
<template #icon>
|
||||
<UiIcon name="refresh" />
|
||||
</template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<UiCard class="kpi-card">
|
||||
<div class="kpi-icon-wrap primary">
|
||||
<UiIcon name="dollar" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<span class="kpi-label">جمع پورسانت ثبتشده</span>
|
||||
<strong class="kpi-val">{{ formatMoney(total) }}</strong>
|
||||
<small class="kpi-sub">{{ toFaDigits(String(posted.length)) }} فاکتور تایید شده</small>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard class="kpi-card">
|
||||
<div class="kpi-icon-wrap success">
|
||||
<UiIcon name="document" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<span class="kpi-label">تعداد تراکنشها</span>
|
||||
<strong class="kpi-val">{{ toFaDigits(String(items.length)) }}</strong>
|
||||
<small class="kpi-sub">{{ toFaDigits(String(posted.length)) }} فعال / {{ toFaDigits(String(items.length - posted.length)) }} باطل</small>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard class="kpi-card">
|
||||
<div class="kpi-icon-wrap warning">
|
||||
<UiIcon name="trend" />
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<span class="kpi-label">میانگین هر فاکتور</span>
|
||||
<strong class="kpi-val">{{ formatMoney(avgCommission) }}</strong>
|
||||
<small class="kpi-sub">بر اساس موارد فعال</small>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard v-if="posted.length > 0" title="سهم پورسانت به تفکیک کارشناسان" class="chart-card">
|
||||
<UiChart :option="chartOption" height="260px" />
|
||||
</UiCard>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading && !items.length"
|
||||
title="پورسانتی ثبت نشده است"
|
||||
description="هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
|
||||
icon="document"
|
||||
/>
|
||||
|
||||
<div v-else class="crm-table-container">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="commissions" align="end" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">پورسانت فروش</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
گزارش پورسانت ثبتشده برای کارشناسان فروش متصل به فاکتورهای صادره
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<table v-table-cols="'commissions'" class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کارشناس فروش</th>
|
||||
<th>شماره و عنوان فاکتور</th>
|
||||
<th>درصد</th>
|
||||
<th>مبلغ پورسانت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ ثبت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td class="font-medium text-primary">{{ row.userName }}</td>
|
||||
<td>
|
||||
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }" class="table-link">
|
||||
{{ row.invoiceNumber }}
|
||||
</RouterLink>
|
||||
<span v-if="row.invoiceTitle" class="invoice-sub-title">{{ row.invoiceTitle }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<UiBadge variant="neutral">{{ toFaDigits(String(row.percent)) }}٪</UiBadge>
|
||||
</td>
|
||||
<td class="amount-cell">{{ formatMoney(row.amount, row.currency) }}</td>
|
||||
<td>
|
||||
<UiBadge :variant="row.status === 'posted' ? 'success' : 'neutral'">
|
||||
{{ row.status === 'posted' ? 'ثبتشده' : 'باطل' }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<a-button :disabled="loading" :loading="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="جمع پورسانت ثبتشده" :value="formatMoney(total)">
|
||||
<template #prefix><WalletOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(String(posted.length)) }} فاکتور تایید شده
|
||||
</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="تعداد تراکنشها" :value="toFaDigits(String(items.length))">
|
||||
<template #prefix><FileTextOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(String(posted.length)) }} فعال /
|
||||
{{ toFaDigits(String(items.length - posted.length)) }} باطل
|
||||
</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="میانگین هر فاکتور" :value="formatMoney(avgCommission)">
|
||||
<template #prefix><RiseOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-typography-text type="secondary">بر اساس موارد فعال</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card v-if="posted.length > 0" title="سهم پورسانت به تفکیک کارشناسان" :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<div v-for="row in chartData" :key="row.name">
|
||||
<a-flex justify="space-between" align="center" :gap="12">
|
||||
<a-typography-text strong>{{ row.name }}</a-typography-text>
|
||||
<a-typography-text>{{ formatMoney(row.value) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress
|
||||
:percent="row.percent"
|
||||
:format="(p?: number) => `${toFaDigits(String(p ?? 0))}٪`"
|
||||
/>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(String(items.length)) }} پورسانت
|
||||
</a-typography-text>
|
||||
<TableColumnsMenu table-key="commissions" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty
|
||||
description="پورسانتی ثبت نشده است؛ هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
|
||||
/>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'کارشناس فروش'">
|
||||
<a-typography-text strong>{{ record.userName }}</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شماره و عنوان فاکتور'">
|
||||
<a-flex vertical :gap="0">
|
||||
<RouterLink
|
||||
:to="{ name: 'invoice-detail', params: { id: record.invoiceId } }"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a-typography-link :href="href" @click="navigate">
|
||||
{{ record.invoiceNumber }}
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
<a-typography-text v-if="record.invoiceTitle" type="secondary">
|
||||
{{ record.invoiceTitle }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ پورسانت'">
|
||||
<a-typography-text strong>
|
||||
{{ formatMoney(record.amount, record.currency) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag :color="record.status === 'posted' ? 'success' : 'default'" :bordered="false">
|
||||
{{ record.status === 'posted' ? 'ثبتشده' : 'باطل' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.commissions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.kpi-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kpi-icon-wrap.primary {
|
||||
background: var(--color-brand-50, #f0fdfa);
|
||||
color: var(--color-brand-600, #0f766e);
|
||||
}
|
||||
|
||||
.kpi-icon-wrap.success {
|
||||
background: var(--color-success-bg, #f0fdf4);
|
||||
color: var(--color-success-text, #15803d);
|
||||
}
|
||||
|
||||
.kpi-icon-wrap.warning {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning-text, #b45309);
|
||||
}
|
||||
|
||||
.kpi-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.kpi-val {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.kpi-sub {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-weight: 700;
|
||||
color: var(--color-brand-700, #0f766e);
|
||||
}
|
||||
|
||||
.table-link {
|
||||
color: var(--color-brand-600, #0f766e);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.table-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.invoice-sub-title {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+760
-1581
File diff suppressed because it is too large
Load Diff
+152
-77
@@ -1,18 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Spin as ASpin } from 'ant-design-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import { formatJalaliDateTime, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
|
||||
import HomeHero from '@/components/dashboard/HomeHero.vue';
|
||||
import HomeAlertsSection from '@/components/dashboard/HomeAlertsSection.vue';
|
||||
import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue';
|
||||
import HomeBirthdayStrip from '@/components/dashboard/HomeBirthdayStrip.vue';
|
||||
import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue';
|
||||
import HomeCustomerGrowth from '@/components/dashboard/HomeCustomerGrowth.vue';
|
||||
import HomeDealOutcomeChart from '@/components/dashboard/HomeDealOutcomeChart.vue';
|
||||
import HomeFunnelChart from '@/components/dashboard/HomeFunnelChart.vue';
|
||||
import HomeSourcesChart from '@/components/dashboard/HomeSourcesChart.vue';
|
||||
import HomeLostReasonsChart from '@/components/dashboard/HomeLostReasonsChart.vue';
|
||||
import HomeActivityMixChart from '@/components/dashboard/HomeActivityMixChart.vue';
|
||||
import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue';
|
||||
import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue';
|
||||
import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue';
|
||||
import HomeTopCustomers from '@/components/dashboard/HomeTopCustomers.vue';
|
||||
import { Alert as AAlert } from 'ant-design-vue';
|
||||
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -22,10 +31,21 @@ import type {
|
||||
PaginatedResponse,
|
||||
PipelineBoardDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
HOME_ACTIVITIES_MOCK,
|
||||
HOME_DASHBOARD_MOCK,
|
||||
HOME_PIPELINE_MOCK,
|
||||
isHomeDashboardEmpty,
|
||||
} from './home-dashboard-mock';
|
||||
|
||||
/** پیشنمایش UI با دیتای ماک — برای اتصال به API واقعی بگذارید false */
|
||||
const FORCE_HOME_MOCK = true;
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const usingMock = ref(false);
|
||||
const showMockBanner = ref(true);
|
||||
const home = ref<HomeDashboardDto | null>(null);
|
||||
const activities = ref<ActivityDto[]>([]);
|
||||
const board = ref<PipelineBoardDto | null>(null);
|
||||
@@ -62,9 +82,22 @@ function formatKpiValue(key: string, value: number) {
|
||||
if (key === 'sales') {
|
||||
return formatMoney(value, home.value?.currency ?? 'IRR');
|
||||
}
|
||||
if (key === 'win-rate') {
|
||||
return `${toFaDigits(value)}٪`;
|
||||
}
|
||||
return toFaDigits(value);
|
||||
}
|
||||
|
||||
const emptySalesSummary = {
|
||||
openCount: 0,
|
||||
wonCount: 0,
|
||||
lostCount: 0,
|
||||
openAmount: 0,
|
||||
wonAmount: 0,
|
||||
lostAmount: 0,
|
||||
winRate: 0,
|
||||
};
|
||||
|
||||
function formatChange(value: number | null) {
|
||||
if (value == null) return '';
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
@@ -141,9 +174,21 @@ function onAskHotkey(e: KeyboardEvent) {
|
||||
document.getElementById('home-hero-ask')?.focus();
|
||||
}
|
||||
|
||||
function applyHomeMock() {
|
||||
home.value = HOME_DASHBOARD_MOCK;
|
||||
activities.value = HOME_ACTIVITIES_MOCK;
|
||||
board.value = HOME_PIPELINE_MOCK;
|
||||
usingMock.value = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onAskHotkey);
|
||||
loading.value = true;
|
||||
if (FORCE_HOME_MOCK) {
|
||||
applyHomeMock();
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [homeRes, actRes, boardRes] = await Promise.all([
|
||||
api.get<HomeDashboardDto>('/reports/home'),
|
||||
@@ -152,11 +197,16 @@ onMounted(async () => {
|
||||
}),
|
||||
api.get<PipelineBoardDto>('/pipelines/board'),
|
||||
]);
|
||||
home.value = homeRes.data;
|
||||
activities.value = actRes.data.items;
|
||||
board.value = boardRes.data;
|
||||
if (isHomeDashboardEmpty(homeRes.data)) {
|
||||
applyHomeMock();
|
||||
} else {
|
||||
home.value = homeRes.data;
|
||||
activities.value = actRes.data.items;
|
||||
board.value = boardRes.data;
|
||||
usingMock.value = false;
|
||||
}
|
||||
} catch {
|
||||
home.value = null;
|
||||
applyHomeMock();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -168,12 +218,11 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboard">
|
||||
<div class="flex flex-col gap-6">
|
||||
<HomeHero
|
||||
:greeting="greeting"
|
||||
:first-name="firstName"
|
||||
:user-initial="userInitial"
|
||||
:alerts="home?.alerts ?? []"
|
||||
:asking="asking || assistantBusy"
|
||||
:ask-answer="askAnswer"
|
||||
:ask-error="askError"
|
||||
@@ -182,7 +231,19 @@ onUnmounted(() => {
|
||||
@ask="onHeroAsk"
|
||||
/>
|
||||
|
||||
<p v-if="loading" class="muted center">در حال بارگذاری داشبورد…</p>
|
||||
<a-alert
|
||||
v-if="usingMock && showMockBanner"
|
||||
type="info"
|
||||
closable
|
||||
class="rounded-xl"
|
||||
message="پیشنمایش با دیتای ماک"
|
||||
description="برای دیدن دادهٔ واقعی، FORCE_HOME_MOCK را در HomeView روی false بگذارید."
|
||||
@close="showMockBanner = false"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-[120px]">
|
||||
<a-spin tip="در حال بارگذاری داشبورد…" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<HomeKpiRow
|
||||
@@ -191,80 +252,94 @@ onUnmounted(() => {
|
||||
:format-value="formatKpiValue"
|
||||
:format-change="formatChange"
|
||||
/>
|
||||
|
||||
<HomeBirthdayStrip />
|
||||
|
||||
<div class="mid-grid">
|
||||
<HomeSalesTrend
|
||||
:trend="home?.salesTrend ?? []"
|
||||
:change-percent="home?.kpis?.[0]?.changePercent ?? null"
|
||||
:format-change="formatChange"
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
<div v-if="home?.alerts?.length" class="lg:col-span-7 min-w-0">
|
||||
<HomeAlertsSection :alerts="home.alerts" />
|
||||
</div>
|
||||
<div :class="home?.alerts?.length ? 'lg:col-span-5' : 'lg:col-span-12'" class="min-w-0">
|
||||
<HomeAiAssistant
|
||||
id="home-ai-assistant"
|
||||
ref="assistantRef"
|
||||
v-model:ask-query="askQuery"
|
||||
:asking="asking"
|
||||
:answer="askAnswer"
|
||||
:error="askError"
|
||||
:sources="askSources"
|
||||
@ask="submitAsk"
|
||||
@clear="clearAsk"
|
||||
@busy="assistantBusy = $event"
|
||||
@notice="onAssistantNotice"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200/80 pb-3">
|
||||
<div class="w-1.5 h-6 rounded-full bg-emerald-600"></div>
|
||||
<div>
|
||||
<h2 class="text-lg font-extrabold text-slate-800">تحلیل فروش</h2>
|
||||
<p class="text-xs text-slate-500">روند، نتیجه معاملات و قیف مراحل باز</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div class="lg:col-span-7 min-w-0">
|
||||
<HomeSalesTrend
|
||||
:trend="home?.salesTrend ?? []"
|
||||
:change-percent="home?.kpis?.[0]?.changePercent ?? null"
|
||||
:format-change="formatChange"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
</div>
|
||||
<div class="lg:col-span-5 min-w-0">
|
||||
<HomeDealOutcomeChart
|
||||
:summary="home?.salesSummary ?? emptySalesSummary"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div class="lg:col-span-7 min-w-0">
|
||||
<HomeCustomerGrowth :series="home?.customerGrowth ?? []" />
|
||||
</div>
|
||||
<div class="lg:col-span-5 min-w-0">
|
||||
<HomeFunnelChart
|
||||
:funnel="home?.funnel ?? []"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200/80 pb-3">
|
||||
<div class="w-1.5 h-6 rounded-full bg-teal-600"></div>
|
||||
<div>
|
||||
<h2 class="text-lg font-extrabold text-slate-800">منابع و عملیات</h2>
|
||||
<p class="text-xs text-slate-500">ورود مشتری، دلایل باخت و ترکیب پیگیریها</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<HomeSourcesChart :sources="home?.sources ?? []" />
|
||||
<HomeLostReasonsChart
|
||||
:reasons="home?.lostReasons ?? []"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
<HomeSourcesChart :sources="home?.sources ?? []" />
|
||||
<HomeAiAssistant
|
||||
id="home-ai-assistant"
|
||||
ref="assistantRef"
|
||||
v-model:ask-query="askQuery"
|
||||
:asking="asking"
|
||||
:answer="askAnswer"
|
||||
:error="askError"
|
||||
:sources="askSources"
|
||||
@ask="submitAsk"
|
||||
@clear="clearAsk"
|
||||
@busy="assistantBusy = $event"
|
||||
@notice="onAssistantNotice"
|
||||
/>
|
||||
</div>
|
||||
<HomeActivityMixChart :items="home?.activityByType ?? []" />
|
||||
</section>
|
||||
|
||||
<div class="bottom-grid">
|
||||
<HomeRecentActivities :activities="activities" :format-when="formatWhen" />
|
||||
<HomeMiniPipeline :board="board" :format-money="formatMoney" />
|
||||
<HomeTopCustomers :customers="home?.topCustomers ?? []" />
|
||||
</div>
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
<div class="lg:col-span-6 min-w-0 flex">
|
||||
<HomeRecentActivities class="w-full" :activities="activities" :format-when="formatWhen" />
|
||||
</div>
|
||||
<div class="lg:col-span-6 min-w-0 flex">
|
||||
<HomeTopCustomers class="w-full" :customers="home?.topCustomers ?? []" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeMiniPipeline :board="board" :format-money="formatMoney" />
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.15rem;
|
||||
min-width: 0;
|
||||
/* تا رسیدن داده، ردیفها ارتفاع viewport را پر نکنند */
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.dashboard > * {
|
||||
min-width: 0;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.mid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr 0.88fr;
|
||||
gap: 0.8rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bottom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.35fr 0.78fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.mid-grid,
|
||||
.bottom-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+256
-547
@@ -9,8 +9,38 @@ import type { LoginChallengeResponse, LoginOrganizationChoiceResponse } from '@m
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import { brand } from '@mento/shared';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Grid,
|
||||
Input as AInput,
|
||||
InputPassword as AInputPassword,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
LockOutlined,
|
||||
PhoneOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'password', label: 'ورود با رمز' },
|
||||
{ value: 'otp', label: 'ورود با کد یکبارمصرف' },
|
||||
];
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -23,9 +53,15 @@ const otpStep = ref<'mobile' | 'code'>('mobile');
|
||||
const identifier = ref('');
|
||||
const password = ref('');
|
||||
const mobile = ref('');
|
||||
const showPassword = ref(false);
|
||||
const remember = ref(true);
|
||||
const otpCode = ref('');
|
||||
/** Ant Form فقط وقتی `:model` داشته باشد `@finish` را صدا میزند */
|
||||
const passwordFormModel = computed(() => ({
|
||||
identifier: identifier.value,
|
||||
password: password.value,
|
||||
}));
|
||||
const otpMobileFormModel = computed(() => ({ mobile: mobile.value }));
|
||||
const otpCodeFormModel = computed(() => ({ otpCode: otpCode.value }));
|
||||
const challenge = ref<LoginChallengeResponse | null>(null);
|
||||
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
|
||||
const error = ref(
|
||||
@@ -193,565 +229,238 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<AuthBrandPanel />
|
||||
<a-flex
|
||||
vertical
|
||||
:style="{ minHeight: '100dvh', background: token.colorBgContainer }"
|
||||
>
|
||||
<a-row :style="{ flex: 1, minHeight: '100dvh' }">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-flex
|
||||
vertical
|
||||
justify="center"
|
||||
:style="{ minHeight: '100%', padding: token.paddingLG }"
|
||||
>
|
||||
<a-card :bordered="false" :style="{ width: 'min(440px, 100%)', margin: '0 auto' }">
|
||||
<a-flex v-if="!screens.lg" vertical align="center" :gap="4" :style="{ marginBottom: '24px' }">
|
||||
<MentoLogo :size="52" :show-text="false" />
|
||||
<BrandName />
|
||||
<a-typography-text type="secondary">{{ brand.taglineFa }}</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<section class="form-panel">
|
||||
<header class="mobile-brand">
|
||||
<MentoLogo :size="52" :show-text="false" />
|
||||
<BrandName />
|
||||
<span>{{ brand.taglineFa }}</span>
|
||||
</header>
|
||||
<a-typography-title :level="2" :style="{ margin: 0 }">خوش آمدید 👋</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
{{
|
||||
pendingChoice
|
||||
? 'در کدام محیط میخواهید وارد شوید؟'
|
||||
: 'برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید'
|
||||
}}
|
||||
</a-typography-text>
|
||||
|
||||
<div class="form-inner">
|
||||
<h1>خوش آمدید 👋</h1>
|
||||
<p class="lead">
|
||||
{{
|
||||
pendingChoice
|
||||
? 'در کدام محیط میخواهید وارد شوید؟'
|
||||
: 'برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید'
|
||||
}}
|
||||
</p>
|
||||
<template v-if="pendingChoice">
|
||||
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<a-flex>
|
||||
<a-button type="link" @click="cancelOrgChoice">بازگشت</a-button>
|
||||
</a-flex>
|
||||
<a-card
|
||||
v-for="org in pendingChoice.organizations"
|
||||
:key="org.id"
|
||||
hoverable
|
||||
@click="!loading && pickOrganization(org.id)"
|
||||
>
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong>{{ org.name }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<template v-if="pendingChoice">
|
||||
<button type="button" class="back" @click="cancelOrgChoice">بازگشت</button>
|
||||
<div class="org-list">
|
||||
<button
|
||||
v-for="org in pendingChoice.organizations"
|
||||
:key="org.id"
|
||||
type="button"
|
||||
class="org-card"
|
||||
:disabled="loading"
|
||||
@click="pickOrganization(org.id)"
|
||||
<template v-else>
|
||||
<a-segmented
|
||||
:value="mode"
|
||||
:options="modeOptions"
|
||||
block
|
||||
:style="{ marginTop: '16px', marginBottom: '16px' }"
|
||||
@update:value="(v) => switchMode(v as 'password' | 'otp')"
|
||||
/>
|
||||
|
||||
<a-form
|
||||
v-if="mode === 'password'"
|
||||
layout="vertical"
|
||||
:model="passwordFormModel"
|
||||
@finish="submitPasswordLogin"
|
||||
>
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<div class="mode-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'password'"
|
||||
:class="{ on: mode === 'password' }"
|
||||
@click="switchMode('password')"
|
||||
>
|
||||
ورود با رمز
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'otp'"
|
||||
:class="{ on: mode === 'otp' }"
|
||||
@click="switchMode('otp')"
|
||||
>
|
||||
ورود با کد یکبارمصرف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'password'">
|
||||
<form class="form" @submit.prevent="submitPasswordLogin">
|
||||
<div class="field">
|
||||
<label for="identifier">ایمیل یا شماره موبایل</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="identifier"
|
||||
v-model="identifier"
|
||||
type="text"
|
||||
required
|
||||
<a-form-item
|
||||
label="ایمیل یا شماره موبایل"
|
||||
name="identifier"
|
||||
:rules="[{ required: true, message: 'ایمیل یا موبایل را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="identifier"
|
||||
autocomplete="username"
|
||||
placeholder="ایمیل یا شماره موبایل خود را وارد کنید"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="user" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور</label>
|
||||
<div class="input-wrap has-eye">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: 'رمز عبور را وارد کنید' }]"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="lock" :size="18" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-checkbox v-model:checked="remember">مرا به خاطر بسپار</a-checkbox>
|
||||
<a-typography-text type="secondary">
|
||||
رمز عبور خود را فراموش کردهاید؟
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
{{ loading ? 'در حال ورود…' : 'ورود' }}
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<div class="row-actions">
|
||||
<label class="remember">
|
||||
<input v-model="remember" type="checkbox" />
|
||||
<span>مرا به خاطر بسپار</span>
|
||||
</label>
|
||||
<span class="forgot muted">رمز عبور خود را فراموش کردهاید؟</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ورود…' : 'ورود' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else-if="otpStep === 'mobile'">
|
||||
<form class="form" @submit.prevent="submitOtpMobile">
|
||||
<div class="field">
|
||||
<label for="mobile">شماره موبایل</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="mobile"
|
||||
v-model="mobile"
|
||||
<a-form
|
||||
v-else-if="otpStep === 'mobile'"
|
||||
layout="vertical"
|
||||
:model="otpMobileFormModel"
|
||||
@finish="submitOtpMobile"
|
||||
>
|
||||
<a-form-item
|
||||
label="شماره موبایل"
|
||||
name="mobile"
|
||||
:rules="[{ required: true, message: 'شماره موبایل را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="mobile"
|
||||
type="tel"
|
||||
required
|
||||
autocomplete="tel"
|
||||
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||
class="ltr-value"
|
||||
>
|
||||
<template #prefix><PhoneOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
{{ loading ? 'در حال ارسال کد…' : 'ارسال کد' }}
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<template v-else>
|
||||
<a-flex vertical :gap="8">
|
||||
<a-flex>
|
||||
<a-button type="link" @click="backToOtpMobile()">بازگشت</a-button>
|
||||
</a-flex>
|
||||
<a-typography-text type="secondary">{{ otpHint }}</a-typography-text>
|
||||
<a-typography-text :type="otpSecondsLeft <= 15 ? 'danger' : undefined">
|
||||
اعتبار کد:
|
||||
<strong dir="ltr">{{ otpTimerLabel }}</strong>
|
||||
</a-typography-text>
|
||||
<a-alert
|
||||
v-if="challenge?.devCode"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="`کد توسعه: ${challenge.devCode}`"
|
||||
/>
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="phone" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ارسال کد…' : 'ارسال کد' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</a-flex>
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="otpCodeFormModel"
|
||||
:style="{ marginTop: '16px' }"
|
||||
@finish="submitOtpCode"
|
||||
>
|
||||
<a-form-item
|
||||
label="کد یکبارمصرف"
|
||||
name="otpCode"
|
||||
:rules="[{ required: true, message: 'کد را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="otpCode"
|
||||
inputmode="numeric"
|
||||
:maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="کد را وارد کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="loading || otpSecondsLeft <= 0"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
{{ loading ? 'در حال تأیید…' : 'تأیید و ورود' }}
|
||||
</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button type="button" class="back" @click="backToOtpMobile()">بازگشت</button>
|
||||
<p class="otp-lead">{{ otpHint }}</p>
|
||||
<p class="otp-timer" :class="{ urgent: otpSecondsLeft <= 15 }" aria-live="polite">
|
||||
اعتبار کد: <strong>{{ otpTimerLabel }}</strong>
|
||||
</p>
|
||||
<p v-if="challenge?.devCode" class="dev-hint">
|
||||
کد توسعه: <strong>{{ challenge.devCode }}</strong>
|
||||
</p>
|
||||
<form class="form" @submit.prevent="submitOtpCode">
|
||||
<div class="field">
|
||||
<label for="otp">کد یکبارمصرف</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="otp"
|
||||
v-model="otpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="8"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
placeholder="کد را وارد کنید"
|
||||
class="ltr-value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading || otpSecondsLeft <= 0">
|
||||
{{ loading ? 'در حال تأیید…' : 'تأیید و ورود' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
<a-flex justify="center" :gap="4" :style="{ marginTop: '24px' }">
|
||||
<a-typography-text type="secondary">حساب کاربری ندارید؟</a-typography-text>
|
||||
<RouterLink to="/register" custom v-slot="{ href, navigate }">
|
||||
<a-typography-link :href="href" strong @click="navigate">
|
||||
ثبتنام کنید
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="0" :lg="12">
|
||||
<AuthBrandPanel />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<p class="switch">
|
||||
حساب کاربری ندارید؟
|
||||
<RouterLink to="/register">ثبتنام کنید</RouterLink>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="auth-footer">
|
||||
<p>۱۴۰۵ {{ brand.nameFa }}. تمامی حقوق محفوظ است.</p>
|
||||
<nav>
|
||||
<a href="#">حریم خصوصی</a>
|
||||
<span>|</span>
|
||||
<a href="#">شرایط و قوانین</a>
|
||||
<span>|</span>
|
||||
<a href="#">تماس با ما</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
<a-flex
|
||||
v-if="!screens.lg"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
:gap="8"
|
||||
:style="{ padding: token.padding, width: 'min(1040px, 100%)', margin: '0 auto' }"
|
||||
>
|
||||
<a-typography-text type="secondary">
|
||||
۱۴۰۵ {{ brand.nameFa }}. تمامی حقوق محفوظ است.
|
||||
</a-typography-text>
|
||||
<a-flex :gap="8" align="center">
|
||||
<a-typography-link href="#">حریم خصوصی</a-typography-link>
|
||||
<a-typography-text type="secondary">|</a-typography-text>
|
||||
<a-typography-link href="#">شرایط و قوانین</a-typography-link>
|
||||
<a-typography-text type="secondary">|</a-typography-text>
|
||||
<a-typography-link href="#">تماس با ما</a-typography-link>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
|
||||
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
|
||||
#f5fbfb;
|
||||
background-size: auto, 20px 20px, auto;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
flex: 1;
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
min-height: min(680px, calc(100dvh - 5.5rem));
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.2rem, 3vw, 2.4rem);
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.4rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.mobile-brand strong {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-brand span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
width: min(420px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: clamp(1.45rem, 3vw, 1.85rem);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.1rem;
|
||||
padding: 0.3rem;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--line) 55%, white);
|
||||
}
|
||||
|
||||
.mode-tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.mode-tabs button.on {
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.form input {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form input::placeholder {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
color: #cdd4de;
|
||||
}
|
||||
|
||||
.form input.ltr-value:not(:placeholder-shown) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrap input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 2.6rem 0.85rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-wrap.has-eye input {
|
||||
padding-left: 2.7rem;
|
||||
}
|
||||
|
||||
.input-wrap input:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.field-ico {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.45rem;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forgot {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.muted {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin: 1.35rem 0 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.switch a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.back {
|
||||
align-self: flex-start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.otp-lead {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.otp-timer {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text);
|
||||
font-size: 0.92rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.otp-timer strong {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.otp-timer.urgent {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dev-hint {
|
||||
margin: -0.4rem 0 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: #0f766e;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.org-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.org-card {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
text-align: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-card:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, white);
|
||||
}
|
||||
|
||||
.org-card strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.org-card span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0.85rem auto 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.auth-footer nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.auth-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
|
||||
grid-template-areas: "form brand";
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
grid-area: form;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-shell :deep(.brand-panel) {
|
||||
grid-area: brand;
|
||||
height: 100dvh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
margin: 0 auto;
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+967
-2061
File diff suppressed because it is too large
Load Diff
@@ -498,10 +498,11 @@ onMounted(load);
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="موبایل">
|
||||
<a-space-compact block style="width: 100%">
|
||||
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
|
||||
<a-space-compact block>
|
||||
<a-input v-model:value="mobile" type="tel" />
|
||||
<a-button
|
||||
:disabled="pickingContact || saving"
|
||||
:style="{ flex: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }"
|
||||
@click="fillFromPhoneContact"
|
||||
>
|
||||
<template #icon><PhoneOutlined /></template>
|
||||
|
||||
@@ -6,23 +6,37 @@ import { api } from '@/lib/api';
|
||||
import { currencySuffix } from '@/lib/money';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSegmented,
|
||||
UiSelect,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
Alert as AAlert,
|
||||
Breadcrumb as ABreadcrumb,
|
||||
BreadcrumbItem as ABreadcrumbItem,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Switch as ASwitch,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CheckOutlined } from '@ant-design/icons-vue';
|
||||
import type {
|
||||
CreateProductRequest,
|
||||
OrganizationDto,
|
||||
ProductDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const CATEGORIES = [
|
||||
'کارساخته',
|
||||
'طلا و جواهر',
|
||||
@@ -35,6 +49,9 @@ const CATEGORIES = [
|
||||
|
||||
const UNITS = ['عدد', 'گرم', 'مثقال', 'سوت', 'کیلوگرم', 'ساعت', 'بسته'];
|
||||
|
||||
const categoryOptions = CATEGORIES.map((c) => ({ value: c, label: c }));
|
||||
const unitOptions = UNITS.map((u) => ({ value: u, label: u }));
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
embedded?: boolean;
|
||||
@@ -78,6 +95,10 @@ const country = ref('');
|
||||
const tagInput = ref('');
|
||||
const tags = ref<string[]>([]);
|
||||
|
||||
const currencyOptions = computed(() =>
|
||||
enabledCurrencies.value.map((code) => ({ value: code, label: code })),
|
||||
);
|
||||
|
||||
const title = computed(() => (isEdit.value ? 'ویرایش محصول' : 'افزودن محصول جدید'));
|
||||
const submitLabel = computed(() =>
|
||||
saving.value
|
||||
@@ -105,6 +126,10 @@ function onTagKey(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function onCategoryChange(value: unknown) {
|
||||
category.value = value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
async function loadOrg() {
|
||||
const { data } = await api.get<OrganizationDto>('/organizations/current');
|
||||
enabledCurrencies.value = data.enabledCurrencies?.length
|
||||
@@ -214,335 +239,182 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page product-form-page" :class="{ embedded }" dir="rtl">
|
||||
<UiPageHeader
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex
|
||||
v-if="!embedded"
|
||||
:title="title"
|
||||
description="تعریف و ویرایش کالا و خدمات، قیمتگذاری و مدیریت موجودی انبار"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
:gap="12"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<RouterLink :to="{ name: 'products' }" class="breadcrumb-link">
|
||||
محصولات
|
||||
</RouterLink>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span>{{ title }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<UiButton :loading="saving" @click="submit">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink :to="{ name: 'products' }">محصولات</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تعریف و ویرایش کالا و خدمات، قیمتگذاری و مدیریت موجودی انبار
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" :loading="saving" @click="submit">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
{{ submitLabel }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<form class="crm-form product-form" @submit.prevent="submit">
|
||||
<div class="form-layout-grid">
|
||||
<div class="main-column">
|
||||
<UiCard title="مشخصات اصلی کالا / خدمت" class="section-card">
|
||||
<div class="kind-selection-row">
|
||||
<span class="kind-label">ماهیت آیتم:</span>
|
||||
<UiSegmented v-model="kind" :options="kindOptions" />
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-divider orientation="right">مشخصات اصلی کالا / خدمت</a-divider>
|
||||
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام کالا یا خدمت" required class="col-span-2">
|
||||
<UiInput
|
||||
v-model="name"
|
||||
required
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="ماهیت آیتم">
|
||||
<a-segmented v-model:value="kind" :options="kindOptions" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام کالا یا خدمت" required>
|
||||
<a-input
|
||||
v-model:value="name"
|
||||
placeholder="مثلاً لپتاپ لنوو مدل ThinkPad یا پشتیبانی سالانه نرمافزار"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="دستهبندی" required>
|
||||
<UiSelect v-model="category" required>
|
||||
<option disabled value="">انتخاب دستهبندی</option>
|
||||
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="کد کالا / شناسه انبار (SKU)" required>
|
||||
<UiInput
|
||||
v-model="sku"
|
||||
required
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="دستهبندی" required>
|
||||
<a-select
|
||||
:value="category || undefined"
|
||||
:options="categoryOptions"
|
||||
placeholder="انتخاب دستهبندی"
|
||||
style="width: 100%"
|
||||
@update:value="onCategoryChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="کد کالا / شناسه انبار (SKU)" required>
|
||||
<a-input
|
||||
v-model:value="sku"
|
||||
placeholder="MNT-001"
|
||||
dir="ltr"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="قیمت پایه / واحد" required>
|
||||
<div class="price-input-wrapper">
|
||||
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ واحد" />
|
||||
<span class="currency-unit-label">
|
||||
{{ currencySuffix(currency) }}
|
||||
</span>
|
||||
</div>
|
||||
</UiField>
|
||||
|
||||
<UiField label="ارز قیمتگذاری">
|
||||
<UiSelect v-model="currency">
|
||||
<option v-for="code in enabledCurrencies" :key="code" :value="code">
|
||||
{{ code }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="واحد سنجش" required>
|
||||
<UiSelect v-model="unit" required>
|
||||
<option v-for="u in UNITS" :key="u" :value="u">{{ u }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField v-if="kind === 'product'" label="موجودی اولیه در انبار">
|
||||
<UiInput
|
||||
v-model="stockQty"
|
||||
type="number"
|
||||
min="0"
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item :label="`قیمت پایه / واحد (${currencySuffix(currency)})`" required>
|
||||
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ واحد" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="ارز قیمتگذاری">
|
||||
<a-select
|
||||
v-model:value="currency"
|
||||
:options="currencyOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="واحد سنجش" required>
|
||||
<a-select v-model:value="unit" :options="unitOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="kind === 'product'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="موجودی اولیه در انبار">
|
||||
<a-input-number
|
||||
:value="stockQty === '' ? undefined : Number(stockQty)"
|
||||
:min="0"
|
||||
placeholder="مثلاً ۱۲۰"
|
||||
dir="ltr"
|
||||
style="width: 100%"
|
||||
@update:value="(v) => (stockQty = v == null ? '' : String(v))"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-space :size="12" wrap>
|
||||
<a-switch v-model:checked="isActive" />
|
||||
<a-typography-text>
|
||||
این آیتم فعال و در فاکتورها قابل انتخاب باشد
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="status-toggle-box">
|
||||
<label class="toggle-switch-label">
|
||||
<input v-model="isActive" type="checkbox" />
|
||||
<span class="toggle-text">این آیتم فعال و در فاکتورها قابل انتخاب باشد</span>
|
||||
</label>
|
||||
</div>
|
||||
</UiCard>
|
||||
<a-divider orientation="right">شرح و توضیحات محصول</a-divider>
|
||||
|
||||
<UiCard title="شرح و توضیحات محصول" class="section-card">
|
||||
<UiField label="توضیحات کامل کالا یا شرایط خدمت">
|
||||
<UiTextarea
|
||||
v-model="description"
|
||||
:rows="4"
|
||||
placeholder="مشخصات فنی، گارانتی، اقلام همراه یا شرایط ارائه خدمت..."
|
||||
/>
|
||||
</UiField>
|
||||
</UiCard>
|
||||
</div>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="توضیحات کامل کالا یا شرایط خدمت">
|
||||
<a-textarea
|
||||
v-model:value="description"
|
||||
:rows="4"
|
||||
placeholder="مشخصات فنی، گارانتی، اقلام همراه یا شرایط ارائه خدمت..."
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="side-column">
|
||||
<UiCard title="اطلاعات تکمیلی و بازرگانی" class="section-card">
|
||||
<div class="side-form-stack">
|
||||
<UiField label="برند یا تولیدکننده">
|
||||
<UiInput v-model="brand" placeholder="نام برند تجاری" />
|
||||
</UiField>
|
||||
<a-divider orientation="right">اطلاعات تکمیلی و بازرگانی</a-divider>
|
||||
|
||||
<UiField label="مدل یا سری ساخت">
|
||||
<UiInput v-model="model" placeholder="سری و مدل" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="کشور سازنده">
|
||||
<UiInput v-model="country" placeholder="مثلاً ایران، آلمان" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="برچسبها (تگ)" hint="برای ثبت هر برچسب Enter بزنید">
|
||||
<UiInput
|
||||
v-model="tagInput"
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="برند یا تولیدکننده">
|
||||
<a-input v-model:value="brand" placeholder="نام برند تجاری" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="مدل یا سری ساخت">
|
||||
<a-input v-model:value="model" placeholder="سری و مدل" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="کشور سازنده">
|
||||
<a-input v-model:value="country" placeholder="مثلاً ایران، آلمان" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="برچسبها (تگ)" extra="برای ثبت هر برچسب Enter بزنید">
|
||||
<a-input
|
||||
v-model:value="tagInput"
|
||||
placeholder="افزودن برچسب..."
|
||||
@keydown="onTagKey"
|
||||
/>
|
||||
<div v-if="tags.length" class="tags-container">
|
||||
<UiBadge
|
||||
<a-space v-if="tags.length" :size="8" wrap :style="{ marginTop: '8px' }">
|
||||
<a-tag
|
||||
v-for="t in tags"
|
||||
:key="t"
|
||||
variant="neutral"
|
||||
class="tag-badge"
|
||||
closable
|
||||
@close="removeTag(t)"
|
||||
>
|
||||
<span>{{ t }}</span>
|
||||
<button type="button" class="tag-remove-btn" @click="removeTag(t)">×</button>
|
||||
</UiBadge>
|
||||
</div>
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
{{ t }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="form-footer-actions">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
{{ submitLabel }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<a-flex justify="flex-end" :style="{ marginTop: '8px' }">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
{{ submitLabel }}
|
||||
</a-button>
|
||||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-form-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.product-form-page.embedded {
|
||||
max-width: none;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.embedded-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-brand-600);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 var(--sp-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.product-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
.form-layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--sp-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main-column,
|
||||
.side-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.kind-selection-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
padding-bottom: var(--sp-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.kind-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.price-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.price-input-wrapper :deep(input) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.currency-unit-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-toggle-box {
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.toggle-switch-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.side-form-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.tag-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-remove-btn:hover {
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
|
||||
.form-footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,20 +8,46 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { currencySuffix, formatMoney } from '@/lib/money';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
UiBadge,
|
||||
UiBusyOverlay,
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiEmptyState,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Dropdown as ADropdown,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Menu as AMenu,
|
||||
MenuItem as AMenuItem,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Pagination as APagination,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
import {
|
||||
DownloadOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ShoppingOutlined,
|
||||
ToolOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ProductFormView from '@/views/ProductFormView.vue';
|
||||
import type { ImportContactsResult, ProductDto } from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
@@ -29,6 +55,20 @@ import { brand } from '@mento/shared';
|
||||
type SortKey = 'name' | 'newest' | 'price';
|
||||
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
const PRODUCT_COL_LABELS = [
|
||||
'نام محصول',
|
||||
'کد',
|
||||
'دستهبندی',
|
||||
'قیمت',
|
||||
'موجودی',
|
||||
'وضعیت',
|
||||
'تاریخ ایجاد',
|
||||
'عملیات',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('products', [...PRODUCT_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'products');
|
||||
|
||||
const pageSize = usePageSize('products', 10);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -42,7 +82,6 @@ const sort = ref<SortKey>('name');
|
||||
const page = ref(1);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
const menuId = ref<string | null>(null);
|
||||
const sortOpen = ref(false);
|
||||
const moreOpen = ref(false);
|
||||
const productModal = ref(false);
|
||||
const busy = ref(false);
|
||||
@@ -58,6 +97,23 @@ const categories = computed(() => {
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, 'fa'));
|
||||
});
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: '', label: 'همه دستهبندیها' },
|
||||
...categories.value.map((c) => ({ value: c, label: c })),
|
||||
]);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'همه وضعیتها' },
|
||||
{ value: 'active', label: 'فعال' },
|
||||
{ value: 'inactive', label: 'غیرفعال' },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'name', label: 'مرتبسازی: نام' },
|
||||
{ value: 'newest', label: 'مرتبسازی: جدیدترین' },
|
||||
{ value: 'price', label: 'مرتبسازی: قیمت' },
|
||||
];
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = [...products.value];
|
||||
if (q.value.trim()) {
|
||||
@@ -91,6 +147,53 @@ const allPageSelected = computed(
|
||||
pageItems.value.every((p) => selected.value.has(p.id)),
|
||||
);
|
||||
|
||||
const selectedKeys = computed({
|
||||
get: () => [...selected.value],
|
||||
set: (keys: string[]) => {
|
||||
selected.value = new Set(keys);
|
||||
},
|
||||
});
|
||||
|
||||
const columns = computed<TableColumnsType<ProductDto>>(() => {
|
||||
const all: TableColumnsType<ProductDto> = [
|
||||
{
|
||||
title: '#',
|
||||
key: 'index',
|
||||
width: 56,
|
||||
customRender: ({ index }) =>
|
||||
toFaDigits((page.value - 1) * pageSize.value + index + 1),
|
||||
},
|
||||
{ title: 'نام محصول', key: 'نام محصول' },
|
||||
{
|
||||
title: 'کد',
|
||||
key: 'کد',
|
||||
customRender: ({ record }) => ({
|
||||
children: record.sku || '—',
|
||||
props: { dir: 'ltr' },
|
||||
}),
|
||||
},
|
||||
{ title: 'دستهبندی', key: 'دستهبندی' },
|
||||
{ title: 'قیمت', key: 'قیمت' },
|
||||
{
|
||||
title: 'موجودی',
|
||||
key: 'موجودی',
|
||||
customRender: ({ record }) =>
|
||||
record.stockQty != null ? toFaDigits(record.stockQty) : '—',
|
||||
},
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'تاریخ ایجاد', key: 'تاریخ ایجاد' },
|
||||
{ title: 'عملیات', key: 'عملیات', fixed: 'left', width: 72 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (key === 'index') return true;
|
||||
if (!PRODUCT_COL_LABELS.includes(key as (typeof PRODUCT_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
watch([q, category, statusFilter, sort], () => {
|
||||
page.value = 1;
|
||||
closeMenus();
|
||||
@@ -98,7 +201,6 @@ watch([q, category, statusFilter, sort], () => {
|
||||
|
||||
function closeMenus() {
|
||||
menuId.value = null;
|
||||
sortOpen.value = false;
|
||||
moreOpen.value = false;
|
||||
}
|
||||
|
||||
@@ -119,6 +221,27 @@ function toggleSelect(id: string) {
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function onTableSelectChange(keys: (string | number)[]) {
|
||||
const next = keys.map(String);
|
||||
const prev = selected.value;
|
||||
if (Math.abs(next.length - prev.size) === 1) {
|
||||
const nextSet = new Set(next);
|
||||
for (const id of nextSet) {
|
||||
if (!prev.has(id)) {
|
||||
toggleSelect(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const id of prev) {
|
||||
if (!nextSet.has(id)) {
|
||||
toggleSelect(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedKeys.value = next;
|
||||
}
|
||||
|
||||
function formatPrice(value: number | null, currency: string) {
|
||||
if (value == null) return '—';
|
||||
return formatMoney(value, currency);
|
||||
@@ -225,22 +348,49 @@ async function toggleActive(product: ProductDto) {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeProduct(product: ProductDto) {
|
||||
function removeProduct(product: ProductDto) {
|
||||
closeMenus();
|
||||
if (!confirm(`محصول «${product.name}» حذف شود؟`)) return;
|
||||
Modal.confirm({
|
||||
title: `محصول «${product.name}» حذف شود؟`,
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await api.delete(`/products/${product.id}`);
|
||||
selected.value.delete(product.id);
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
function removeSelected() {
|
||||
closeMenus();
|
||||
const ids = Array.from(selected.value);
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`${toFaDigits(ids.length)} محصول انتخابشده حذف شوند؟`)) return;
|
||||
Modal.confirm({
|
||||
title: `${toFaDigits(ids.length)} محصول انتخابشده حذف شوند؟`,
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await Promise.all(ids.map((id) => api.delete(`/products/${id}`)));
|
||||
selected.value = new Set();
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onMoreMenu({ key }: { key: string | number }) {
|
||||
if (String(key) === 'remove-selected') removeSelected();
|
||||
else if (String(key) === 'reload') void load();
|
||||
moreOpen.value = false;
|
||||
}
|
||||
|
||||
function onRowMenu({ key }: { key: string | number }, record: ProductDto) {
|
||||
if (String(key) === 'edit') goEdit(record.id);
|
||||
else if (String(key) === 'toggle-active') void toggleActive(record);
|
||||
else if (String(key) === 'remove') removeProduct(record);
|
||||
menuId.value = null;
|
||||
}
|
||||
|
||||
function goEdit(id: string) {
|
||||
@@ -252,16 +402,20 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page" @click="closeMenus">
|
||||
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
|
||||
<UiPageHeader
|
||||
title="محصولات"
|
||||
lead="مدیریت محصولات، قیمتها و موجودی"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" :loading="pageBusy" @click="exportExcel">
|
||||
<div @click="closeMenus">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">محصولات</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
مدیریت محصولات، قیمتها و موجودی
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap @click.stop>
|
||||
<a-button :disabled="busy" :loading="pageBusy" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</UiButton>
|
||||
</a-button>
|
||||
<ExcelImportFlow
|
||||
preview-path="/products/import/preview"
|
||||
import-path="/products/import"
|
||||
@@ -271,514 +425,216 @@ onMounted(load);
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<template #default="{ loading }">
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" :loading="loading">ورود از اکسل</UiButton>
|
||||
<template #default="{ loading: importLoading }">
|
||||
<a-button :disabled="busy" :loading="importLoading">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
ورود از اکسل
|
||||
</a-button>
|
||||
</template>
|
||||
</ExcelImportFlow>
|
||||
<UiButton variant="primary" @click="productModal = true">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
<a-button type="primary" @click="productModal = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
محصول جدید
|
||||
</UiButton>
|
||||
<UiDropdownMenu v-model:open="moreOpen" align="end">
|
||||
<template #trigger="{ toggle }">
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
aria-label="عملیات بیشتر"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots" :size="18" />
|
||||
</UiButton>
|
||||
</template>
|
||||
<button type="button" :disabled="!selected.size" @click="removeSelected">
|
||||
</a-button>
|
||||
<a-dropdown v-model:open="moreOpen" trigger="click">
|
||||
<a-button aria-label="عملیات بیشتر">
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="onMoreMenu">
|
||||
<a-menu-item key="remove-selected" :disabled="!selected.size">
|
||||
حذف انتخابشدهها
|
||||
</button>
|
||||
<button type="button" @click="load">بروزرسانی لیست</button>
|
||||
</UiDropdownMenu>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="reload">بروزرسانی لیست</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div class="crm-toolbar" @click.stop>
|
||||
<div class="search-box">
|
||||
<UiInput
|
||||
v-model="q"
|
||||
type="search"
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @click.stop>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12" :lg="9">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو در نام، کد یا توضیحات..."
|
||||
>
|
||||
<template #prefix>
|
||||
<UiIcon name="search" :size="16" />
|
||||
</template>
|
||||
</UiInput>
|
||||
</div>
|
||||
<UiSelect v-model="category">
|
||||
<option value="">همه دستهبندیها</option>
|
||||
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
|
||||
</UiSelect>
|
||||
<UiSelect v-model="statusFilter">
|
||||
<option value="all">همه وضعیتها</option>
|
||||
<option value="active">فعال</option>
|
||||
<option value="inactive">غیرفعال</option>
|
||||
</UiSelect>
|
||||
<UiDropdownMenu v-model:open="sortOpen" align="end">
|
||||
<template #trigger="{ toggle }">
|
||||
<UiButton variant="outline" @click.stop="toggle">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
مرتبسازی
|
||||
</UiButton>
|
||||
</template>
|
||||
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'">
|
||||
نام
|
||||
</button>
|
||||
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'">
|
||||
جدیدترین
|
||||
</button>
|
||||
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'">
|
||||
قیمت
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Desktop table -->
|
||||
<div class="tc-row desk">
|
||||
<TableColumnsMenu table-key="products" align="end" />
|
||||
</div>
|
||||
<div class="table-wrap desk">
|
||||
<table v-table-cols="'products'" class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allPageSelected"
|
||||
@change="toggleSelectAll"
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="5">
|
||||
<a-form-item label="دستهبندی">
|
||||
<a-select
|
||||
v-model:value="category"
|
||||
:options="categoryOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</th>
|
||||
<th class="num-col">#</th>
|
||||
<th>نام محصول</th>
|
||||
<th>کد</th>
|
||||
<th>دستهبندی</th>
|
||||
<th>قیمت</th>
|
||||
<th>موجودی</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ ایجاد</th>
|
||||
<th class="ops">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(p, idx) in pageItems" :key="p.id">
|
||||
<td class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected.has(p.id)"
|
||||
@change="toggleSelect(p.id)"
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="5">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</td>
|
||||
<td class="num-col">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</td>
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<span class="thumb" :class="p.kind">
|
||||
<UiIcon :name="p.kind === 'service' ? 'briefcase' : 'box'" :size="20" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span v-if="p.description" class="desc">{{ p.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="sku" dir="ltr">{{ p.sku || '—' }}</td>
|
||||
<td>
|
||||
<UiBadge v-if="p.category" variant="neutral" size="sm">{{ p.category }}</UiBadge>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
<td class="price">
|
||||
<strong>{{ formatPrice(p.unitPrice, p.currency) }}</strong>
|
||||
<small>{{ currencyLabel(p.currency) }}</small>
|
||||
</td>
|
||||
<td class="stock">{{ p.stockQty != null ? toFaDigits(p.stockQty) : '—' }}</td>
|
||||
<td>
|
||||
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td class="when">
|
||||
<span>{{ formatDate(p.createdAt) }}</span>
|
||||
<small>{{ formatTime(p.createdAt) }}</small>
|
||||
</td>
|
||||
<td class="ops" @click.stop>
|
||||
<UiDropdownMenu
|
||||
:open="menuId === p.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? p.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="عملیات"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots" :size="16" />
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" @click="goEdit(p.id)">ویرایش</button>
|
||||
<button type="button" @click="toggleActive(p)">
|
||||
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<UiEmptyState
|
||||
v-if="!pageItems.length"
|
||||
icon="box"
|
||||
title="محصولی یافت نشد"
|
||||
description="هیچ محصولی با معیارهای جستجو مطابقت ندارد."
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="5">
|
||||
<a-form-item label="مرتبسازی">
|
||||
<a-select v-model:value="sort" :options="sortOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">لیست محصولات</a-divider>
|
||||
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" @click.stop>
|
||||
<a-typography-text v-if="selected.size" type="secondary">
|
||||
{{ toFaDigits(selected.size) }} محصول انتخاب شده است
|
||||
</a-typography-text>
|
||||
<a-typography-text v-else type="secondary">
|
||||
{{ toFaDigits(filtered.length) }} محصول
|
||||
</a-typography-text>
|
||||
<TableColumnsMenu table-key="products" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
<a-alert
|
||||
v-if="importMsg"
|
||||
type="success"
|
||||
show-icon
|
||||
:message="importMsg"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="mob-list">
|
||||
<article v-for="p in pageItems" :key="p.id" class="mob-card" @click.stop>
|
||||
<span class="thumb" :class="p.kind">
|
||||
<UiIcon :name="p.kind === 'service' ? 'briefcase' : 'box'" :size="22" />
|
||||
</span>
|
||||
<div class="mob-body">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="sku" dir="ltr">{{ p.sku || '—' }}</span>
|
||||
<UiBadge v-if="p.category" variant="neutral" size="sm">{{ p.category }}</UiBadge>
|
||||
<span class="mob-price">
|
||||
{{ formatPrice(p.unitPrice, p.currency) }}
|
||||
{{ currencyLabel(p.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mob-side">
|
||||
<UiDropdownMenu
|
||||
:open="menuId === p.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? p.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="عملیات"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<a-spin :spinning="loading || pageBusy" :tip="pageBusy ? busyMessage : undefined">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="pageItems"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: onTableSelectChange,
|
||||
onSelectAll: toggleSelectAll,
|
||||
}"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty
|
||||
description="محصولی یافت نشد؛ هیچ محصولی با معیارهای جستجو مطابقت ندارد."
|
||||
/>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'نام محصول'">
|
||||
<a-space :size="12" align="start">
|
||||
<a-avatar shape="square">
|
||||
<template #icon>
|
||||
<ToolOutlined v-if="record.kind === 'service'" />
|
||||
<ShoppingOutlined v-else />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong>{{ record.name }}</a-typography-text>
|
||||
<a-typography-text v-if="record.description" type="secondary">
|
||||
{{ record.description }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-space>
|
||||
</template>
|
||||
<button type="button" @click="goEdit(p.id)">ویرایش</button>
|
||||
<button type="button" @click="toggleActive(p)">
|
||||
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
|
||||
</UiDropdownMenu>
|
||||
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</article>
|
||||
<UiEmptyState
|
||||
v-if="!pageItems.length"
|
||||
icon="box"
|
||||
title="محصولی یافت نشد"
|
||||
description="هیچ محصولی با معیارهای جستجو مطابقت ندارد."
|
||||
/>
|
||||
</div>
|
||||
<template v-else-if="column.key === 'دستهبندی'">
|
||||
<a-tag v-if="record.category" :bordered="false">{{ record.category }}</a-tag>
|
||||
<a-typography-text v-else type="secondary">—</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'قیمت'">
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong>
|
||||
{{ formatPrice(record.unitPrice, record.currency) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ currencyLabel(record.currency) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag :color="record.isActive ? 'success' : 'default'" :bordered="false">
|
||||
{{ record.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'تاریخ ایجاد'">
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text>{{ formatDate(record.createdAt) }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ formatTime(record.createdAt) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-dropdown
|
||||
:open="menuId === record.id"
|
||||
trigger="click"
|
||||
@update:open="(v: boolean) => (menuId = v ? record.id : null)"
|
||||
>
|
||||
<a-button type="text" aria-label="عملیات" @click.stop>
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(info) => onRowMenu(info, record as ProductDto)">
|
||||
<a-menu-item key="edit">ویرایش</a-menu-item>
|
||||
<a-menu-item key="toggle-active">
|
||||
{{ record.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="remove" danger>حذف</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<UiPagination
|
||||
v-if="filtered.length"
|
||||
v-model:page="page"
|
||||
:total-items="filtered.length"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page-size="pageSize = $event; page = 1"
|
||||
/>
|
||||
</template>
|
||||
<a-flex v-if="filtered.length" justify="flex-end" :style="{ marginTop: '16px' }">
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="filtered.length"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
:show-total="
|
||||
(total: number) =>
|
||||
`${toFaDigits(total)} مورد · ${toFaDigits(Math.max(1, Math.ceil(total / pageSize)))} صفحه`
|
||||
"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
|
||||
<UiModal :open="productModal" title="محصول جدید" @close="productModal = false">
|
||||
<a-modal
|
||||
v-model:open="productModal"
|
||||
title="محصول جدید"
|
||||
:footer="null"
|
||||
width="960px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ProductFormView
|
||||
v-if="productModal"
|
||||
embedded
|
||||
@saved="productModal = false; void load()"
|
||||
@cancel="productModal = false"
|
||||
/>
|
||||
</UiModal>
|
||||
</section>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.more-wrap,
|
||||
.sort-wrap,
|
||||
.row-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.more-wrap,
|
||||
.sort-wrap,
|
||||
.row-more {
|
||||
position: relative;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 160px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
}
|
||||
.menu.left {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.menu button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.menu button:hover,
|
||||
.menu button.on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.menu button.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 900px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.85rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.check {
|
||||
width: 42px;
|
||||
}
|
||||
.name-cell {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.thumb.service {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
.name-cell strong,
|
||||
.mob-body strong {
|
||||
display: block;
|
||||
}
|
||||
.desc {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.sku {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #475569;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pill.soft {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.price,
|
||||
.when {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.price small,
|
||||
.when small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.status.on {
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
}
|
||||
.status.off {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.ops {
|
||||
width: 64px;
|
||||
}
|
||||
.mob-list {
|
||||
display: none;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.mob-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
.mob-body {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.mob-price {
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
.mob-side {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.pages {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.page-btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-btn.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.empty,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fab {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 1.1rem;
|
||||
bottom: calc(1.1rem + var(--mobile-tabbar-h));
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
place-items: center;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 10px 24px rgba(13, 148, 136, 0.35);
|
||||
z-index: 30;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.desk {
|
||||
display: none;
|
||||
}
|
||||
.mob-list {
|
||||
display: grid;
|
||||
}
|
||||
.fab {
|
||||
display: grid;
|
||||
}
|
||||
.head-actions .btn.primary {
|
||||
display: none;
|
||||
}
|
||||
.toolbar .filter-select:nth-of-type(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+496
-1094
File diff suppressed because it is too large
Load Diff
+990
-1481
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,6 @@ import {
|
||||
RESERVATION_STATUS_LABELS,
|
||||
RESERVATION_STATUSES,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiModal,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import {
|
||||
formatClockFa,
|
||||
@@ -35,6 +25,35 @@ import {
|
||||
} from '@/lib/jalali';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
List as AList,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
type TabKey = 'appointments' | 'services';
|
||||
|
||||
@@ -73,6 +92,46 @@ const serviceEnd = ref(17);
|
||||
|
||||
const dayNames = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: 'همه' },
|
||||
...RESERVATION_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: RESERVATION_STATUS_LABELS[s],
|
||||
})),
|
||||
]);
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
RESERVATION_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: RESERVATION_STATUS_LABELS[s],
|
||||
})),
|
||||
);
|
||||
|
||||
const activeServiceOptions = computed(() =>
|
||||
services.value
|
||||
.filter((x) => x.isActive)
|
||||
.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${toFaDigits(s.durationMinutes)} دقیقه)`,
|
||||
})),
|
||||
);
|
||||
|
||||
const personOptions = computed(() =>
|
||||
people.value.map((p) => ({ value: p.id, label: p.fullName })),
|
||||
);
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: '—' },
|
||||
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
|
||||
const slotOptions = computed(() =>
|
||||
slots.value.map((sl) => ({
|
||||
value: sl.startsAt,
|
||||
label: slotLabel(sl.startsAt),
|
||||
})),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -176,14 +235,22 @@ async function setStatus(id: string, status: ReservationStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAppointment(id: string) {
|
||||
if (!confirm('این نوبت حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/reservations/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف نوبت ناموفق بود.';
|
||||
}
|
||||
function removeAppointment(id: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف نوبت',
|
||||
content: 'این نوبت حذف شود؟',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/reservations/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف نوبت ناموفق بود.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openServiceCreate() {
|
||||
@@ -239,308 +306,376 @@ async function saveService() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeService(id: string) {
|
||||
if (!confirm('این خدمت حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/reservations/services/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
|
||||
: 'حذف ناموفق بود.';
|
||||
}
|
||||
function removeService(id: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف خدمت',
|
||||
content: 'این خدمت حذف شود؟',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/reservations/services/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
|
||||
: 'حذف ناموفق بود.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function slotLabel(iso: string) {
|
||||
return formatClockFa(new Date(iso));
|
||||
}
|
||||
|
||||
function appointmentDescription(a: ReservationAppointmentDto) {
|
||||
const parts = [
|
||||
formatJalaliNumeric(new Date(a.startsAt)),
|
||||
`${formatClockFa(new Date(a.startsAt))} تا ${formatClockFa(new Date(a.endsAt))}`,
|
||||
RESERVATION_STATUS_LABELS[a.status],
|
||||
];
|
||||
if (a.assigneeName) parts.push(a.assigneeName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function serviceDescription(s: ReservationServiceDto) {
|
||||
return [
|
||||
`مدت ${toFaDigits(s.durationMinutes)} دقیقه`,
|
||||
`ساعت ${toFaDigits(s.dayStartHour)} تا ${toFaDigits(s.dayEndHour)}`,
|
||||
s.isActive ? 'فعال' : 'غیرفعال',
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
tab.value = String(key) as TabKey;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="رزرو و نوبتدهی"
|
||||
description="تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">رزرو و نوبتدهی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="8">
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
<UiButton v-if="tab === 'appointments'" @click="openCreate">
|
||||
</a-button>
|
||||
<a-button v-if="tab === 'appointments'" type="primary" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
نوبت جدید
|
||||
</UiButton>
|
||||
<UiButton
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="canEditServices"
|
||||
type="primary"
|
||||
@click="openServiceCreate"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
خدمت جدید
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'appointments' }" @click="tab = 'appointments'">
|
||||
نوبتها
|
||||
</button>
|
||||
<button type="button" :class="{ on: tab === 'services' }" @click="tab = 'services'">
|
||||
خدمات
|
||||
</button>
|
||||
</nav>
|
||||
<a-card :bordered="false">
|
||||
<a-tabs :active-key="tab" @change="onTabChange">
|
||||
<a-tab-pane key="appointments" tab="نوبتها" />
|
||||
<a-tab-pane key="services" tab="خدمات" />
|
||||
</a-tabs>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
|
||||
<template v-if="tab === 'appointments'">
|
||||
<div class="filters">
|
||||
<label>
|
||||
وضعیت
|
||||
<select v-model="statusFilter" @change="load">
|
||||
<option value="">همه</option>
|
||||
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
|
||||
{{ RESERVATION_STATUS_LABELS[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !appointments.length"
|
||||
title="نوبتی نیست"
|
||||
description="اول خدمت تعریف کنید، بعد نوبت بسازید."
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
closable
|
||||
:style="{ marginBottom: '12px' }"
|
||||
@close="error = ''"
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="a in appointments" :key="a.id" class="item">
|
||||
<div>
|
||||
<strong>{{ a.serviceName }} — {{ a.personName }}</strong>
|
||||
<p class="muted">
|
||||
{{ formatJalaliNumeric(new Date(a.startsAt)) }}
|
||||
· {{ formatClockFa(new Date(a.startsAt)) }} تا {{ formatClockFa(new Date(a.endsAt)) }}
|
||||
· {{ RESERVATION_STATUS_LABELS[a.status] }}
|
||||
<template v-if="a.assigneeName"> · {{ a.assigneeName }}</template>
|
||||
</p>
|
||||
<p v-if="a.notes" class="muted">{{ a.notes }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
v-if="a.status === 'pending'"
|
||||
variant="secondary"
|
||||
@click="setStatus(a.id, 'confirmed')"
|
||||
>
|
||||
تأیید
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="a.status === 'confirmed' || a.status === 'pending'"
|
||||
variant="secondary"
|
||||
@click="setStatus(a.id, 'done')"
|
||||
>
|
||||
انجام شد
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="a.status !== 'canceled' && a.status !== 'done'"
|
||||
variant="secondary"
|
||||
@click="setStatus(a.id, 'canceled')"
|
||||
>
|
||||
لغو
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" @click="removeAppointment(a.id)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<UiEmptyState
|
||||
v-if="!loading && !services.length"
|
||||
title="خدمتی نیست"
|
||||
description="مثلاً مشاوره ۳۰ دقیقهای تعریف کنید."
|
||||
<a-alert
|
||||
v-if="ok"
|
||||
type="success"
|
||||
show-icon
|
||||
:message="ok"
|
||||
closable
|
||||
:style="{ marginBottom: '12px' }"
|
||||
@close="ok = ''"
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="s in services" :key="s.id" class="item">
|
||||
<div>
|
||||
<strong>{{ s.name }}</strong>
|
||||
<p class="muted">
|
||||
مدت {{ toFaDigits(s.durationMinutes) }} دقیقه
|
||||
· ساعت {{ toFaDigits(s.dayStartHour) }} تا {{ toFaDigits(s.dayEndHour) }}
|
||||
· {{ s.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</p>
|
||||
<p v-if="s.description" class="muted">{{ s.description }}</p>
|
||||
</div>
|
||||
<div v-if="canEditServices" class="actions">
|
||||
<UiButton variant="secondary" @click="openServiceEdit(s)">ویرایش</UiButton>
|
||||
<UiButton variant="secondary" @click="removeService(s.id)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UiModal :open="formOpen" title="نوبت جدید" @close="formOpen = false">
|
||||
<div class="form-grid">
|
||||
<UiField label="خدمت">
|
||||
<select v-model="formServiceId">
|
||||
<option disabled value="">انتخاب کنید</option>
|
||||
<option
|
||||
v-for="s in services.filter((x) => x.isActive)"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
>
|
||||
{{ s.name }} ({{ toFaDigits(s.durationMinutes) }} دقیقه)
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مشتری">
|
||||
<select v-model="formPersonId">
|
||||
<option disabled value="">انتخاب کنید</option>
|
||||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||||
{{ p.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="کارشناس">
|
||||
<select v-model="formAssigneeId">
|
||||
<option value="">—</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="تاریخ">
|
||||
<JalaliDateInput v-model="formDate" />
|
||||
</UiField>
|
||||
<UiField label="ساعت آزاد">
|
||||
<select v-model="formStartsAt">
|
||||
<option disabled value="">انتخاب کنید</option>
|
||||
<option v-for="sl in slots" :key="sl.startsAt" :value="sl.startsAt">
|
||||
{{ slotLabel(sl.startsAt) }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="formServiceId && formDate && !slots.length" class="muted">
|
||||
برای این تاریخ اسلات آزادی نیست.
|
||||
</p>
|
||||
</UiField>
|
||||
<UiField label="وضعیت">
|
||||
<select v-model="formStatus">
|
||||
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
|
||||
{{ RESERVATION_STATUS_LABELS[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="یادداشت">
|
||||
<UiTextarea v-model="formNotes" :rows="2" />
|
||||
</UiField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="secondary" @click="formOpen = false">انصراف</UiButton>
|
||||
<UiButton :loading="saving" @click="saveAppointment">ثبت نوبت</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="tab === 'appointments'">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusFilterOptions"
|
||||
style="width: 100%"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<UiModal
|
||||
:open="serviceFormOpen"
|
||||
<a-empty
|
||||
v-if="!loading && !appointments.length"
|
||||
description="نوبتی نیست — اول خدمت تعریف کنید، بعد نوبت بسازید."
|
||||
/>
|
||||
<a-list v-else :data-source="appointments" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-space wrap :size="8">
|
||||
<a-button
|
||||
v-if="(item as ReservationAppointmentDto).status === 'pending'"
|
||||
@click="setStatus((item as ReservationAppointmentDto).id, 'confirmed')"
|
||||
>
|
||||
تأیید
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="
|
||||
(item as ReservationAppointmentDto).status === 'confirmed' ||
|
||||
(item as ReservationAppointmentDto).status === 'pending'
|
||||
"
|
||||
@click="setStatus((item as ReservationAppointmentDto).id, 'done')"
|
||||
>
|
||||
انجام شد
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="
|
||||
(item as ReservationAppointmentDto).status !== 'canceled' &&
|
||||
(item as ReservationAppointmentDto).status !== 'done'
|
||||
"
|
||||
@click="setStatus((item as ReservationAppointmentDto).id, 'canceled')"
|
||||
>
|
||||
لغو
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
@click="removeAppointment((item as ReservationAppointmentDto).id)"
|
||||
>
|
||||
حذف
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ (item as ReservationAppointmentDto).serviceName }} —
|
||||
{{ (item as ReservationAppointmentDto).personName }}
|
||||
</template>
|
||||
<template #description>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">
|
||||
{{ appointmentDescription(item as ReservationAppointmentDto) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="(item as ReservationAppointmentDto).notes"
|
||||
type="secondary"
|
||||
>
|
||||
{{ (item as ReservationAppointmentDto).notes }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-empty
|
||||
v-if="!loading && !services.length"
|
||||
description="خدمتی نیست — مثلاً مشاوره ۳۰ دقیقهای تعریف کنید."
|
||||
/>
|
||||
<a-list v-else :data-source="services" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template v-if="canEditServices" #actions>
|
||||
<a-space wrap :size="8">
|
||||
<a-button @click="openServiceEdit(item as ReservationServiceDto)">
|
||||
ویرایش
|
||||
</a-button>
|
||||
<a-button danger @click="removeService((item as ReservationServiceDto).id)">
|
||||
حذف
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ (item as ReservationServiceDto).name }}
|
||||
</template>
|
||||
<template #description>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">
|
||||
{{ serviceDescription(item as ReservationServiceDto) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="(item as ReservationServiceDto).description"
|
||||
type="secondary"
|
||||
>
|
||||
{{ (item as ReservationServiceDto).description }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:open="formOpen" title="نوبت جدید" destroy-on-close :footer="null" width="640px">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="خدمت" required>
|
||||
<a-select
|
||||
v-model:value="formServiceId"
|
||||
:options="activeServiceOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="مشتری" required>
|
||||
<a-select
|
||||
v-model:value="formPersonId"
|
||||
:options="personOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="کارشناس">
|
||||
<a-select
|
||||
v-model:value="formAssigneeId"
|
||||
:options="userOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ" required>
|
||||
<JalaliDateInput v-model="formDate" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ساعت آزاد" required>
|
||||
<a-select
|
||||
v-model:value="formStartsAt"
|
||||
:options="slotOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-if="formServiceId && formDate && !slots.length"
|
||||
type="secondary"
|
||||
>
|
||||
برای این تاریخ اسلات آزادی نیست.
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
v-model:value="formStatus"
|
||||
:options="statusOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea v-model:value="formNotes" :rows="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-flex justify="flex-end" :gap="8">
|
||||
<a-button :disabled="saving" @click="formOpen = false">انصراف</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="saveAppointment">ثبت نوبت</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="serviceFormOpen"
|
||||
:title="serviceEditId ? 'ویرایش خدمت' : 'خدمت جدید'"
|
||||
@close="serviceFormOpen = false"
|
||||
destroy-on-close
|
||||
:footer="null"
|
||||
width="560px"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="serviceName" placeholder="مشاوره حضوری" />
|
||||
</UiField>
|
||||
<UiField label="مدت (دقیقه)">
|
||||
<UiInput v-model.number="serviceDuration" type="number" />
|
||||
</UiField>
|
||||
<UiField label="شروع روز (ساعت)">
|
||||
<UiInput v-model.number="serviceStart" type="number" />
|
||||
</UiField>
|
||||
<UiField label="پایان روز (ساعت)">
|
||||
<UiInput v-model.number="serviceEnd" type="number" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiTextarea v-model="serviceDesc" :rows="2" />
|
||||
</UiField>
|
||||
</div>
|
||||
<p class="muted">روزهای کاری پیشفرض: همه روزهای هفته ({{ dayNames.join('، ') }})</p>
|
||||
<template #footer>
|
||||
<UiButton variant="secondary" @click="serviceFormOpen = false">انصراف</UiButton>
|
||||
<UiButton :loading="saving" @click="saveService">ذخیره</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input
|
||||
v-model:value="serviceName"
|
||||
placeholder="مشاوره حضوری"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت (دقیقه)">
|
||||
<a-input-number
|
||||
v-model:value="serviceDuration"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع روز (ساعت)">
|
||||
<a-input-number
|
||||
v-model:value="serviceStart"
|
||||
:min="0"
|
||||
:max="23"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پایان روز (ساعت)">
|
||||
<a-input-number
|
||||
v-model:value="serviceEnd"
|
||||
:min="0"
|
||||
:max="24"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="توضیح">
|
||||
<a-textarea v-model:value="serviceDesc" :rows="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-typography-paragraph type="secondary">
|
||||
روزهای کاری پیشفرض: همه روزهای هفته ({{ dayNames.join('، ') }})
|
||||
</a-typography-paragraph>
|
||||
<a-flex justify="flex-end" :gap="8">
|
||||
<a-button :disabled="saving" @click="serviceFormOpen = false">انصراف</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="saveService">ذخیره</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button.on {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.filters select,
|
||||
.form-grid select {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.item {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,17 +16,48 @@ import {
|
||||
MESSAGE_AUTOMATION_FIELDS,
|
||||
SMS_CAMPAIGN_CHUNK_SIZE,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Dropdown as ADropdown,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
Menu as AMenu,
|
||||
MenuItem as AMenuItem,
|
||||
Modal,
|
||||
Pagination as APagination,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Switch as ASwitch,
|
||||
Table as ATable,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType, TableProps } from 'ant-design-vue';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
type AudienceTab = 'people' | 'leads';
|
||||
|
||||
@@ -65,7 +96,40 @@ const statusLabel: Record<string, string> = {
|
||||
cancelled: 'لغو شده',
|
||||
};
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
const statusColor: Record<string, string> = {
|
||||
draft: 'default',
|
||||
queued: 'processing',
|
||||
sending: 'warning',
|
||||
done: 'success',
|
||||
cancelled: 'error',
|
||||
};
|
||||
|
||||
const stageOptions = computed(() => [
|
||||
{ value: '', label: 'همه' },
|
||||
...stages.value.map((s) => ({ value: s.id, label: s.name })),
|
||||
]);
|
||||
|
||||
const pageIds = computed(() =>
|
||||
audience.value === 'people'
|
||||
? people.value.map((p) => p.id)
|
||||
: leads.value.map((l) => l.id),
|
||||
);
|
||||
|
||||
const tableData = computed(() =>
|
||||
audience.value === 'people' ? people.value : leads.value,
|
||||
);
|
||||
|
||||
const audienceColumns = computed<TableColumnsType>(() => [
|
||||
{ title: 'نام', key: 'name' },
|
||||
]);
|
||||
|
||||
const rowSelection = computed<TableProps['rowSelection']>(() => ({
|
||||
selectedRowKeys: selectedIds.value.filter((id) => pageIds.value.includes(id)),
|
||||
onChange: (keys) => {
|
||||
const kept = selectedIds.value.filter((id) => !pageIds.value.includes(id));
|
||||
selectedIds.value = [...kept, ...keys.map(String)];
|
||||
},
|
||||
}));
|
||||
|
||||
const filterParams = computed(() => {
|
||||
const base: Record<string, string | number | boolean> = {
|
||||
@@ -163,17 +227,31 @@ watch(page, () => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(audience, (next, prev) => {
|
||||
function onAudienceChange(key: string | number) {
|
||||
const next = String(key) as AudienceTab;
|
||||
const prev = audience.value;
|
||||
if (next === prev) return;
|
||||
if (selectedIds.value.length) {
|
||||
const okSwitch = window.confirm(
|
||||
'با تعویض تب، انتخابهای فعلی پاک میشود. ادامه میدهید؟',
|
||||
);
|
||||
if (!okSwitch) {
|
||||
audience.value = prev;
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'تعویض تب مخاطب',
|
||||
content: 'با تعویض تب، انتخابهای فعلی پاک میشود. ادامه میدهید؟',
|
||||
okText: 'ادامه',
|
||||
cancelText: 'انصراف',
|
||||
onOk: () => {
|
||||
audience.value = next;
|
||||
selectedIds.value = [];
|
||||
q.value = '';
|
||||
province.value = '';
|
||||
city.value = '';
|
||||
region.value = '';
|
||||
stageId.value = '';
|
||||
page.value = 1;
|
||||
void loadList();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
audience.value = next;
|
||||
selectedIds.value = [];
|
||||
q.value = '';
|
||||
province.value = '';
|
||||
@@ -182,21 +260,10 @@ watch(audience, (next, prev) => {
|
||||
stageId.value = '';
|
||||
page.value = 1;
|
||||
void loadList();
|
||||
});
|
||||
|
||||
function toggleId(id: string) {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((x) => x !== id);
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id];
|
||||
}
|
||||
}
|
||||
|
||||
function selectVisible() {
|
||||
const ids =
|
||||
audience.value === 'people'
|
||||
? people.value.map((p) => p.id)
|
||||
: leads.value.map((l) => l.id);
|
||||
const ids = pageIds.value;
|
||||
selectedIds.value = [...new Set([...selectedIds.value, ...ids])];
|
||||
}
|
||||
|
||||
@@ -211,12 +278,16 @@ async function selectAllFiltered() {
|
||||
error.value = 'نتیجهای با این فیلتر پیدا نشد.';
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
`${toFaDigits(data.total)} مخاطب مطابق فیلتر انتخاب شود؟`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
selectedIds.value = data.ids;
|
||||
ok.value = `${toFaDigits(data.ids.length)} مخاطب انتخاب شد.`;
|
||||
Modal.confirm({
|
||||
title: 'انتخاب همهٔ نتایج',
|
||||
content: `${toFaDigits(data.total)} مخاطب مطابق فیلتر انتخاب شود؟`,
|
||||
okText: 'انتخاب',
|
||||
cancelText: 'انصراف',
|
||||
onOk: () => {
|
||||
selectedIds.value = data.ids;
|
||||
ok.value = `${toFaDigits(data.ids.length)} مخاطب انتخاب شد.`;
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -316,462 +387,284 @@ async function cancel(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function goReport(id: string) {
|
||||
void router.push({ name: 'sms-campaign-report', params: { id } });
|
||||
}
|
||||
|
||||
function personLine(p: PersonDto) {
|
||||
const bits: string[] = [p.mobile || 'بدون موبایل'];
|
||||
if (p.company) {
|
||||
bits.push(p.company.name);
|
||||
const place = [p.company.city, p.company.province].filter(Boolean).join('، ');
|
||||
if (place) bits.push(place);
|
||||
}
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
function leadLine(l: LeadDto) {
|
||||
const bits: string[] = [l.mobile || 'بدون موبایل'];
|
||||
const place = [l.city, l.region].filter(Boolean).join('، ');
|
||||
if (place) bits.push(place);
|
||||
bits.push(l.stageName);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="پیامک گروهی"
|
||||
description="ارسال انبوه پیامک به اشخاص یا لیدهای فیلترشده"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">پیامک گروهی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ارسال انبوه پیامک به اشخاص یا لیدهای فیلترشده
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<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 = ''" />
|
||||
|
||||
<UiCard title="کمپین جدید">
|
||||
<div class="form-grid">
|
||||
<UiField label="نام کمپین">
|
||||
<UiInput v-model="name" placeholder="مثلاً اطلاعرسانی جشنواره" />
|
||||
</UiField>
|
||||
<label class="check">
|
||||
<input v-model="queueNow" type="checkbox" />
|
||||
ارسال فوری پس از ذخیره
|
||||
</label>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-card :bordered="false" title="کمپین جدید">
|
||||
<a-form layout="vertical" @finish="createCampaign">
|
||||
<a-divider orientation="right">متن و تنظیمات</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام کمپین" required>
|
||||
<a-input
|
||||
v-model:value="name"
|
||||
placeholder="مثلاً اطلاعرسانی جشنواره"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ارسال">
|
||||
<a-space :size="8">
|
||||
<a-switch v-model:checked="queueNow" />
|
||||
<a-typography-text>ارسال فوری پس از ذخیره</a-typography-text>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="متن پیامک" required>
|
||||
<template #extra>
|
||||
<a-dropdown v-model:open="fieldsOpen" :trigger="['click']">
|
||||
<a-button type="link">{ } فیلدهای پویا</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-for="item in MESSAGE_AUTOMATION_FIELDS"
|
||||
:key="item.token"
|
||||
@click="insertField(item.token)"
|
||||
>
|
||||
{{ item.token }} — {{ item.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
<a-textarea
|
||||
v-model:value="body"
|
||||
:rows="4"
|
||||
placeholder="متن پیامک را بنویسید… از فیلدهای پویا استفاده کنید. لینک http(s) برای هر مخاطب یکتا میشود."
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="body-block">
|
||||
<div class="body-tools">
|
||||
<span class="muted">متن پیامک</span>
|
||||
<div class="pop-wrap">
|
||||
<button
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="fieldsOpen = !fieldsOpen"
|
||||
<a-divider orientation="right">مخاطبان</a-divider>
|
||||
<a-tabs :active-key="audience" @change="onAudienceChange">
|
||||
<a-tab-pane key="people" tab="اشخاص" />
|
||||
<a-tab-pane key="leads" tab="لیدها" />
|
||||
</a-tabs>
|
||||
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="نام یا موبایل…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="audience === 'people'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="استان">
|
||||
<a-input
|
||||
v-model:value="province"
|
||||
allow-clear
|
||||
placeholder="مثلاً اصفهان"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="شهر">
|
||||
<a-input
|
||||
v-model:value="city"
|
||||
allow-clear
|
||||
placeholder="شهر…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="audience === 'leads'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="منطقه / استان">
|
||||
<a-input
|
||||
v-model:value="region"
|
||||
allow-clear
|
||||
placeholder="منطقه یا استان…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="audience === 'leads'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="مرحله">
|
||||
<a-select
|
||||
v-model:value="stageId"
|
||||
:options="stageOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="موبایل">
|
||||
<a-space :size="8">
|
||||
<a-checkbox v-model:checked="hasMobile" />
|
||||
<a-typography-text>فقط با موبایل</a-typography-text>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginBottom: '12px' }">
|
||||
<a-typography-text type="secondary">
|
||||
انتخابشده: {{ toFaDigits(selectedIds.length) }} نفر · نتایج فیلتر:
|
||||
{{ toFaDigits(total) }}
|
||||
</a-typography-text>
|
||||
<a-space wrap :size="8">
|
||||
<a-button @click="selectVisible">انتخاب صفحه</a-button>
|
||||
<a-button @click="selectAllFiltered">انتخاب همهٔ نتایج فیلتر</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<a-spin :spinning="listLoading">
|
||||
<a-table
|
||||
:columns="audienceColumns"
|
||||
:data-source="tableData"
|
||||
:row-selection="rowSelection"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:locale="{ emptyText: 'موردی پیدا نشد.' }"
|
||||
:scroll="{ y: 360 }"
|
||||
>
|
||||
<b>{ }</b>
|
||||
فیلدهای پویا
|
||||
</button>
|
||||
<div v-if="fieldsOpen" class="pop">
|
||||
<button
|
||||
v-for="item in MESSAGE_AUTOMATION_FIELDS"
|
||||
:key="item.token"
|
||||
type="button"
|
||||
@click="insertField(item.token)"
|
||||
>
|
||||
<code>{{ item.token }}</code>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiTextarea
|
||||
v-model="body"
|
||||
:rows="4"
|
||||
placeholder="متن پیامک را بنویسید… از فیلدهای پویا استفاده کنید. لینک http(s) برای هر مخاطب یکتا میشود."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: audience === 'people' }"
|
||||
@click="audience = 'people'"
|
||||
>
|
||||
اشخاص
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: audience === 'leads' }"
|
||||
@click="audience = 'leads'"
|
||||
>
|
||||
لیدها
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<UiField label="جستجو">
|
||||
<UiInput v-model="q" placeholder="نام یا موبایل…" />
|
||||
</UiField>
|
||||
<UiField v-if="audience === 'people'" label="استان">
|
||||
<UiInput v-model="province" placeholder="مثلاً اصفهان" />
|
||||
</UiField>
|
||||
<UiField :label="audience === 'people' ? 'شهر' : 'شهر'">
|
||||
<UiInput v-model="city" placeholder="شهر…" />
|
||||
</UiField>
|
||||
<UiField v-if="audience === 'leads'" label="منطقه / استان">
|
||||
<UiInput v-model="region" placeholder="منطقه یا استان…" />
|
||||
</UiField>
|
||||
<UiField v-if="audience === 'leads'" label="مرحله">
|
||||
<select v-model="stageId" class="select">
|
||||
<option value="">همه</option>
|
||||
<option v-for="s in stages" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<label class="check filter-check">
|
||||
<input v-model="hasMobile" type="checkbox" />
|
||||
فقط با موبایل
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="people-head">
|
||||
<p class="muted">
|
||||
انتخابشده: {{ toFaDigits(selectedIds.length) }} نفر · نتایج فیلتر:
|
||||
{{ toFaDigits(total) }}
|
||||
</p>
|
||||
<div class="head-actions">
|
||||
<UiButton variant="secondary" @click="selectVisible">
|
||||
انتخاب صفحه
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" @click="selectAllFiltered">
|
||||
انتخاب همهٔ نتایج فیلتر
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="listLoading" class="muted">در حال بارگذاری…</p>
|
||||
<div class="people-list">
|
||||
<template v-if="audience === 'people'">
|
||||
<label v-for="p in people" :key="p.id" class="person">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.includes(p.id)"
|
||||
@change="toggleId(p.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ p.fullName }}</strong>
|
||||
<small>
|
||||
{{ p.mobile || 'بدون موبایل' }}
|
||||
<template v-if="p.company">
|
||||
· {{ p.company.name }}
|
||||
<template v-if="p.company.city || p.company.province">
|
||||
·
|
||||
{{
|
||||
[p.company.city, p.company.province].filter(Boolean).join('، ')
|
||||
}}
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<template v-if="audience === 'people'">
|
||||
<a-typography-text strong>
|
||||
{{ (record as PersonDto).fullName }}
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text type="secondary">
|
||||
{{ personLine(record as PersonDto) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-typography-text strong>
|
||||
{{ (record as LeadDto).contactName || (record as LeadDto).title }}
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text type="secondary">
|
||||
{{ leadLine(record as LeadDto) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label v-for="l in leads" :key="l.id" class="person">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.includes(l.id)"
|
||||
@change="toggleId(l.id)"
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
|
||||
<a-flex justify="center" :style="{ marginTop: '12px', marginBottom: '16px' }">
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:show-size-changer="false"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ l.contactName || l.title }}</strong>
|
||||
<small>
|
||||
{{ l.mobile || 'بدون موبایل' }}
|
||||
<template v-if="l.city || l.region">
|
||||
· {{ [l.city, l.region].filter(Boolean).join('، ') }}
|
||||
</template>
|
||||
· {{ l.stageName }}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
<p
|
||||
v-if="!listLoading && !(audience === 'people' ? people.length : leads.length)"
|
||||
class="muted empty-list"
|
||||
>
|
||||
موردی پیدا نشد.
|
||||
</p>
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<div class="pager">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="page <= 1"
|
||||
@click="page -= 1"
|
||||
>
|
||||
قبلی
|
||||
</UiButton>
|
||||
<span class="muted">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(pageCount) }}
|
||||
</span>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="page >= pageCount"
|
||||
@click="page += 1"
|
||||
>
|
||||
بعدی
|
||||
</UiButton>
|
||||
</div>
|
||||
<a-button type="primary" html-type="submit" :loading="saving" :disabled="saving">
|
||||
ساخت کمپین
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<UiButton :loading="saving" @click="createCampaign">ساخت کمپین</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="کمپینها">
|
||||
<UiEmptyState
|
||||
v-if="!loading && !campaigns.length"
|
||||
title="کمپینی نیست"
|
||||
description="اولین کمپین پیامک گروهی را بسازید."
|
||||
/>
|
||||
<div v-else class="campaigns">
|
||||
<article v-for="c in campaigns" :key="c.id" class="campaign">
|
||||
<div>
|
||||
<strong>{{ c.name }}</strong>
|
||||
<p class="muted">{{ c.body }}</p>
|
||||
<p class="meta">
|
||||
{{ statusLabel[c.status] ?? c.status }}
|
||||
· کل {{ toFaDigits(c.totalCount) }}
|
||||
· ارسال {{ toFaDigits(c.sentCount) }}
|
||||
· ناموفق {{ toFaDigits(c.failedCount) }}
|
||||
· ردشده {{ toFaDigits(c.skippedCount) }}
|
||||
· {{ formatJalaliNumeric(new Date(c.createdAt)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
@click="router.push({ name: 'sms-campaign-report', params: { id: c.id } })"
|
||||
>
|
||||
گزارش
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="c.status === 'draft' || c.status === 'cancelled'"
|
||||
variant="secondary"
|
||||
@click="queue(c.id)"
|
||||
>
|
||||
صف ارسال
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="c.status === 'queued' || c.status === 'sending' || c.status === 'draft'"
|
||||
variant="secondary"
|
||||
@click="cancel(c.id)"
|
||||
>
|
||||
لغو
|
||||
</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
<a-card :bordered="false" title="کمپینها" :style="{ marginTop: '16px' }">
|
||||
<a-empty
|
||||
v-if="!loading && !campaigns.length"
|
||||
description="کمپینی نیست. اولین کمپین پیامک گروهی را بسازید."
|
||||
/>
|
||||
<a-list v-else :data-source="campaigns" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta
|
||||
:title="(item as SmsCampaignDto).name"
|
||||
:description="(item as SmsCampaignDto).body"
|
||||
/>
|
||||
<template #actions>
|
||||
<a-tag
|
||||
:color="statusColor[(item as SmsCampaignDto).status] || 'default'"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ statusLabel[(item as SmsCampaignDto).status] ?? (item as SmsCampaignDto).status }}
|
||||
</a-tag>
|
||||
<a-typography-text type="secondary">
|
||||
کل {{ toFaDigits((item as SmsCampaignDto).totalCount) }}
|
||||
· ارسال {{ toFaDigits((item as SmsCampaignDto).sentCount) }}
|
||||
· ناموفق {{ toFaDigits((item as SmsCampaignDto).failedCount) }}
|
||||
· ردشده {{ toFaDigits((item as SmsCampaignDto).skippedCount) }}
|
||||
· {{ formatJalaliNumeric(new Date((item as SmsCampaignDto).createdAt)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="goReport((item as SmsCampaignDto).id)">
|
||||
<template #icon><BarChartOutlined /></template>
|
||||
گزارش
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="(item as SmsCampaignDto).status === 'draft' || (item as SmsCampaignDto).status === 'cancelled'"
|
||||
@click="queue((item as SmsCampaignDto).id)"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
صف ارسال
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="
|
||||
(item as SmsCampaignDto).status === 'queued' ||
|
||||
(item as SmsCampaignDto).status === 'sending' ||
|
||||
(item as SmsCampaignDto).status === 'draft'
|
||||
"
|
||||
danger
|
||||
@click="cancel((item as SmsCampaignDto).id)"
|
||||
>
|
||||
<template #icon><StopOutlined /></template>
|
||||
لغو
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
.filter-check {
|
||||
align-self: end;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.body-block {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.body-tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.pop-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
}
|
||||
.pill:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.pop {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
top: calc(100% + 0.35rem);
|
||||
z-index: 20;
|
||||
min-width: 220px;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.45rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.pop button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
}
|
||||
.pop button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.pop code {
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tabs button.on {
|
||||
color: #0f766e;
|
||||
border-bottom-color: #0f766e;
|
||||
}
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.select {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.65rem;
|
||||
background: #fff;
|
||||
}
|
||||
.people-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.people-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 0.45rem;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
margin: 0.75rem 0 0.75rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.person {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.45rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.person:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.person span {
|
||||
display: grid;
|
||||
}
|
||||
.person small {
|
||||
color: #64748b;
|
||||
}
|
||||
.empty-list {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.campaigns {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.campaign {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
.muted,
|
||||
.meta {
|
||||
color: #64748b;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.form-grid,
|
||||
.campaign {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+238
-588
@@ -1,22 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
UiPageHeader,
|
||||
UiEmptyState,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
TicketDto,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { FilterOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
const TICKET_COL_LABELS = [
|
||||
'موضوع',
|
||||
'شماره',
|
||||
'وضعیت',
|
||||
'اولویت',
|
||||
'مشتری / مسئول',
|
||||
'SLA',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('tickets', [...TICKET_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'tickets');
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -32,7 +61,6 @@ const status = ref<TicketStatus | 'all'>(
|
||||
: 'all',
|
||||
);
|
||||
const slaOnly = ref(route.query.sla === 'overdue');
|
||||
const filterOpen = ref(false);
|
||||
|
||||
const statusLabel: Record<TicketStatus, string> = {
|
||||
open: 'باز',
|
||||
@@ -49,11 +77,28 @@ const priorityLabel: Record<TicketPriority, string> = {
|
||||
urgent: 'فوری',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'همه وضعیتها' },
|
||||
...Object.entries(statusLabel).map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
|
||||
const items = computed(() => data.value?.items ?? []);
|
||||
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
const statusFilterLabel = computed(() =>
|
||||
status.value === 'all' ? 'همه وضعیتها' : statusLabel[status.value],
|
||||
);
|
||||
|
||||
function statusTagColor(s: TicketStatus | string) {
|
||||
if (s === 'open') return 'cyan';
|
||||
if (s === 'in_progress') return 'orange';
|
||||
if (s === 'waiting') return 'gold';
|
||||
if (s === 'resolved' || s === 'closed') return 'success';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function priorityTagColor(p: TicketPriority | string) {
|
||||
if (p === 'low') return 'default';
|
||||
if (p === 'normal') return 'cyan';
|
||||
if (p === 'high' || p === 'urgent') return 'error';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function formatSla(iso: string | null) {
|
||||
if (!iso) return '';
|
||||
@@ -62,10 +107,41 @@ function formatSla(iso: string | null) {
|
||||
return formatJalaliDateTime(d);
|
||||
}
|
||||
|
||||
function personAssigneeText(ticket: TicketDto) {
|
||||
const parts: string[] = [];
|
||||
if (ticket.person) parts.push(ticket.person.fullName);
|
||||
if (ticket.assignee) parts.push(ticket.assignee.fullName);
|
||||
return parts.length ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
function slaText(ticket: TicketDto) {
|
||||
if (ticket.slaBreached) return { text: 'تأخیر SLA', color: 'error' as const };
|
||||
if (ticket.firstRespondedAt) return { text: 'پاسخ داده شد', color: 'success' as const };
|
||||
if (ticket.slaDueAt) return { text: `مهلت ${formatSla(ticket.slaDueAt)}`, color: 'processing' as const };
|
||||
return { text: '—', color: 'default' as const };
|
||||
}
|
||||
|
||||
const columns = computed<TableColumnsType<TicketDto>>(() => {
|
||||
const all: TableColumnsType<TicketDto> = [
|
||||
{ title: 'موضوع', key: 'موضوع' },
|
||||
{ title: 'شماره', key: 'شماره', dataIndex: 'number' },
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'اولویت', key: 'اولویت' },
|
||||
{ title: 'مشتری / مسئول', key: 'مشتری / مسئول' },
|
||||
{ title: 'SLA', key: 'SLA' },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!TICKET_COL_LABELS.includes(key as (typeof TICKET_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
filterOpen.value = false;
|
||||
try {
|
||||
const { data: res } = await api.get<PaginatedResponse<TicketDto>>('/tickets', {
|
||||
params: {
|
||||
@@ -98,9 +174,8 @@ function onSearch() {
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(next: TicketStatus | 'all') {
|
||||
status.value = next;
|
||||
filterOpen.value = false;
|
||||
function onStatusChange(value: unknown) {
|
||||
status.value = (value == null ? 'all' : String(value)) as TicketStatus | 'all';
|
||||
onSearch();
|
||||
}
|
||||
|
||||
@@ -109,6 +184,14 @@ function toggleSla() {
|
||||
onSearch();
|
||||
}
|
||||
|
||||
function goNew() {
|
||||
void router.push({ name: 'ticket-new' });
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
void router.push({ name: 'ticket-detail', params: { id } });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.q, route.query.status, route.query.sla],
|
||||
() => {
|
||||
@@ -127,581 +210,148 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" @click="filterOpen = false">
|
||||
<UiPageHeader
|
||||
title="تیکتها"
|
||||
lead="درخواستهای پشتیبانی را ثبت و پیگیری کنید."
|
||||
>
|
||||
<template #actions>
|
||||
<RouterLink :to="{ name: 'ticket-new' }">
|
||||
<UiButton variant="primary">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
تیکت جدید
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">تیکتها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
درخواستهای پشتیبانی را ثبت و پیگیری کنید.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button type="primary" @click="goNew">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تیکت جدید
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<div class="tools">
|
||||
<form class="search" @submit.prevent="onSearch">
|
||||
<UiIcon name="search" :size="18" />
|
||||
<input v-model="q" type="search" placeholder="جستجو در موضوع یا شماره…" />
|
||||
</form>
|
||||
<button
|
||||
class="btn ghost sla-btn"
|
||||
type="button"
|
||||
:class="{ on: slaOnly }"
|
||||
@click="toggleSla"
|
||||
>
|
||||
فقط SLA
|
||||
</button>
|
||||
<div class="filter-wrap" @click.stop>
|
||||
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
<span class="btn-full">{{ statusFilterLabel }}</span>
|
||||
<span class="btn-short">وضعیت</span>
|
||||
<UiIcon name="chevron" :size="14" class="chev" />
|
||||
</button>
|
||||
<div v-if="filterOpen" class="popover">
|
||||
<button type="button" :class="{ on: status === 'all' }" @click="setStatus('all')">
|
||||
همه وضعیتها
|
||||
</button>
|
||||
<button
|
||||
v-for="(label, key) in statusLabel"
|
||||
:key="key"
|
||||
type="button"
|
||||
:class="{ on: status === key }"
|
||||
@click="setStatus(key as TicketStatus)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="onSearch">
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="12" :lg="10">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو در موضوع یا شماره…"
|
||||
style="width: 100%"
|
||||
@pressEnter="onSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
:value="status"
|
||||
:options="statusOptions"
|
||||
style="width: 100%"
|
||||
@update:value="onStatusChange"
|
||||
>
|
||||
<template #suffixIcon><FilterOutlined /></template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="8">
|
||||
<a-form-item label=" ">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button :type="slaOnly ? 'primary' : 'default'" @click="toggleSla">
|
||||
فقط SLA
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit">اعمال</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="listEmpty"
|
||||
title="هنوز تیکتی ثبت نشده است"
|
||||
description="اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند."
|
||||
>
|
||||
<template #action>
|
||||
<RouterLink :to="{ name: 'ticket-new' }">
|
||||
<UiButton variant="primary">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
تیکت جدید
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
|
||||
<template v-else>
|
||||
<div class="list-head">
|
||||
<strong>{{ toFaDigits(items.length) }} تیکت</strong>
|
||||
</div>
|
||||
|
||||
<div class="desktop-only">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="tickets" align="end" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap desktop-only">
|
||||
<table v-table-cols="'tickets'" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>موضوع</th>
|
||||
<th>شماره</th>
|
||||
<th>وضعیت</th>
|
||||
<th>اولویت</th>
|
||||
<th>مشتری / مسئول</th>
|
||||
<th>SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ticket in items" :key="ticket.id" :class="{ breached: ticket.slaBreached }">
|
||||
<td>
|
||||
<RouterLink class="title" :to="{ name: 'ticket-detail', params: { id: ticket.id } }">
|
||||
{{ ticket.subject }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="muted">{{ ticket.number }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="ticket.status">
|
||||
{{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="prio" :class="ticket.priority">
|
||||
{{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">
|
||||
<span v-if="ticket.person">{{ ticket.person.fullName }}</span>
|
||||
<span v-if="ticket.person && ticket.assignee"> · </span>
|
||||
<span v-if="ticket.assignee">{{ ticket.assignee.fullName }}</span>
|
||||
<span v-if="!ticket.person && !ticket.assignee">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="ticket.slaBreached" class="sla bad">تأخیر SLA</span>
|
||||
<span v-else-if="ticket.firstRespondedAt" class="sla ok">پاسخ داده شد</span>
|
||||
<span v-else-if="ticket.slaDueAt" class="sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="cards mobile-only">
|
||||
<RouterLink
|
||||
v-for="ticket in items"
|
||||
:key="ticket.id"
|
||||
class="m-card"
|
||||
:class="{ breached: ticket.slaBreached }"
|
||||
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="listEmpty" :bordered="false">
|
||||
<a-empty
|
||||
description="هنوز تیکتی ثبت نشده است. اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند."
|
||||
>
|
||||
<div class="m-top">
|
||||
<strong>{{ ticket.subject }}</strong>
|
||||
<span class="badge" :class="ticket.status">
|
||||
{{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">
|
||||
{{ ticket.number }} ·
|
||||
{{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
|
||||
</p>
|
||||
<p v-if="ticket.person || ticket.assignee" class="meta">
|
||||
<span v-if="ticket.person">{{ ticket.person.fullName }}</span>
|
||||
<span v-if="ticket.assignee"> · {{ ticket.assignee.fullName }}</span>
|
||||
</p>
|
||||
<div class="m-foot">
|
||||
<span v-if="ticket.slaBreached" class="sla bad">تأخیر SLA</span>
|
||||
<span v-else-if="ticket.firstRespondedAt" class="sla ok">پاسخ داده شد</span>
|
||||
<span v-else-if="ticket.slaDueAt" class="sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<a-button type="primary" @click="goNew">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تیکت جدید
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</a-card>
|
||||
|
||||
<a-card v-else-if="!error" :bordered="false">
|
||||
<a-flex justify="space-between" align="center" :gap="12" :style="{ marginBottom: '12px' }">
|
||||
<a-typography-text strong>
|
||||
{{ toFaDigits(items.length) }} تیکت
|
||||
</a-typography-text>
|
||||
<TableColumnsMenu table-key="tickets" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 720 }"
|
||||
:custom-row="(record: TicketDto) => ({
|
||||
style: record.slaBreached
|
||||
? { background: 'var(--ant-color-error-bg)' }
|
||||
: undefined,
|
||||
onClick: () => goDetail(record.id),
|
||||
})"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'موضوع'">
|
||||
<a-typography-link @click.stop="goDetail((record as TicketDto).id)">
|
||||
{{ (record as TicketDto).subject }}
|
||||
</a-typography-link>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شماره'">
|
||||
<a-typography-text type="secondary">
|
||||
{{ (record as TicketDto).number }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag
|
||||
:color="statusTagColor((record as TicketDto).status)"
|
||||
:bordered="false"
|
||||
>
|
||||
{{
|
||||
statusLabel[(record as TicketDto).status as TicketStatus] ||
|
||||
(record as TicketDto).status
|
||||
}}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'اولویت'">
|
||||
<a-tag
|
||||
:color="priorityTagColor((record as TicketDto).priority)"
|
||||
:bordered="false"
|
||||
>
|
||||
{{
|
||||
priorityLabel[(record as TicketDto).priority as TicketPriority] ||
|
||||
(record as TicketDto).priority
|
||||
}}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مشتری / مسئول'">
|
||||
<a-typography-text type="secondary">
|
||||
{{ personAssigneeText(record as TicketDto) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'SLA'">
|
||||
<a-tag
|
||||
v-if="slaText(record as TicketDto).text !== '—'"
|
||||
:color="slaText(record as TicketDto).color"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ slaText(record as TicketDto).text }}
|
||||
</a-tag>
|
||||
<a-typography-text v-else type="secondary">—</a-typography-text>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted,
|
||||
.error,
|
||||
.meta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted,
|
||||
.meta {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-decoration: none;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.28);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 400px;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.btn.ghost.on {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.btn.wide {
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.sla-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-btn .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.btn-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-end: 0;
|
||||
min-width: 11rem;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.popover button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.popover button:hover,
|
||||
.popover button.on {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
padding: 2.4rem 1.2rem 2rem;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.art svg {
|
||||
width: 148px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-card h2 {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.empty-card > p {
|
||||
max-width: 22rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.9rem 1rem;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.data-table tr.breached td {
|
||||
background: color-mix(in srgb, #fef2f2 55%, #fff);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.prio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.open,
|
||||
.prio.normal {
|
||||
background: #ecfeff;
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.badge.in_progress {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.badge.waiting {
|
||||
background: #fefce8;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.badge.resolved,
|
||||
.badge.closed {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.prio.low {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.prio.high,
|
||||
.prio.urgent {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.sla {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.sla.bad {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.sla.ok {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-card.breached {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, #b91c1c 25%, transparent),
|
||||
0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.m-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.m-top strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m-foot {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tools {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-short {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.add-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
}
|
||||
|
||||
.sla-btn {
|
||||
font-size: 0.85rem;
|
||||
padding-inline: 0.7rem;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
import type {
|
||||
ActivityDto,
|
||||
HomeDashboardDto,
|
||||
PipelineBoardDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
/** پیشنمایش داشبورد خانه وقتی API خالی است یا FORCE روشن باشد */
|
||||
export const HOME_DASHBOARD_MOCK: HomeDashboardDto = {
|
||||
currency: 'IRR',
|
||||
kpis: [
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'فروش این ماه',
|
||||
value: 186_500_000,
|
||||
changePercent: 18.4,
|
||||
changeLabel: 'نسبت به ماه قبل',
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: 'مشتریان جدید',
|
||||
value: 42,
|
||||
changePercent: 12.5,
|
||||
changeLabel: 'نسبت به ماه قبل',
|
||||
},
|
||||
{
|
||||
key: 'open-deals',
|
||||
label: 'معاملات باز',
|
||||
value: 27,
|
||||
changePercent: -4.2,
|
||||
changeLabel: 'نسبت به هفته قبل',
|
||||
},
|
||||
{
|
||||
key: 'followups',
|
||||
label: 'پیگیری امروز',
|
||||
value: 9,
|
||||
changePercent: 25,
|
||||
changeLabel: 'نسبت به دیروز',
|
||||
},
|
||||
{
|
||||
key: 'win-rate',
|
||||
label: 'نرخ برد',
|
||||
value: 58.3,
|
||||
changePercent: null,
|
||||
changeLabel: '۱۲ ماه اخیر',
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'عقبافتاده',
|
||||
value: 6,
|
||||
changePercent: null,
|
||||
changeLabel: 'فعالیت باز',
|
||||
},
|
||||
],
|
||||
alerts: [
|
||||
{
|
||||
key: 'stale-deal',
|
||||
tone: 'urgent',
|
||||
text: 'شرکت نمونه آکمه در مرحله تصمیمگیری است — امروز تماس بگیرید',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'stale-count',
|
||||
tone: 'warning',
|
||||
text: '۵ معامله بیش از ۵ روز پیگیری نشده',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'overdue-acts',
|
||||
tone: 'warning',
|
||||
text: '۶ پیگیری عقبافتاده دارید — همین امروز ببندید',
|
||||
link: '/activities',
|
||||
},
|
||||
{
|
||||
key: 'target-close',
|
||||
tone: 'info',
|
||||
text: 'به تارگت ماه نزدیک شدهاید',
|
||||
link: '/sales/forecast',
|
||||
},
|
||||
{
|
||||
key: 'big-deal',
|
||||
tone: 'info',
|
||||
text: 'پیشنهاد ۱۱۲ میلیون تومانی سپهر در انتظار تأیید است',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'win-streak',
|
||||
tone: 'success',
|
||||
text: 'این هفته ۳ معامله برنده بستهاید',
|
||||
link: '/reports',
|
||||
},
|
||||
{
|
||||
key: 'meeting-today',
|
||||
tone: 'urgent',
|
||||
text: 'جلسه دمو با پارسنوین ساعت ۱۴ — آمادهسازی پیشنهاد',
|
||||
link: '/activities',
|
||||
},
|
||||
{
|
||||
key: 'quote-expire',
|
||||
tone: 'warning',
|
||||
text: 'پیشفاکتور صنایع سپهر تا فردا منقضی میشود',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'new-lead',
|
||||
tone: 'info',
|
||||
text: '۴ لید جدید از فرم وبسایت بدون مالک ماندهاند',
|
||||
link: '/contacts',
|
||||
},
|
||||
{
|
||||
key: 'renewal',
|
||||
tone: 'info',
|
||||
text: 'قرارداد تمدید سارا محمدی این ماه سررسید میشود',
|
||||
link: '/pipeline',
|
||||
},
|
||||
],
|
||||
salesTrend: [
|
||||
{ monthKey: '1403-07', monthLabel: 'مهر', amount: 64_000_000 },
|
||||
{ monthKey: '1403-08', monthLabel: 'آبان', amount: 71_500_000 },
|
||||
{ monthKey: '1403-09', monthLabel: 'آذر', amount: 68_200_000 },
|
||||
{ monthKey: '1403-10', monthLabel: 'دی', amount: 79_800_000 },
|
||||
{ monthKey: '1403-11', monthLabel: 'بهمن', amount: 88_400_000 },
|
||||
{ monthKey: '1403-12', monthLabel: 'اسفند', amount: 95_000_000 },
|
||||
{ monthKey: '1404-01', monthLabel: 'فروردین', amount: 82_600_000 },
|
||||
{ monthKey: '1404-02', monthLabel: 'اردیبهشت', amount: 92_000_000 },
|
||||
{ monthKey: '1404-03', monthLabel: 'خرداد', amount: 118_500_000 },
|
||||
{ monthKey: '1404-04', monthLabel: 'تیر', amount: 141_200_000 },
|
||||
{ monthKey: '1404-05', monthLabel: 'مرداد', amount: 157_800_000 },
|
||||
{ monthKey: '1404-06', monthLabel: 'شهریور', amount: 186_500_000 },
|
||||
],
|
||||
customerGrowth: [
|
||||
{ monthKey: '1403-07', monthLabel: 'مهر', value: 11 },
|
||||
{ monthKey: '1403-08', monthLabel: 'آبان', value: 14 },
|
||||
{ monthKey: '1403-09', monthLabel: 'آذر', value: 13 },
|
||||
{ monthKey: '1403-10', monthLabel: 'دی', value: 16 },
|
||||
{ monthKey: '1403-11', monthLabel: 'بهمن', value: 19 },
|
||||
{ monthKey: '1403-12', monthLabel: 'اسفند', value: 22 },
|
||||
{ monthKey: '1404-01', monthLabel: 'فروردین', value: 15 },
|
||||
{ monthKey: '1404-02', monthLabel: 'اردیبهشت', value: 18 },
|
||||
{ monthKey: '1404-03', monthLabel: 'خرداد', value: 24 },
|
||||
{ monthKey: '1404-04', monthLabel: 'تیر', value: 31 },
|
||||
{ monthKey: '1404-05', monthLabel: 'مرداد', value: 28 },
|
||||
{ monthKey: '1404-06', monthLabel: 'شهریور', value: 42 },
|
||||
],
|
||||
sources: [
|
||||
{ label: 'فرم لید', count: 38, percent: 32.2 },
|
||||
{ label: 'معرفی دوستان', count: 29, percent: 24.6 },
|
||||
{ label: 'وبسایت', count: 21, percent: 17.8 },
|
||||
{ label: 'اکسل', count: 16, percent: 13.6 },
|
||||
{ label: 'تلفن', count: 8, percent: 6.8 },
|
||||
{ label: 'سایر', count: 6, percent: 5 },
|
||||
],
|
||||
topCustomers: [
|
||||
{ id: 'mock-c1', name: 'شرکت آکمه', dealCount: 7, rank: 1 },
|
||||
{ id: 'mock-c2', name: 'گروه پارسنوین', dealCount: 5, rank: 2 },
|
||||
{ id: 'mock-c3', name: 'سارا محمدی', dealCount: 4, rank: 3 },
|
||||
{ id: 'mock-c4', name: 'صنایع سپهر', dealCount: 3, rank: 4 },
|
||||
{ id: 'mock-c5', name: 'رضا کریمی', dealCount: 3, rank: 5 },
|
||||
],
|
||||
salesSummary: {
|
||||
openCount: 27,
|
||||
wonCount: 35,
|
||||
lostCount: 25,
|
||||
openAmount: 412_000_000,
|
||||
wonAmount: 696_000_000,
|
||||
lostAmount: 218_000_000,
|
||||
winRate: 58.3,
|
||||
},
|
||||
funnel: [
|
||||
{ stageId: 's1', stageName: 'تماس اول', count: 11, amount: 95_000_000 },
|
||||
{ stageId: 's2', stageName: 'نیازسنجی', count: 8, amount: 128_000_000 },
|
||||
{ stageId: 's3', stageName: 'پیشنهاد', count: 5, amount: 112_000_000 },
|
||||
{ stageId: 's4', stageName: 'مذاکره', count: 3, amount: 77_000_000 },
|
||||
],
|
||||
lostReasons: [
|
||||
{ label: 'قیمت', count: 9, percent: 36, amount: 82_000_000 },
|
||||
{ label: 'رقیب', count: 7, percent: 28, amount: 61_000_000 },
|
||||
{ label: 'زمانبندی', count: 5, percent: 20, amount: 44_000_000 },
|
||||
{ label: 'بدون دلیل ثبتشده', count: 4, percent: 16, amount: 31_000_000 },
|
||||
],
|
||||
activityByType: [
|
||||
{ type: 'call', label: 'تماس', count: 14 },
|
||||
{ type: 'meeting', label: 'جلسه', count: 7 },
|
||||
{ type: 'task', label: 'کار', count: 11 },
|
||||
{ type: 'note', label: 'یادداشت', count: 4 },
|
||||
],
|
||||
};
|
||||
|
||||
function hoursFromNow(hours: number) {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + hours);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export const HOME_ACTIVITIES_MOCK = [
|
||||
{
|
||||
id: 'mock-act-1',
|
||||
type: 'call',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
title: 'تماس پیگیری پیشنهاد آکمه',
|
||||
description: null,
|
||||
dueAt: hoursFromNow(3),
|
||||
durationMinutes: 30,
|
||||
reminderAt: null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
reminderSentAt: null,
|
||||
completedAt: null,
|
||||
tags: [],
|
||||
person: { id: 'p1', fullName: 'علی رضایی' },
|
||||
company: { id: 'c1', name: 'شرکت آکمه' },
|
||||
deal: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdBy: null,
|
||||
createdAt: hoursFromNow(-20),
|
||||
updatedAt: hoursFromNow(-2),
|
||||
},
|
||||
{
|
||||
id: 'mock-act-2',
|
||||
type: 'meeting',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
title: 'جلسه دمو با پارسنوین',
|
||||
description: null,
|
||||
dueAt: hoursFromNow(26),
|
||||
durationMinutes: 60,
|
||||
reminderAt: null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
reminderSentAt: null,
|
||||
completedAt: null,
|
||||
tags: [],
|
||||
person: { id: 'p2', fullName: 'مریم احمدی' },
|
||||
company: { id: 'c2', name: 'گروه پارسنوین' },
|
||||
deal: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdBy: null,
|
||||
createdAt: hoursFromNow(-48),
|
||||
updatedAt: hoursFromNow(-10),
|
||||
},
|
||||
{
|
||||
id: 'mock-act-3',
|
||||
type: 'task',
|
||||
status: 'done',
|
||||
priority: 'low',
|
||||
title: 'ارسال پیشفاکتور سپهر',
|
||||
description: null,
|
||||
dueAt: hoursFromNow(-5),
|
||||
durationMinutes: null,
|
||||
reminderAt: null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
reminderSentAt: null,
|
||||
completedAt: hoursFromNow(-4),
|
||||
tags: [],
|
||||
person: null,
|
||||
company: { id: 'c3', name: 'صنایع سپهر' },
|
||||
deal: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdBy: null,
|
||||
createdAt: hoursFromNow(-72),
|
||||
updatedAt: hoursFromNow(-4),
|
||||
},
|
||||
] as unknown as ActivityDto[];
|
||||
|
||||
export const HOME_PIPELINE_MOCK = {
|
||||
pipeline: {
|
||||
id: 'mock-pipe',
|
||||
name: 'فروش اصلی',
|
||||
isDefault: true,
|
||||
createdAt: hoursFromNow(-1000),
|
||||
updatedAt: hoursFromNow(-1),
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'تماس اول',
|
||||
position: 0,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
totalAmount: 95_000_000,
|
||||
deals: [
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'لایسنس سازمانی',
|
||||
amount: 45_000_000,
|
||||
currency: 'IRR',
|
||||
company: { id: 'c1', name: 'شرکت آکمه' },
|
||||
person: null,
|
||||
updatedAt: hoursFromNow(-30),
|
||||
createdAt: hoursFromNow(-200),
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
title: 'پکیج پشتیبانی',
|
||||
amount: 22_000_000,
|
||||
currency: 'IRR',
|
||||
company: null,
|
||||
person: { id: 'p3', fullName: 'سارا محمدی' },
|
||||
updatedAt: hoursFromNow(-8),
|
||||
createdAt: hoursFromNow(-90),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
name: 'نیازسنجی',
|
||||
position: 1,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
totalAmount: 128_000_000,
|
||||
deals: [
|
||||
{
|
||||
id: 'd3',
|
||||
title: 'ارتقای CRM',
|
||||
amount: 68_000_000,
|
||||
currency: 'IRR',
|
||||
company: { id: 'c2', name: 'گروه پارسنوین' },
|
||||
person: null,
|
||||
updatedAt: hoursFromNow(-12),
|
||||
createdAt: hoursFromNow(-140),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
name: 'پیشنهاد',
|
||||
position: 2,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
totalAmount: 112_000_000,
|
||||
deals: [
|
||||
{
|
||||
id: 'd4',
|
||||
title: 'پروژه یکپارچهسازی',
|
||||
amount: 112_000_000,
|
||||
currency: 'IRR',
|
||||
company: { id: 'c3', name: 'صنایع سپهر' },
|
||||
person: null,
|
||||
updatedAt: hoursFromNow(-2),
|
||||
createdAt: hoursFromNow(-60),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as PipelineBoardDto;
|
||||
|
||||
export function isHomeDashboardEmpty(data: HomeDashboardDto | null | undefined): boolean {
|
||||
if (!data) return true;
|
||||
const salesFlat = (data.salesTrend ?? []).every((p) => !p.amount);
|
||||
const growthFlat = (data.customerGrowth ?? []).every((p) => !p.value);
|
||||
const noSources = !(data.sources ?? []).length;
|
||||
const kpisFlat = (data.kpis ?? []).every((k) => !k.value);
|
||||
return (salesFlat && growthFlat && noSources) || kpisFlat;
|
||||
}
|
||||
@@ -2,11 +2,9 @@
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import type { RoutineCalendarDayDto, RoutineCalendarDto, RoutineTaskDto } from '@mento/shared';
|
||||
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiButton, UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -25,6 +23,25 @@ import {
|
||||
weekStart,
|
||||
} from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
type View = 'day' | 'week' | 'month';
|
||||
|
||||
@@ -44,6 +61,11 @@ const viewOptions = [
|
||||
{ value: 'month' as const, label: 'ماه' },
|
||||
];
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: 'تقویم خودم' },
|
||||
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
|
||||
const range = computed(() => {
|
||||
if (view.value === 'day') return { from: anchor.value, to: anchor.value, label: formatDayLong(anchor.value) };
|
||||
if (view.value === 'week') {
|
||||
@@ -143,411 +165,223 @@ function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
||||
else actions.openCancel(task);
|
||||
}
|
||||
|
||||
const STATE_TAG_COLORS: Record<string, string> = {
|
||||
complete: 'success',
|
||||
partial: 'warning',
|
||||
missed: 'error',
|
||||
empty: 'default',
|
||||
future: 'default',
|
||||
};
|
||||
|
||||
const STATE_DOT_COLORS: Record<string, string> = {
|
||||
complete: 'var(--ant-color-success)',
|
||||
partial: 'var(--ant-color-warning)',
|
||||
missed: 'var(--ant-color-error)',
|
||||
empty: 'var(--ant-color-text-quaternary)',
|
||||
};
|
||||
|
||||
const STATE_HEAD_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-fill-quaternary)',
|
||||
};
|
||||
|
||||
const TASK_DOT_COLORS: Record<string, string> = {
|
||||
completed: 'var(--ant-color-success)',
|
||||
overdue: 'var(--ant-color-error)',
|
||||
skipped: 'var(--ant-color-warning)',
|
||||
cancelled: 'var(--ant-color-warning)',
|
||||
};
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const today = todayKey();
|
||||
|
||||
const legendItems = [
|
||||
{ state: 'complete', label: 'روز کامل' },
|
||||
{ state: 'partial', label: 'بخشی انجام شده' },
|
||||
{ state: 'missed', label: 'انجام نشده' },
|
||||
{ state: 'empty', label: 'بدون کار' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<div class="cal-toolbar">
|
||||
<div class="nav">
|
||||
<UiButton variant="icon" aria-label="قبلی" @click="move(-1)">
|
||||
<UiIcon name="chevron-right" :size="18" />
|
||||
</UiButton>
|
||||
<strong class="range-label">{{ range.label }}</strong>
|
||||
<UiButton variant="icon" aria-label="بعدی" @click="move(1)">
|
||||
<UiIcon name="chevron-left" :size="18" />
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="goToday">امروز</UiButton>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<UiSelect v-if="isManager && users.length > 1" v-model="userId" size="sm" class="who">
|
||||
<option value="">تقویم خودم</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
<UiSegmented v-model="view" :options="viewOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<a-space :size="8" align="center">
|
||||
<a-button aria-label="قبلی" @click="move(-1)">
|
||||
<template #icon><RightOutlined /></template>
|
||||
</a-button>
|
||||
<a-typography-text strong :style="{ minWidth: '150px', textAlign: 'center' }">
|
||||
{{ range.label }}
|
||||
</a-typography-text>
|
||||
<a-button aria-label="بعدی" @click="move(1)">
|
||||
<template #icon><LeftOutlined /></template>
|
||||
</a-button>
|
||||
<a-button @click="goToday">امروز</a-button>
|
||||
</a-space>
|
||||
<a-space :size="8" wrap>
|
||||
<a-select
|
||||
v-if="isManager && users.length > 1"
|
||||
v-model:value="userId"
|
||||
:options="userOptions"
|
||||
:style="{ width: '200px' }"
|
||||
/>
|
||||
<a-segmented v-model:value="view" :options="viewOptions" />
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div class="legend">
|
||||
<span><i class="lg complete" /> روز کامل</span>
|
||||
<span><i class="lg partial" /> بخشی انجام شده</span>
|
||||
<span><i class="lg missed" /> انجام نشده</span>
|
||||
<span><i class="lg empty" /> بدون کار</span>
|
||||
</div>
|
||||
<a-flex wrap="wrap" :gap="16">
|
||||
<a-space v-for="item in legendItems" :key="item.state" :size="6">
|
||||
<a-flex
|
||||
:style="{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
background: STATE_DOT_COLORS[item.state],
|
||||
flexShrink: 0,
|
||||
}"
|
||||
/>
|
||||
<a-typography-text type="secondary">{{ item.label }}</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="actions.error.value" type="error" show-icon :message="actions.error.value" />
|
||||
|
||||
<div class="cal-body" :class="{ dim: loading }">
|
||||
<!-- ماه -->
|
||||
<div v-if="view === 'month'" class="month-layout">
|
||||
<a-spin :spinning="loading">
|
||||
<RoutineMonthGrid
|
||||
v-if="view === 'month'"
|
||||
:from="range.from"
|
||||
:to="range.to"
|
||||
:days="cal.days"
|
||||
:selected="selected"
|
||||
@select="selectDay"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- هفته -->
|
||||
<div v-else-if="view === 'week'" class="week">
|
||||
<div
|
||||
v-for="(d, i) in weekDays"
|
||||
:key="d.key"
|
||||
class="week-col"
|
||||
:class="[`s-${d.day?.state ?? 'empty'}`, { today: d.key === today, sel: d.key === selected }]"
|
||||
>
|
||||
<button type="button" class="week-head" @click="openDay(d.key)">
|
||||
<span>{{ ROUTINE_WEEKDAY_LABELS[i] }}</span>
|
||||
<strong>{{ formatDayNumeric(d.key) }}</strong>
|
||||
<em v-if="d.day?.total">{{ toFaDigits(d.day.completed) }}/{{ toFaDigits(d.day.total) }}</em>
|
||||
</button>
|
||||
<ul class="week-tasks">
|
||||
<li
|
||||
v-for="t in d.tasks"
|
||||
:key="t.id"
|
||||
:class="`st-${t.status}`"
|
||||
@click="shell.openTask(t.id)"
|
||||
<a-flex v-else-if="view === 'week'" wrap="wrap" :gap="6" align="stretch">
|
||||
<a-card
|
||||
v-for="(d, i) in weekDays"
|
||||
:key="d.key"
|
||||
:bordered="true"
|
||||
:style="{
|
||||
flex: '1 1 150px',
|
||||
minWidth: 0,
|
||||
borderColor: d.key === today ? token.colorPrimary : undefined,
|
||||
boxShadow: d.key === selected ? `0 0 0 1px ${token.colorPrimary}` : undefined,
|
||||
}"
|
||||
:body-style="{ padding: '8px' }"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
block
|
||||
:style="{
|
||||
height: 'auto',
|
||||
padding: '8px',
|
||||
textAlign: 'right',
|
||||
background: STATE_HEAD_BG[d.day?.state ?? 'empty'],
|
||||
}"
|
||||
@click="openDay(d.key)"
|
||||
>
|
||||
<span class="wt-dot" />
|
||||
<span class="wt-title">{{ t.title }}</span>
|
||||
<small v-if="hasExplicitTime(t)">{{ formatTime(t.dueAt) }}</small>
|
||||
</li>
|
||||
<li v-if="!d.tasks.length" class="none">—</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-flex vertical align="start" :gap="0" :style="{ width: '100%' }">
|
||||
<a-typography-text type="secondary">{{ ROUTINE_WEEKDAY_LABELS[i] }}</a-typography-text>
|
||||
<a-typography-text strong>{{ formatDayNumeric(d.key) }}</a-typography-text>
|
||||
<a-typography-text v-if="d.day?.total" type="secondary">
|
||||
{{ toFaDigits(d.day.completed) }}/{{ toFaDigits(d.day.total) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-button>
|
||||
<a-flex vertical :gap="4" :style="{ marginTop: '8px' }">
|
||||
<a-button
|
||||
v-for="t in d.tasks"
|
||||
:key="t.id"
|
||||
type="text"
|
||||
block
|
||||
:style="{ height: 'auto', padding: '6px', textAlign: 'right' }"
|
||||
@click="shell.openTask(t.id)"
|
||||
>
|
||||
<a-flex align="center" :gap="6" :style="{ width: '100%' }">
|
||||
<a-flex
|
||||
:style="{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
background: TASK_DOT_COLORS[t.status] ?? 'var(--ant-color-text-quaternary)',
|
||||
flexShrink: 0,
|
||||
}"
|
||||
/>
|
||||
<a-typography-text
|
||||
:delete="t.status === 'completed'"
|
||||
type="secondary"
|
||||
ellipsis
|
||||
:style="{ flex: 1, minWidth: 0, textAlign: 'right' }"
|
||||
>
|
||||
{{ t.title }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-text v-if="hasExplicitTime(t)" type="secondary">
|
||||
{{ formatTime(t.dueAt) }}
|
||||
</a-typography-text>
|
||||
</a-button>
|
||||
<a-typography-text
|
||||
v-if="!d.tasks.length"
|
||||
type="secondary"
|
||||
:style="{ textAlign: 'center' }"
|
||||
>
|
||||
—
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
|
||||
<!-- جزئیات روز انتخابشده / نمای روز -->
|
||||
<section v-if="view !== 'week'" class="day-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{{ formatDayLong(focusDay) }}</h3>
|
||||
<p v-if="focusStat?.total" class="day-sub">
|
||||
<span :class="`chip s-${focusStat.state}`">{{ DAY_STATE_LABELS[focusStat.state] }}</span>
|
||||
{{ toFaDigits(focusStat.completed) }} از {{ toFaDigits(focusStat.total) }} انجام شده
|
||||
· {{ formatPercent(focusStat.percent) }}
|
||||
</p>
|
||||
</div>
|
||||
<UiButton v-if="view === 'month'" variant="ghost" size="sm" @click="openDay(focusDay)">نمای روز</UiButton>
|
||||
</header>
|
||||
<div v-if="focusTasks.length" class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in focusTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
:show-assignee="!!userId"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</div>
|
||||
<UiEmptyState
|
||||
v-else
|
||||
icon="calendar"
|
||||
title="کاری برای این روز نیست"
|
||||
:description="focusDay > today ? 'کارهای روتین روزهای آینده صبح همان روز ساخته میشوند.' : undefined"
|
||||
/>
|
||||
</section>
|
||||
<template v-if="view !== 'week'">
|
||||
<a-divider orientation="right">جزئیات روز</a-divider>
|
||||
<a-flex justify="space-between" align="start" wrap="wrap" :gap="8">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="4" :style="{ margin: 0 }">
|
||||
{{ formatDayLong(focusDay) }}
|
||||
</a-typography-title>
|
||||
<a-space v-if="focusStat?.total" :size="6" wrap>
|
||||
<a-tag :color="STATE_TAG_COLORS[focusStat.state] ?? 'default'" :bordered="false">
|
||||
{{ DAY_STATE_LABELS[focusStat.state] }}
|
||||
</a-tag>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(focusStat.completed) }} از {{ toFaDigits(focusStat.total) }} انجام شده
|
||||
· {{ formatPercent(focusStat.percent) }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
<a-button v-if="view === 'month'" @click="openDay(focusDay)">نمای روز</a-button>
|
||||
</a-flex>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</div>
|
||||
<a-flex v-if="focusTasks.length" vertical :gap="8">
|
||||
<RoutineTaskRow
|
||||
v-for="t in focusTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
:show-assignee="!!userId"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</a-flex>
|
||||
<a-empty
|
||||
v-else
|
||||
description="کاری برای این روز نیست"
|
||||
>
|
||||
<template v-if="focusDay > today" #description>
|
||||
<a-typography-text type="secondary">
|
||||
کاری برای این روز نیست؛ کارهای روتین روزهای آینده صبح همان روز ساخته میشوند.
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-empty>
|
||||
</template>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.cal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.range-label {
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lg.complete {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.lg.partial {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.lg.missed {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.lg.empty {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.cal-body {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.cal-body.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.week-col {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 260px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.week-col.today {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.week-head {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
background: var(--surface-subtle);
|
||||
text-align: right;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.s-complete .week-head {
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.s-partial .week-head {
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.s-missed .week-head {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.week-head span {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.week-head strong {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.week-head em {
|
||||
font-style: normal;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.week-tasks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.week-tasks li {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px 6px;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
cursor: pointer;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.week-tasks li:hover {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.week-tasks li small {
|
||||
grid-column: 2;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.week-tasks li.none {
|
||||
background: transparent;
|
||||
color: var(--muted-light);
|
||||
cursor: default;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wt-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.st-completed .wt-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.st-completed .wt-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-overdue .wt-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.st-skipped .wt-dot,
|
||||
.st-cancelled .wt-dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.wt-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.day-panel {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.day-panel header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.day-panel h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.day-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 1px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.chip.s-complete {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.chip.s-partial {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.chip.s-missed {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.week {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.week-col {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tools,
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import type { RoutineDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -14,6 +12,27 @@ import {
|
||||
formatPercent,
|
||||
percentTone,
|
||||
} from '@/lib/routines';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
@@ -43,213 +62,115 @@ const filtered = computed(() => {
|
||||
if (!term) return items.value;
|
||||
return items.value.filter((r) => r.title.includes(term) || (r.description ?? '').includes(term));
|
||||
});
|
||||
|
||||
const PRIORITY_TAG_COLORS: Record<string, string> = {
|
||||
neutral: 'default',
|
||||
info: 'processing',
|
||||
warning: 'warning',
|
||||
danger: 'error',
|
||||
success: 'success',
|
||||
accent: 'cyan',
|
||||
};
|
||||
|
||||
function priorityColor(priority: RoutineDto['priority']) {
|
||||
return PRIORITY_TAG_COLORS[PRIORITY_VARIANTS[priority]] ?? 'default';
|
||||
}
|
||||
|
||||
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)',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rlist">
|
||||
<div class="toolbar">
|
||||
<p class="lead">روتینهایی که به شما یا تیم شما سپرده شدهاند. کارهای هر روز از روی اینها ساخته میشوند.</p>
|
||||
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-row :gutter="[16, 12]" align="middle">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-typography-text type="secondary">
|
||||
روتینهایی که به شما یا تیم شما سپرده شدهاند. کارهای هر روز از روی اینها ساخته میشوند.
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-input v-model:value="q" allow-clear placeholder="جستجوی روتین…">
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!filtered.length"
|
||||
icon="refresh"
|
||||
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
|
||||
description="روتینها توسط مدیر تیم تعریف میشوند و هر روز بهصورت خودکار در «کارهای امروز» ظاهر میشوند."
|
||||
/>
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !filtered.length"
|
||||
:description="
|
||||
q
|
||||
? 'روتینی با این عنوان پیدا نشد'
|
||||
: 'هنوز روتینی برای شما تعریف نشده؛ روتینها توسط مدیر تیم تعریف میشوند و هر روز بهصورت خودکار در «کارهای امروز» ظاهر میشوند.'
|
||||
"
|
||||
/>
|
||||
|
||||
<div v-else class="cards">
|
||||
<article v-for="r in filtered" :key="r.id" class="rcard">
|
||||
<header>
|
||||
<span class="ico" :style="{ background: r.category?.color ?? 'var(--accent)' }">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
</span>
|
||||
<div class="titles">
|
||||
<h3>{{ r.title }}</h3>
|
||||
<p>{{ r.recurrenceLabel }} · ساعت {{ toFaDigits(r.timeOfDay) }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="r.description" class="desc">{{ r.description }}</p>
|
||||
<div class="tags">
|
||||
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ PRIORITY_LABELS[r.priority] }}</UiBadge>
|
||||
<UiBadge v-if="r.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
|
||||
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
|
||||
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</UiBadge>
|
||||
</div>
|
||||
<footer>
|
||||
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
|
||||
<span>اجرا در ۳۰ روز اخیر</span>
|
||||
<strong>{{ formatPercent(r.executionRate) }}</strong>
|
||||
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
|
||||
</div>
|
||||
<small class="since">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</small>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<a-row v-else :gutter="[16, 16]">
|
||||
<a-col v-for="r in filtered" :key="r.id" :xs="24" :sm="12" :xl="8">
|
||||
<a-card :bordered="false" :style="{ height: '100%', background: token.colorFillQuaternary }">
|
||||
<a-flex vertical :gap="12" :style="{ height: '100%' }">
|
||||
<a-flex align="center" :gap="12">
|
||||
<a-avatar
|
||||
shape="square"
|
||||
:style="{
|
||||
background: r.category?.color ?? token.colorPrimary,
|
||||
color: token.colorWhite,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<template #icon><SyncOutlined /></template>
|
||||
</a-avatar>
|
||||
<a-flex vertical :gap="0" :style="{ minWidth: 0 }">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
{{ r.title }}
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
{{ r.recurrenceLabel }} · ساعت {{ toFaDigits(r.timeOfDay) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<a-typography-paragraph v-if="r.description" type="secondary" :style="{ margin: 0 }">
|
||||
{{ r.description }}
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-space :size="6" wrap>
|
||||
<a-tag v-if="r.category" :bordered="false">{{ r.category.name }}</a-tag>
|
||||
<a-tag :color="priorityColor(r.priority)" :bordered="false">
|
||||
{{ PRIORITY_LABELS[r.priority] }}
|
||||
</a-tag>
|
||||
<a-tag v-if="r.isMandatory" color="warning" :bordered="false">اجباری</a-tag>
|
||||
<a-tag v-if="r.reportRequired" color="processing" :bordered="false">گزارش الزامی</a-tag>
|
||||
<a-tag v-if="r.team" color="cyan" :bordered="false">تیم {{ r.team.name }}</a-tag>
|
||||
</a-space>
|
||||
|
||||
<a-flex vertical :gap="4" :style="{ marginTop: 'auto' }">
|
||||
<a-divider :style="{ margin: 0 }" />
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">اجرا در ۳۰ روز اخیر</a-typography-text>
|
||||
<a-typography-text strong>{{ formatPercent(r.executionRate) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, Math.round(r.executionRate ?? 0)))"
|
||||
:show-info="false"
|
||||
:stroke-color="TONE_COLORS[percentTone(r.executionRate)]"
|
||||
/>
|
||||
<a-typography-text type="secondary">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rlist {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rcard {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.rcard header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titles h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.titles p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rcard footer {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.exec {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.exec strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 5px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good .bar b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid .bar b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low .bar b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.since {
|
||||
color: var(--muted-light);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,23 +2,67 @@
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { RoutineCategoryDto, RoutineDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiSearch,
|
||||
UiSegmented,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { ROUTINES_CTX, apiError, formatPercent, percentTone } from '@/lib/routines';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Badge as ABadge,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Dropdown as ADropdown,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Menu as AMenu,
|
||||
MenuItem as AMenuItem,
|
||||
Modal as AModal,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Switch as ASwitch,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
AppstoreOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const MANAGE_COL_LABELS = [
|
||||
'عنوان',
|
||||
'مسئول',
|
||||
'تکرار',
|
||||
'ساعت',
|
||||
'وضعیت',
|
||||
'درصد انجام',
|
||||
'عملیات',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('routines-manage', [...MANAGE_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'routines-manage');
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const router = useRouter();
|
||||
@@ -54,6 +98,37 @@ const statusOptions = [
|
||||
{ value: 'inactive' as const, label: 'غیرفعال' },
|
||||
];
|
||||
|
||||
const assigneeOptions = computed(() => [
|
||||
{ value: '', label: 'همه مسئولها' },
|
||||
{
|
||||
label: 'کاربران',
|
||||
options: users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
},
|
||||
...(teams.value.length
|
||||
? [
|
||||
{
|
||||
label: 'تیمها',
|
||||
options: teams.value.map((t) => ({ value: `team:${t.id}`, label: t.name })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const reassignOptions = computed(() => [
|
||||
{
|
||||
label: 'کاربران',
|
||||
options: users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
},
|
||||
...(teams.value.length
|
||||
? [
|
||||
{
|
||||
label: 'کل اعضای تیم',
|
||||
options: teams.value.map((t) => ({ value: `team:${t.id}`, label: t.name })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = q.value.trim();
|
||||
return items.value.filter((r) => {
|
||||
@@ -70,6 +145,36 @@ const counts = computed(() => ({
|
||||
active: items.value.filter((r) => r.isActive).length,
|
||||
}));
|
||||
|
||||
const columns = computed<TableColumnsType<RoutineDto>>(() => {
|
||||
const all: TableColumnsType<RoutineDto> = [
|
||||
{ title: 'عنوان', key: 'عنوان' },
|
||||
{ title: 'مسئول', key: 'مسئول' },
|
||||
{ title: 'تکرار', dataIndex: 'recurrenceLabel', key: 'تکرار' },
|
||||
{
|
||||
title: 'ساعت',
|
||||
key: 'ساعت',
|
||||
customRender: ({ record }) => toFaDigits(record.timeOfDay),
|
||||
},
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'درصد انجام', key: 'درصد انجام', width: 180 },
|
||||
{ title: 'عملیات', key: 'عملیات', fixed: 'left', width: 110 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!MANAGE_COL_LABELS.includes(key as (typeof MANAGE_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
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 replace(updated: RoutineDto) {
|
||||
const i = items.value.findIndex((r) => r.id === updated.id);
|
||||
if (i >= 0) items.value[i] = updated;
|
||||
@@ -206,468 +311,284 @@ async function deleteCat(c: RoutineCategoryDto) {
|
||||
}
|
||||
}
|
||||
|
||||
function onRowMenu({ key }: { key: string | number }, r: RoutineDto) {
|
||||
if (String(key) === 'edit') void router.push(`/routines/manage/${r.id}/edit`);
|
||||
else if (String(key) === 'reassign') openReassign(r);
|
||||
else if (String(key) === 'duplicate') void duplicate(r);
|
||||
else if (String(key) === 'toggle') void toggle(r);
|
||||
else if (String(key) === 'remove') removing.value = r;
|
||||
menuFor.value = null;
|
||||
}
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manage">
|
||||
<div class="toolbar">
|
||||
<UiSearch v-model="q" placeholder="جستجوی عنوان…" class="search" />
|
||||
<UiSelect v-model="assignee" size="sm" class="who">
|
||||
<option value="">همه مسئولها</option>
|
||||
<optgroup label="کاربران">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="teams.length" label="تیمها">
|
||||
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
|
||||
</optgroup>
|
||||
</UiSelect>
|
||||
<UiSegmented v-model="status" :options="statusOptions" />
|
||||
<span class="spacer" />
|
||||
<UiButton variant="ghost" size="sm" @click="catsOpen = true; editCat(null)">
|
||||
<template #icon><UiIcon name="layers" :size="15" /></template>
|
||||
دستهبندیها
|
||||
</UiButton>
|
||||
<UiButton @click="router.push('/routines/manage/new')">
|
||||
<template #icon><UiIcon name="plus" :size="16" /></template>
|
||||
روتین جدید
|
||||
</UiButton>
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12" :lg="6">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input v-model:value="q" allow-clear placeholder="جستجوی عنوان…">
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="5">
|
||||
<a-form-item label="مسئول">
|
||||
<a-select v-model:value="assignee" :options="assigneeOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="5">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-segmented v-model:value="status" :options="statusOptions" block />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-form-item label=" ">
|
||||
<a-flex justify="flex-end" :gap="8" wrap="wrap">
|
||||
<a-button @click="catsOpen = true; editCat(null)">
|
||||
<template #icon><AppstoreOutlined /></template>
|
||||
دستهبندیها
|
||||
</a-button>
|
||||
<a-button type="primary" @click="router.push('/routines/manage/new')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
روتین جدید
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<p class="summary">
|
||||
{{ toFaDigits(counts.total) }} روتین · {{ toFaDigits(counts.active) }} فعال
|
||||
</p>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(counts.total) }} روتین · {{ toFaDigits(counts.active) }} فعال
|
||||
</a-typography-text>
|
||||
<TableColumnsMenu table-key="routines-manage" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!filtered.length"
|
||||
icon="refresh"
|
||||
:title="items.length ? 'روتینی با این فیلتر پیدا نشد' : 'هنوز روتینی تعریف نشده'"
|
||||
description="روتینها کارهای تکرارشوندهای هستند که هر روز بهصورت خودکار برای مسئولشان ساخته میشوند."
|
||||
>
|
||||
<template v-if="!items.length" #action>
|
||||
<UiButton @click="router.push('/routines/manage/new')">ساخت اولین روتین</UiButton>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
|
||||
<div v-else class="crm-table-container">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>عنوان</th>
|
||||
<th>مسئول</th>
|
||||
<th>تکرار</th>
|
||||
<th>ساعت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>درصد انجام</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in filtered" :key="r.id" :class="{ inactive: !r.isActive }">
|
||||
<td class="title-cell">
|
||||
<span class="cat-dot" :style="{ background: r.category?.color ?? 'var(--line-strong)' }" />
|
||||
<div>
|
||||
<strong>{{ r.title }}</strong>
|
||||
<small>
|
||||
{{ r.category?.name ?? 'بدون دسته' }}
|
||||
<template v-if="r.isMandatory"> · اجباری</template>
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.team" class="who-cell">
|
||||
<span class="team-ico"><UiIcon name="users" :size="13" /></span>
|
||||
تیم {{ r.team.name }}
|
||||
</span>
|
||||
<span v-else-if="r.assignee" class="who-cell">
|
||||
<UserAvatar :name="r.assignee.fullName" :user-id="r.assignee.id" :size="24" />
|
||||
{{ r.assignee.fullName }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="nowrap">{{ r.recurrenceLabel }}</td>
|
||||
<td class="nowrap">{{ toFaDigits(r.timeOfDay) }}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
:class="{ on: r.isActive }"
|
||||
:disabled="busyId === r.id"
|
||||
:aria-pressed="r.isActive"
|
||||
:aria-label="r.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
|
||||
@click="toggle(r)"
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filtered"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty
|
||||
:description="
|
||||
items.length
|
||||
? 'روتینی با این فیلتر پیدا نشد'
|
||||
: 'هنوز روتینی تعریف نشده؛ روتینها کارهای تکرارشوندهای هستند که هر روز بهصورت خودکار برای مسئولشان ساخته میشوند.'
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
v-if="!items.length"
|
||||
type="primary"
|
||||
@click="router.push('/routines/manage/new')"
|
||||
>
|
||||
<i />
|
||||
</button>
|
||||
<UiBadge size="sm" :variant="r.isActive ? 'success' : 'neutral'">
|
||||
{{ r.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
|
||||
<strong>{{ formatPercent(r.executionRate) }}</strong>
|
||||
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
|
||||
<small v-if="r.executionTotal">
|
||||
{{ toFaDigits(r.executionDone) }} از {{ toFaDigits(r.executionTotal) }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<div class="crm-table-actions">
|
||||
<UiButton
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ساخت اولین روتین
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'عنوان'">
|
||||
<a-space :size="8" align="start">
|
||||
<a-badge :color="record.category?.color ?? undefined" />
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong :type="record.isActive ? undefined : 'secondary'">
|
||||
{{ record.title }}
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ record.category?.name ?? 'بدون دسته' }}
|
||||
<template v-if="record.isMandatory"> · اجباری</template>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مسئول'">
|
||||
<a-space v-if="record.team" :size="8">
|
||||
<a-typography-text :style="{ color: 'var(--ant-color-primary)' }">
|
||||
<TeamOutlined />
|
||||
</a-typography-text>
|
||||
<a-typography-text>تیم {{ record.team.name }}</a-typography-text>
|
||||
</a-space>
|
||||
<a-space v-else-if="record.assignee" :size="8">
|
||||
<UserAvatar :name="record.assignee.fullName" :user-id="record.assignee.id" :size="24" />
|
||||
<a-typography-text>{{ record.assignee.fullName }}</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text v-else type="secondary">—</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-space :size="8">
|
||||
<a-switch
|
||||
:checked="record.isActive"
|
||||
:loading="busyId === record.id"
|
||||
:aria-label="record.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
|
||||
@change="toggle(record as RoutineDto)"
|
||||
/>
|
||||
<a-tag :color="record.isActive ? 'success' : 'default'" :bordered="false">
|
||||
{{ record.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'درصد انجام'">
|
||||
<a-flex vertical :gap="0" :style="{ minWidth: '8rem' }">
|
||||
<a-flex align="center" :gap="8">
|
||||
<a-typography-text strong>{{ formatPercent(record.executionRate) }}</a-typography-text>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, Math.round(record.executionRate ?? 0)))"
|
||||
:show-info="false"
|
||||
:stroke-color="TONE_COLORS[percentTone(record.executionRate)]"
|
||||
:style="{ flex: 1 }"
|
||||
/>
|
||||
</a-flex>
|
||||
<a-typography-text v-if="record.executionTotal" type="secondary">
|
||||
{{ toFaDigits(record.executionDone) }} از {{ toFaDigits(record.executionTotal) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-space :size="4">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="ویرایش"
|
||||
@click="router.push(`/routines/manage/${r.id}/edit`)"
|
||||
@click="router.push(`/routines/manage/${record.id}/edit`)"
|
||||
>
|
||||
<UiIcon name="pencil" :size="15" />
|
||||
</UiButton>
|
||||
<UiDropdownMenu
|
||||
:open="menuFor === r.id"
|
||||
@update:open="(v: boolean) => (menuFor = v ? r.id : null)"
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-dropdown
|
||||
:open="menuFor === record.id"
|
||||
trigger="click"
|
||||
@update:open="(v: boolean) => (menuFor = v ? record.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle: t }">
|
||||
<UiButton variant="icon" size="sm" aria-label="عملیات بیشتر" @click="t">
|
||||
<UiIcon name="dots" :size="16" />
|
||||
</UiButton>
|
||||
<a-button type="text" aria-label="عملیات بیشتر">
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(info) => onRowMenu(info, record as RoutineDto)">
|
||||
<a-menu-item key="edit">ویرایش</a-menu-item>
|
||||
<a-menu-item key="reassign">تغییر مسئول</a-menu-item>
|
||||
<a-menu-item key="duplicate">کپی روتین</a-menu-item>
|
||||
<a-menu-item key="toggle">
|
||||
{{ record.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="remove" danger>حذف</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<button type="button" @click="router.push(`/routines/manage/${r.id}/edit`)">ویرایش</button>
|
||||
<button type="button" @click="openReassign(r)">تغییر مسئول</button>
|
||||
<button type="button" @click="duplicate(r)">کپی روتین</button>
|
||||
<button type="button" @click="toggle(r)">{{ r.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}</button>
|
||||
<button type="button" class="danger" @click="removing = r">حذف</button>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
|
||||
<!-- حذف -->
|
||||
<UiModal :open="!!removing" title="حذف روتین" @close="removing = null">
|
||||
<p class="confirm">
|
||||
<a-modal
|
||||
:open="!!removing"
|
||||
title="حذف روتین"
|
||||
:confirm-loading="removeBusy"
|
||||
ok-text="حذف روتین"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@ok="confirmRemove"
|
||||
@cancel="removing = null"
|
||||
>
|
||||
<a-typography-text>
|
||||
روتین «{{ removing?.title }}» حذف شود؟ کارهای انجامنشده امروزِ آن لغو میشوند؛ سوابق قبلی در گزارشها باقی
|
||||
میماند.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="danger" :loading="removeBusy" @click="confirmRemove">حذف روتین</UiButton>
|
||||
<UiButton variant="ghost" @click="removing = null">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</a-typography-text>
|
||||
</a-modal>
|
||||
|
||||
<!-- تغییر مسئول -->
|
||||
<UiModal :open="!!reassign.routine" title="تغییر مسئول روتین" @close="reassign.routine = null">
|
||||
<div class="form">
|
||||
<p class="confirm">{{ reassign.routine?.title }}</p>
|
||||
<UiField label="مسئول جدید" hint="کارهای انجامنشده امروز به مسئول جدید منتقل میشوند.">
|
||||
<UiSelect v-model="reassign.target">
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<optgroup label="کاربران">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="teams.length" label="کل اعضای تیم">
|
||||
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
|
||||
</optgroup>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<p v-if="reassign.error" class="error">{{ reassign.error }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton :loading="reassign.saving" @click="saveReassign">ذخیره</UiButton>
|
||||
<UiButton variant="ghost" @click="reassign.routine = null">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<a-modal
|
||||
:open="!!reassign.routine"
|
||||
title="تغییر مسئول روتین"
|
||||
:confirm-loading="reassign.saving"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
@ok="saveReassign"
|
||||
@cancel="reassign.routine = null"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-typography-text strong>{{ reassign.routine?.title }}</a-typography-text>
|
||||
<a-form-item
|
||||
label="مسئول جدید"
|
||||
extra="کارهای انجامنشده امروز به مسئول جدید منتقل میشوند."
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="reassign.target"
|
||||
:options="reassignOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert v-if="reassign.error" type="error" show-icon :message="reassign.error" />
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- دستهبندیها -->
|
||||
<UiModal :open="catsOpen" title="دستهبندی کارها" @close="catsOpen = false">
|
||||
<div class="form">
|
||||
<ul class="cat-list">
|
||||
<li v-for="c in categories" :key="c.id">
|
||||
<span class="cat-dot" :style="{ background: c.color }" />
|
||||
<span class="cat-name">{{ c.name }}</span>
|
||||
<UiButton variant="icon" size="sm" aria-label="ویرایش" @click="editCat(c)">
|
||||
<UiIcon name="pencil" :size="14" />
|
||||
</UiButton>
|
||||
<UiButton variant="icon" size="sm" aria-label="حذف" @click="deleteCat(c)">
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</UiButton>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cat-form">
|
||||
<UiField :label="catForm.id ? 'ویرایش دسته' : 'دسته جدید'">
|
||||
<UiInput v-model="catForm.name" placeholder="مثلاً: پشتیبانی" :maxlength="60" />
|
||||
</UiField>
|
||||
<div class="swatches" role="radiogroup" aria-label="رنگ">
|
||||
<button
|
||||
v-for="col in CAT_COLORS"
|
||||
:key="col"
|
||||
type="button"
|
||||
class="swatch"
|
||||
:class="{ on: catForm.color === col }"
|
||||
:style="{ background: col }"
|
||||
:aria-label="col"
|
||||
@click="catForm.color = col"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="catForm.error" class="error">{{ catForm.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton :loading="catForm.saving" @click="saveCat">{{ catForm.id ? 'ذخیره تغییرات' : 'افزودن دسته' }}</UiButton>
|
||||
<UiButton v-if="catForm.id" variant="ghost" @click="editCat(null)">انصراف از ویرایش</UiButton>
|
||||
<UiButton variant="ghost" @click="catsOpen = false">بستن</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
<a-modal
|
||||
v-model:open="catsOpen"
|
||||
title="دستهبندی کارها"
|
||||
:confirm-loading="catForm.saving"
|
||||
:ok-text="catForm.id ? 'ذخیره تغییرات' : 'افزودن دسته'"
|
||||
cancel-text="بستن"
|
||||
@ok="saveCat"
|
||||
>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-flex
|
||||
v-for="c in categories"
|
||||
:key="c.id"
|
||||
align="center"
|
||||
:gap="8"
|
||||
>
|
||||
<a-badge :color="c.color" />
|
||||
<a-typography-text :style="{ flex: 1 }">{{ c.name }}</a-typography-text>
|
||||
<a-button type="text" aria-label="ویرایش" @click="editCat(c)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-button type="text" danger aria-label="حذف" @click="deleteCat(c)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-empty v-if="!categories.length" description="دستهای تعریف نشده است." />
|
||||
</a-flex>
|
||||
|
||||
<a-divider :style="{ margin: 0 }" />
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="catForm.id ? 'ویرایش دسته' : 'دسته جدید'">
|
||||
<a-input v-model:value="catForm.name" placeholder="مثلاً: پشتیبانی" :maxlength="60" />
|
||||
</a-form-item>
|
||||
<a-form-item label="رنگ">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button
|
||||
v-for="col in CAT_COLORS"
|
||||
:key="col"
|
||||
shape="circle"
|
||||
:style="{
|
||||
background: col,
|
||||
borderColor: col,
|
||||
color: token.colorWhite,
|
||||
}"
|
||||
:aria-label="col"
|
||||
@click="catForm.color = col"
|
||||
>
|
||||
<template v-if="catForm.color === col" #icon><CheckOutlined /></template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-alert v-if="catForm.error" type="error" show-icon :message="catForm.error" />
|
||||
<a-button v-if="catForm.id" @click="editCat(null)">انصراف از ویرایش</a-button>
|
||||
</a-form>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manage {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr.inactive td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr.inactive .title-cell strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.title-cell div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title-cell strong {
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.title-cell small {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.cat-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.who-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-ico {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td:has(.switch) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
margin-inline-end: 8px;
|
||||
border: 0;
|
||||
border-radius: 99px;
|
||||
background: var(--line-strong);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.switch i {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* RTL: روشن = دکمه سمت چپ */
|
||||
.switch.on i {
|
||||
transform: translateX(-14px);
|
||||
}
|
||||
|
||||
.exec {
|
||||
display: grid;
|
||||
grid-template-columns: auto 80px;
|
||||
align-items: center;
|
||||
gap: 2px 8px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.exec small {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good .bar b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid .bar b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low .bar b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
:global(.ui-dd-panel button.danger) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cat-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cat-form {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--surface);
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatch.on {
|
||||
box-shadow: 0 0 0 2px var(--ink);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.search,
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,8 @@ import KpiCard from '@/components/routines/KpiCard.vue';
|
||||
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
|
||||
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
|
||||
import ActivityHistoryList from '@/components/routines/ActivityHistoryList.vue';
|
||||
import { UiChart, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { categoryBar, statusDonut, trendOption } from '@/lib/routineCharts';
|
||||
import {
|
||||
ROUTINES_CTX,
|
||||
addDaysKey,
|
||||
@@ -19,6 +17,24 @@ import {
|
||||
todayKey,
|
||||
} from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
@@ -45,6 +61,13 @@ const users = computed(() => shell.context.value?.users ?? []);
|
||||
const canPickUser = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
|
||||
const filters = computed(() => (userId.value ? { userId: userId.value } : {}));
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: 'عملکرد خودم' },
|
||||
...users.value
|
||||
.filter((x) => x.id !== auth.user?.id)
|
||||
.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
|
||||
const data = ref<RoutinePerformanceDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
@@ -69,205 +92,181 @@ async function load() {
|
||||
watch([section, days, userId, shell.refreshTick], load, { immediate: true });
|
||||
|
||||
const d = computed(() => data.value);
|
||||
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
|
||||
const donutOpt = computed(() =>
|
||||
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
|
||||
);
|
||||
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
|
||||
|
||||
const statusRows = computed(() => {
|
||||
const s = d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 };
|
||||
const totalAll = s.completed + s.notDone + s.overdue + s.skipped + s.cancelled;
|
||||
const rows = [
|
||||
{ key: 'completed', label: 'انجامشده', value: s.completed, color: 'var(--ant-color-success)' },
|
||||
{ key: 'notDone', label: 'انجامنشده', value: s.notDone, color: 'var(--ant-color-text-quaternary)' },
|
||||
{ key: 'overdue', label: 'عقبافتاده', value: s.overdue, color: 'var(--ant-color-error)' },
|
||||
{ key: 'skipped', label: 'رد شده', value: s.skipped, color: 'var(--ant-color-warning)' },
|
||||
{ key: 'cancelled', label: 'لغو شده', value: s.cancelled, color: 'var(--ant-color-warning)' },
|
||||
];
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
percent: totalAll > 0 ? Math.round((r.value / totalAll) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="perf">
|
||||
<div class="toolbar">
|
||||
<UiSegmented v-model="section" :options="sections" />
|
||||
<div class="tools">
|
||||
<UiSelect v-if="canPickUser" v-model="userId" size="sm" class="who">
|
||||
<option value="">عملکرد خودم</option>
|
||||
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
<UiSegmented v-if="section === 'overview'" v-model="days" :options="dayOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<a-segmented v-model:value="section" :options="sections" />
|
||||
<a-space :size="8" wrap>
|
||||
<a-select
|
||||
v-if="canPickUser"
|
||||
v-model:value="userId"
|
||||
:options="userOptions"
|
||||
:style="{ width: '200px' }"
|
||||
/>
|
||||
<a-segmented v-if="section === 'overview'" v-model:value="days" :options="dayOptions" />
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<template v-if="section === 'overview'">
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="d" class="range">بازه {{ formatDayNumeric(d.from) }} تا {{ formatDayNumeric(d.to) }}</p>
|
||||
<template v-if="section === 'overview'">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-typography-text v-if="d" type="secondary">
|
||||
بازه {{ formatDayNumeric(d.from) }} تا {{ formatDayNumeric(d.to) }}
|
||||
</a-typography-text>
|
||||
|
||||
<div class="kpis" :class="{ dim: loading }">
|
||||
<KpiCard label="درصد انجام" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||||
<KpiCard
|
||||
label="کارهای انجامشده"
|
||||
:value="toFaDigits(d?.completed ?? 0)"
|
||||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||||
icon="check"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="انجامنشده"
|
||||
:value="toFaDigits(d?.notDone ?? 0)"
|
||||
:hint="`عقبافتاده ${toFaDigits(d?.overdue ?? 0)}`"
|
||||
icon="x-circle"
|
||||
tone="danger"
|
||||
/>
|
||||
<KpiCard
|
||||
label="میانگین زمان انجام"
|
||||
:value="formatMinutes(d?.avgCompletionMinutes)"
|
||||
hint="از زمان برنامه تا انجام"
|
||||
icon="clock"
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="روزهای کامل"
|
||||
:value="toFaDigits(d?.completeDays ?? 0)"
|
||||
:hint="`ناقص ${toFaDigits(d?.partialDays ?? 0)} · از دست رفته ${toFaDigits(d?.missedDays ?? 0)}`"
|
||||
icon="calendar"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="روزهای پیاپی کامل"
|
||||
:value="toFaDigits(d?.streak ?? 0)"
|
||||
hint="انجام همه کارهای اجباری"
|
||||
icon="flag"
|
||||
tone="warning"
|
||||
/>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="12" :sm="8" :xl="4">
|
||||
<KpiCard label="درصد انجام" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :xl="4">
|
||||
<KpiCard
|
||||
label="کارهای انجامشده"
|
||||
:value="toFaDigits(d?.completed ?? 0)"
|
||||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||||
icon="check"
|
||||
tone="success"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :xl="4">
|
||||
<KpiCard
|
||||
label="انجامنشده"
|
||||
:value="toFaDigits(d?.notDone ?? 0)"
|
||||
:hint="`عقبافتاده ${toFaDigits(d?.overdue ?? 0)}`"
|
||||
icon="x-circle"
|
||||
tone="danger"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :xl="4">
|
||||
<KpiCard
|
||||
label="میانگین زمان انجام"
|
||||
:value="formatMinutes(d?.avgCompletionMinutes)"
|
||||
hint="از زمان برنامه تا انجام"
|
||||
icon="clock"
|
||||
tone="info"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :xl="4">
|
||||
<KpiCard
|
||||
label="روزهای کامل"
|
||||
:value="toFaDigits(d?.completeDays ?? 0)"
|
||||
:hint="`ناقص ${toFaDigits(d?.partialDays ?? 0)} · از دست رفته ${toFaDigits(d?.missedDays ?? 0)}`"
|
||||
icon="calendar"
|
||||
tone="success"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :xl="4">
|
||||
<KpiCard
|
||||
label="روزهای پیاپی کامل"
|
||||
:value="toFaDigits(d?.streak ?? 0)"
|
||||
hint="انجام همه کارهای اجباری"
|
||||
icon="flag"
|
||||
tone="warning"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="charts">
|
||||
<section class="card wide">
|
||||
<h3>روند درصد انجام</h3>
|
||||
<UiChart :option="trendOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>وضعیت کارها</h3>
|
||||
<UiChart :option="donutOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card full">
|
||||
<h3>انجام به تفکیک دستهبندی</h3>
|
||||
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
|
||||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<a-row :gutter="[24, 24]" :style="{ marginTop: '24px' }">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">روند درصد انجام</a-typography-title>
|
||||
<a-flex
|
||||
align="end"
|
||||
:gap="2"
|
||||
:style="{ height: '180px', marginTop: '16px' }"
|
||||
>
|
||||
<a-tooltip
|
||||
v-for="p in d?.trend ?? []"
|
||||
:key="p.date"
|
||||
:title="`${formatDayNumeric(p.date)} — ${formatPercent(p.percent)} (${toFaDigits(p.completed)} از ${toFaDigits(p.total)})`"
|
||||
>
|
||||
<a-flex
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: '3px',
|
||||
height: `${Math.max(2, Math.round(p.percent ?? 0))}%`,
|
||||
background:
|
||||
p.percent == null
|
||||
? 'var(--ant-color-fill-secondary)'
|
||||
: 'var(--ant-color-primary)',
|
||||
borderRadius: '2px 2px 0 0',
|
||||
}"
|
||||
/>
|
||||
</a-tooltip>
|
||||
<a-typography-text v-if="!d?.trend?.length" type="secondary">
|
||||
دادهای برای نمایش نیست.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">وضعیت کارها</a-typography-title>
|
||||
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<div v-for="r in statusRows" :key="r.key">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text>{{ r.label }}</a-typography-text>
|
||||
<a-typography-text strong>
|
||||
{{ toFaDigits(r.value) }} ({{ toFaDigits(r.percent) }}٪)
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress
|
||||
:percent="r.percent"
|
||||
:show-info="false"
|
||||
:stroke-color="r.color"
|
||||
/>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">انجام به تفکیک دستهبندی</a-typography-title>
|
||||
<a-flex v-if="d?.byCategory.length" vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<div v-for="c in d.byCategory" :key="c.categoryId ?? c.name">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text>{{ c.name }}</a-typography-text>
|
||||
<a-typography-text strong>
|
||||
{{ formatPercent(c.percent) }} · {{ toFaDigits(c.completed) }} از {{ toFaDigits(c.total) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, Math.round(c.percent ?? 0)))"
|
||||
:show-info="false"
|
||||
:stroke-color="c.color"
|
||||
/>
|
||||
</div>
|
||||
</a-flex>
|
||||
<a-empty v-else description="دادهای برای نمایش نیست." :style="{ marginTop: '16px' }" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<WeeklyReport v-else-if="section === 'weekly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||||
<MonthlyReport v-else-if="section === 'monthly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||||
<ActivityHistoryList
|
||||
v-else
|
||||
:user-id="userId || null"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
@open="shell.openTask"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<a-divider :style="{ margin: 0 }" />
|
||||
<WeeklyReport v-if="section === 'weekly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||||
<MonthlyReport v-else-if="section === 'monthly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||||
<ActivityHistoryList
|
||||
v-else
|
||||
:user-id="userId || null"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
@open="shell.openTask"
|
||||
/>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.perf {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.range {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpis {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar > :first-child {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineTaskDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -22,6 +20,40 @@ import {
|
||||
hasExplicitTime,
|
||||
} from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Badge as ABadge,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { WarningOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
const OVERDUE_COL_LABELS = [
|
||||
'کار',
|
||||
'مهلت',
|
||||
'تأخیر',
|
||||
'مسئول',
|
||||
'اولویت',
|
||||
'پرونده',
|
||||
'عملیات',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('routines-overdue', [...OVERDUE_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'routines-overdue');
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
@@ -33,6 +65,12 @@ const who = ref('');
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
|
||||
const whoOptions = computed(() => [
|
||||
{ value: '', label: 'کارهای خودم' },
|
||||
{ value: 'all', label: 'همه اعضای تیم' },
|
||||
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
|
||||
async function load(silent = false) {
|
||||
if (!silent) loading.value = true;
|
||||
error.value = '';
|
||||
@@ -62,258 +100,176 @@ const actions = useRoutineTaskActions((updated) => {
|
||||
shell.notifyChanged();
|
||||
});
|
||||
|
||||
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
||||
if (kind === 'complete-note') actions.openComplete(task);
|
||||
else if (kind === 'reschedule') actions.openReschedule(task);
|
||||
else if (kind === 'move-today') void actions.moveToday(task);
|
||||
else if (kind === 'skip') actions.openSkip(task);
|
||||
else if (kind === 'cancel') actions.openCancel(task);
|
||||
const showAssignee = computed(() => isManager.value && who.value !== '');
|
||||
|
||||
const PRIORITY_TAG_COLORS: Record<string, string> = {
|
||||
neutral: 'default',
|
||||
info: 'processing',
|
||||
warning: 'warning',
|
||||
danger: 'error',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
function priorityColor(priority: RoutineTaskDto['priority']) {
|
||||
return PRIORITY_TAG_COLORS[PRIORITY_VARIANTS[priority]] ?? 'default';
|
||||
}
|
||||
|
||||
const showAssignee = computed(() => isManager.value && who.value !== '');
|
||||
const columns = computed<TableColumnsType<RoutineTaskDto>>(() => {
|
||||
const all: TableColumnsType<RoutineTaskDto> = [
|
||||
{ title: 'کار', key: 'کار' },
|
||||
{ title: 'مهلت', key: 'مهلت' },
|
||||
{ title: 'تأخیر', key: 'تأخیر' },
|
||||
...(showAssignee.value
|
||||
? [{ title: 'مسئول', key: 'مسئول' } as const]
|
||||
: []),
|
||||
{ title: 'اولویت', key: 'اولویت' },
|
||||
{ title: 'پرونده', key: 'پرونده' },
|
||||
{ title: 'عملیات', key: 'عملیات', fixed: 'left', width: 340 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!OVERDUE_COL_LABELS.includes(key as (typeof OVERDUE_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
function rowProps(record: RoutineTaskDto) {
|
||||
return {
|
||||
onClick: () => shell.openTask(record.id),
|
||||
style: { cursor: 'pointer' },
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overdue">
|
||||
<div class="toolbar">
|
||||
<p class="lead">
|
||||
<UiIcon name="warning" :size="16" />
|
||||
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
|
||||
<template v-else>کار عقبافتادهای ندارید.</template>
|
||||
</p>
|
||||
<UiSelect v-if="isManager" v-model="who" size="sm" class="who">
|
||||
<option value="">کارهای خودم</option>
|
||||
<option value="all">همه اعضای تیم</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-card :bordered="false">
|
||||
<a-row :gutter="[16, 0]" align="middle">
|
||||
<a-col :xs="24" :md="showAssignee || isManager ? 16 : 24">
|
||||
<a-typography-text type="secondary">
|
||||
<WarningOutlined :style="{ color: 'var(--ant-color-error)' }" />
|
||||
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
|
||||
<template v-else>کار عقبافتادهای ندارید.</template>
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col v-if="isManager" :xs="24" :md="8">
|
||||
<a-select
|
||||
v-model:value="who"
|
||||
:options="whoOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
<a-alert
|
||||
v-if="actions.error.value"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="actions.error.value"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
<a-flex justify="flex-end" :style="{ marginTop: '16px' }">
|
||||
<TableColumnsMenu table-key="routines-overdue" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!items.length"
|
||||
icon="check"
|
||||
title="همه چیز بهروز است"
|
||||
description="هیچ کاری از مهلتش عقب نیفتاده."
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- دسکتاپ: جدول -->
|
||||
<div class="crm-table-container desk">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کار</th>
|
||||
<th>مهلت</th>
|
||||
<th>تأخیر</th>
|
||||
<th v-if="showAssignee">مسئول</th>
|
||||
<th>اولویت</th>
|
||||
<th>پرونده</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in items" :key="t.id" class="clickable" @click="shell.openTask(t.id)">
|
||||
<td class="title-cell">
|
||||
<strong>{{ t.title }}</strong>
|
||||
<small v-if="t.category" class="cat"><i :style="{ background: t.category.color }" />{{ t.category.name }}</small>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{{ formatDayNumeric(t.scheduledFor) }}
|
||||
<small v-if="hasExplicitTime(t)" class="muted">· {{ formatTime(t.dueAt) }}</small>
|
||||
</td>
|
||||
<td class="nowrap late">{{ formatDelay(t.dueAt) }}</td>
|
||||
<td v-if="showAssignee">
|
||||
<span v-if="t.assignee" class="who-cell">
|
||||
<UserAvatar :name="t.assignee.fullName" :user-id="t.assignee.id" :size="24" />
|
||||
{{ t.assignee.fullName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:custom-row="rowProps"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty description="همه چیز بهروز است؛ هیچ کاری از مهلتش عقب نیفتاده." />
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'کار'">
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong>{{ record.title }}</a-typography-text>
|
||||
<a-space v-if="record.category" :size="6">
|
||||
<a-badge :color="record.category.color" />
|
||||
<a-typography-text type="secondary">{{ record.category.name }}</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مهلت'">
|
||||
<a-typography-text>
|
||||
{{ formatDayNumeric(record.scheduledFor) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="hasExplicitTime(record as RoutineTaskDto)" type="secondary">
|
||||
· {{ formatTime((record as RoutineTaskDto).dueAt) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'تأخیر'">
|
||||
<a-typography-text strong type="danger">
|
||||
{{ formatDelay((record as RoutineTaskDto).dueAt) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مسئول'">
|
||||
<a-space v-if="record.assignee" :size="8">
|
||||
<UserAvatar
|
||||
:name="record.assignee.fullName"
|
||||
:user-id="record.assignee.id"
|
||||
:size="24"
|
||||
/>
|
||||
<a-typography-text>{{ record.assignee.fullName }}</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'اولویت'">
|
||||
<a-tag :color="priorityColor((record as RoutineTaskDto).priority)" :bordered="false">
|
||||
{{ PRIORITY_LABELS[(record as RoutineTaskDto).priority] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'پرونده'">
|
||||
<a-flex vertical align="start" :gap="2" @click.stop>
|
||||
<RouterLink
|
||||
v-for="ent in t.entities"
|
||||
v-for="ent in (record as RoutineTaskDto).entities"
|
||||
:key="ent.id"
|
||||
:to="entityRoute(ent)"
|
||||
class="ent"
|
||||
@click.stop
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
|
||||
<a-typography-link :href="href" @click="navigate">
|
||||
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
<span v-if="!t.entities.length" class="muted">—</span>
|
||||
</td>
|
||||
<td class="actions-cell" @click.stop>
|
||||
<div class="crm-table-actions">
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="success"
|
||||
:loading="actions.busyId.value === t.id"
|
||||
@click="actions.toggle(t)"
|
||||
>
|
||||
انجام شد
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="actions.moveToday(t)">به امروز</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="actions.openReschedule(t)">تغییر زمان</UiButton>
|
||||
<UiButton size="sm" variant="ghost" class="danger-text" @click="actions.openCancel(t)">لغو</UiButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- موبایل: کارت -->
|
||||
<div class="mobile rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in items"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
show-delay
|
||||
:show-assignee="showAssignee"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<a-typography-text v-if="!record.entities.length" type="secondary">
|
||||
—
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-space :size="8" wrap @click.stop>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="actions.busyId.value === record.id"
|
||||
@click="actions.toggle(record as RoutineTaskDto)"
|
||||
>
|
||||
انجام شد
|
||||
</a-button>
|
||||
<a-button @click="actions.moveToday(record as RoutineTaskDto)">به امروز</a-button>
|
||||
<a-button @click="actions.openReschedule(record as RoutineTaskDto)">تغییر زمان</a-button>
|
||||
<a-button danger @click="actions.openCancel(record as RoutineTaskDto)">لغو</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overdue {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.lead :deep(svg) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.title-cell strong {
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.cat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cat i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.late {
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.who-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ent {
|
||||
display: block;
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-xs);
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.desk {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue';
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { computed, onMounted, provide, ref, watch, type Component } from 'vue';
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import type { RoutineContextDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import RoutineTaskDrawer from '@/components/routines/RoutineTaskDrawer.vue';
|
||||
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { ROUTINES_CTX, apiError } from '@/lib/routines';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
BellOutlined,
|
||||
CalendarOutlined,
|
||||
ControlOutlined,
|
||||
FundOutlined,
|
||||
PlusOutlined,
|
||||
ScheduleOutlined,
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -63,16 +84,16 @@ onMounted(() => {
|
||||
const taskId = computed(() => (typeof route.query.task === 'string' ? route.query.task : null));
|
||||
const isManager = computed(() => !!context.value?.isManager);
|
||||
|
||||
type Tab = { to: string; name: string; label: string; icon: InstanceType<typeof UiIcon>['$props']['name']; manager?: boolean };
|
||||
type Tab = { to: string; name: string; label: string; icon: Component; manager?: boolean };
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ to: '/routines', name: 'routines-today', label: 'کارهای امروز', icon: 'calendar-sun' },
|
||||
{ to: '/routines/overdue', name: 'routines-overdue', label: 'عقبافتاده', icon: 'warning' },
|
||||
{ to: '/routines/calendar', name: 'routines-calendar', label: 'تقویم', icon: 'calendar' },
|
||||
{ to: '/routines/list', name: 'routines-list', label: 'روتینها', icon: 'refresh' },
|
||||
{ to: '/routines/me', name: 'routines-me', label: 'عملکرد من', icon: 'chart' },
|
||||
{ to: '/routines/team', name: 'routines-team', label: 'عملکرد تیم', icon: 'users', manager: true },
|
||||
{ to: '/routines/manage', name: 'routines-manage', label: 'مدیریت روتینها', icon: 'sliders', manager: true },
|
||||
{ to: '/routines', name: 'routines-today', label: 'کارهای امروز', icon: ScheduleOutlined },
|
||||
{ to: '/routines/overdue', name: 'routines-overdue', label: 'عقبافتاده', icon: WarningOutlined },
|
||||
{ to: '/routines/calendar', name: 'routines-calendar', label: 'تقویم', icon: CalendarOutlined },
|
||||
{ to: '/routines/list', name: 'routines-list', label: 'روتینها', icon: SyncOutlined },
|
||||
{ to: '/routines/me', name: 'routines-me', label: 'عملکرد من', icon: FundOutlined },
|
||||
{ to: '/routines/team', name: 'routines-team', label: 'عملکرد تیم', icon: TeamOutlined, manager: true },
|
||||
{ to: '/routines/manage', name: 'routines-manage', label: 'مدیریت روتینها', icon: ControlOutlined, manager: true },
|
||||
];
|
||||
|
||||
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
|
||||
@@ -95,46 +116,69 @@ function tabActive(tab: Tab) {
|
||||
return name === tab.name;
|
||||
}
|
||||
|
||||
const activeTab = computed(
|
||||
() => visibleTabs.value.find((t) => tabActive(t))?.name ?? 'routines-today',
|
||||
);
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
const tab = tabs.find((t) => t.name === String(key));
|
||||
if (tab && route.name !== tab.name) {
|
||||
void router.push(tab.to);
|
||||
}
|
||||
}
|
||||
|
||||
const hideHeader = computed(() => route.name === 'routine-new' || route.name === 'routine-edit');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page routines-shell">
|
||||
<a-flex vertical :gap="16">
|
||||
<template v-if="!hideHeader">
|
||||
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه و کارهای داخلی تیم">
|
||||
<template #actions>
|
||||
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلانها">
|
||||
<UiIcon name="bell" :size="17" />
|
||||
</RouterLink>
|
||||
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
|
||||
<template #icon><UiIcon name="refresh" :size="16" /></template>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">کارهای روتین</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
برنامه روزانه، روتینهای تکرارشونده و پیگیری انجام کارها
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button
|
||||
aria-label="تنظیمات یادآوری و اعلانها"
|
||||
title="تنظیمات یادآوری و اعلانها"
|
||||
@click="router.push('/routines/settings')"
|
||||
>
|
||||
<template #icon><BellOutlined /></template>
|
||||
</a-button>
|
||||
<a-button v-if="isManager" @click="router.push('/routines/manage/new')">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
روتین جدید
|
||||
</UiButton>
|
||||
<UiButton @click="quickOpen = true">
|
||||
<template #icon><UiIcon name="plus" :size="16" /></template>
|
||||
</a-button>
|
||||
<a-button type="primary" @click="quickOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
کار جدید
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<nav class="r-tabs" aria-label="بخشهای کارهای روتین">
|
||||
<RouterLink
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.name"
|
||||
:to="tab.to"
|
||||
class="r-tab"
|
||||
:class="{ on: tabActive(tab) }"
|
||||
>
|
||||
<UiIcon :name="tab.icon" :size="15" />
|
||||
<span>{{ tab.label }}</span>
|
||||
<em v-if="tab.name === 'routines-overdue' && overdueCount > 0" class="count">
|
||||
{{ toFaDigits(overdueCount) }}
|
||||
</em>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<a-tabs :active-key="activeTab" @change="onTabChange">
|
||||
<a-tab-pane v-for="tab in visibleTabs" :key="tab.name">
|
||||
<template #tab>
|
||||
<a-space :size="6">
|
||||
<component :is="tab.icon" />
|
||||
<span>{{ tab.label }}</span>
|
||||
<a-tag
|
||||
v-if="tab.name === 'routines-overdue' && overdueCount > 0"
|
||||
color="error"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ toFaDigits(overdueCount) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
|
||||
<p v-if="contextError" class="error">{{ contextError }}</p>
|
||||
<a-alert v-if="contextError" type="error" show-icon :message="contextError" />
|
||||
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :key="String(route.name)" />
|
||||
@@ -142,90 +186,5 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
|
||||
|
||||
<RoutineTaskDrawer :task-id="taskId" @close="closeTask" />
|
||||
<QuickTaskModal :open="quickOpen" @close="quickOpen = false" />
|
||||
</section>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.routines-shell {
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink-secondary);
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
.icon-link:hover,
|
||||
.icon-link.router-link-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.r-tabs {
|
||||
display: flex;
|
||||
gap: var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.r-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.r-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.15rem;
|
||||
margin-bottom: -1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted-light);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.r-tab:hover {
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.r-tab.on {
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.count {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.r-tabs {
|
||||
gap: var(--sp-4);
|
||||
margin-inline: calc(-1 * var(--sp-3));
|
||||
padding-inline: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,24 +8,45 @@ import type {
|
||||
RoutineTeamPerformanceDto,
|
||||
} from '@mento/shared';
|
||||
import { ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||||
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
|
||||
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
|
||||
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
|
||||
import { UiButton, UiChart, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { categoryBar, membersBar, statusDonut, trendOption } from '@/lib/routineCharts';
|
||||
import {
|
||||
ROUTINES_CTX,
|
||||
addDaysKey,
|
||||
apiError,
|
||||
formatDayNumeric,
|
||||
formatPercent,
|
||||
percentTone,
|
||||
todayKey,
|
||||
} from '@/lib/routines';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CloseOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
@@ -56,6 +77,31 @@ const sources = (['routine', 'manual'] as const).map(
|
||||
);
|
||||
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: 'همه کاربران' },
|
||||
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
const teamOptions = computed(() => [
|
||||
{ value: '', label: 'همه تیمها' },
|
||||
...teams.value.map((t) => ({ value: t.id, label: t.name })),
|
||||
]);
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: '', label: 'همه دستهها' },
|
||||
...categories.value.map((c) => ({ value: c.id, label: c.name })),
|
||||
]);
|
||||
const sourceOptions = [
|
||||
{ value: '', label: 'همه' },
|
||||
...sources.map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'همه' },
|
||||
...statuses.map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
const execSortOptions = [
|
||||
{ value: 'percent', label: 'مرتبسازی: کمترین اجرا' },
|
||||
{ value: 'occurrences', label: 'مرتبسازی: بیشترین تکرار' },
|
||||
];
|
||||
|
||||
const query = computed<RoutineStatsQuery>(() => {
|
||||
const q: RoutineStatsQuery = {};
|
||||
if (f.from) q.from = f.from;
|
||||
@@ -102,13 +148,35 @@ watch([section, () => JSON.stringify(query.value), shell.refreshTick], load, { i
|
||||
|
||||
const d = computed(() => data.value);
|
||||
const activeMembers = computed(() => (d.value?.members ?? []).filter((m) => m.total > 0).length);
|
||||
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
|
||||
const donutOpt = computed(() =>
|
||||
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
|
||||
|
||||
const statusRows = computed(() => {
|
||||
const s = d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 };
|
||||
const totalAll = s.completed + s.notDone + s.overdue + s.skipped + s.cancelled;
|
||||
const rows = [
|
||||
{ key: 'completed', label: 'انجامشده', value: s.completed, color: 'var(--ant-color-success)' },
|
||||
{ key: 'notDone', label: 'انجامنشده', value: s.notDone, color: 'var(--ant-color-text-quaternary)' },
|
||||
{ key: 'overdue', label: 'عقبافتاده', value: s.overdue, color: 'var(--ant-color-error)' },
|
||||
{ key: 'skipped', label: 'رد شده', value: s.skipped, color: 'var(--ant-color-warning)' },
|
||||
{ key: 'cancelled', label: 'لغو شده', value: s.cancelled, color: 'var(--ant-color-warning)' },
|
||||
];
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
percent: totalAll > 0 ? Math.round((r.value / totalAll) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const memberRows = computed(() =>
|
||||
[...(d.value?.members ?? [])]
|
||||
.filter((m) => m.total > 0)
|
||||
.sort((a, b) => (b.percent ?? -1) - (a.percent ?? -1)),
|
||||
);
|
||||
const membersOpt = computed(() => membersBar(d.value?.members ?? []));
|
||||
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
|
||||
const membersChartHeight = computed(() => `${Math.max(200, activeMembers.value * 34 + 40)}px`);
|
||||
|
||||
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 execSort = ref<'percent' | 'occurrences'>('percent');
|
||||
const execRows = computed(() =>
|
||||
@@ -116,347 +184,258 @@ const execRows = computed(() =>
|
||||
execSort.value === 'percent' ? (a.percent ?? -1) - (b.percent ?? -1) : b.occurrences - a.occurrences,
|
||||
),
|
||||
);
|
||||
|
||||
const execColumns: TableColumnsType<RoutineExecutionRowDto> = [
|
||||
{
|
||||
title: 'روتین',
|
||||
key: 'title',
|
||||
customRender: ({ record }) => record.title,
|
||||
},
|
||||
{ title: 'مسئول', dataIndex: 'assigneeLabel', key: 'assigneeLabel' },
|
||||
{ title: 'تکرار', dataIndex: 'recurrenceLabel', key: 'recurrenceLabel' },
|
||||
{
|
||||
title: 'دفعات',
|
||||
key: 'occurrences',
|
||||
customRender: ({ record }) => toFaDigits(record.occurrences),
|
||||
},
|
||||
{
|
||||
title: 'انجام شده',
|
||||
key: 'completed',
|
||||
customRender: ({ record }) => toFaDigits(record.completed),
|
||||
},
|
||||
{
|
||||
title: 'انجام نشده',
|
||||
key: 'notDone',
|
||||
customRender: ({ record }) => toFaDigits(record.notDone),
|
||||
},
|
||||
{
|
||||
title: 'رد شده',
|
||||
key: 'skipped',
|
||||
customRender: ({ record }) => toFaDigits(record.skipped),
|
||||
},
|
||||
{ title: 'درصد اجرا', key: 'percent', width: 180 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="team">
|
||||
<UiSegmented v-model="section" :options="sections" class="sections" />
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-segmented v-model:value="section" :options="sections" />
|
||||
|
||||
<!-- فیلترها -->
|
||||
<section class="filters">
|
||||
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
|
||||
<label>از</label>
|
||||
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" />
|
||||
</div>
|
||||
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
|
||||
<label>تا</label>
|
||||
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>کاربر</label>
|
||||
<UiSelect v-model="f.userId" size="sm">
|
||||
<option value="">همه کاربران</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="teams.length" class="f-item">
|
||||
<label>تیم</label>
|
||||
<UiSelect v-model="f.teamId" size="sm">
|
||||
<option value="">همه تیمها</option>
|
||||
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>دستهبندی</label>
|
||||
<UiSelect v-model="f.categoryId" size="sm">
|
||||
<option value="">همه دستهها</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="section !== 'routines'" class="f-item">
|
||||
<label>نوع کار</label>
|
||||
<UiSelect v-model="f.source" size="sm">
|
||||
<option value="">همه</option>
|
||||
<option v-for="[k, label] in sources" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="section === 'overview'" class="f-item">
|
||||
<label>وضعیت</label>
|
||||
<UiSelect v-model="f.status" size="sm">
|
||||
<option value="">همه</option>
|
||||
<option v-for="[k, label] in statuses" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<UiButton v-if="hasFilters" variant="ghost" size="sm" class="reset" @click="resetFilters">
|
||||
<template #icon><UiIcon name="x" :size="14" /></template>
|
||||
حذف فیلترها
|
||||
</UiButton>
|
||||
</section>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col v-if="section === 'overview' || section === 'routines'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="از">
|
||||
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="section === 'overview' || section === 'routines'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="تا">
|
||||
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="کاربر">
|
||||
<a-select v-model:value="f.userId" :options="userOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="teams.length" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="تیم">
|
||||
<a-select v-model:value="f.teamId" :options="teamOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="دستهبندی">
|
||||
<a-select v-model:value="f.categoryId" :options="categoryOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="section !== 'routines'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="نوع کار">
|
||||
<a-select v-model:value="f.source" :options="sourceOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="section === 'overview'" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select v-model:value="f.status" :options="statusOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="hasFilters" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label=" ">
|
||||
<a-button danger @click="resetFilters">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
حذف فیلترها
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<!-- نمای کلی -->
|
||||
<template v-if="section === 'overview'">
|
||||
<div class="kpis" :class="{ dim: loading }">
|
||||
<KpiCard label="درصد انجام تیم" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||||
<KpiCard
|
||||
label="انجامشده"
|
||||
:value="toFaDigits(d?.completed ?? 0)"
|
||||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||||
icon="check"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard label="عقبافتاده" :value="toFaDigits(d?.overdue ?? 0)" icon="warning" tone="danger" />
|
||||
<KpiCard
|
||||
label="اعضای فعال"
|
||||
:value="toFaDigits(activeMembers)"
|
||||
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
|
||||
icon="users"
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="section === 'overview'">
|
||||
<a-spin :spinning="loading">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard label="درصد انجام تیم" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard
|
||||
label="انجامشده"
|
||||
:value="toFaDigits(d?.completed ?? 0)"
|
||||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||||
icon="check"
|
||||
tone="success"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard label="عقبافتاده" :value="toFaDigits(d?.overdue ?? 0)" icon="warning" tone="danger" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard
|
||||
label="اعضای فعال"
|
||||
:value="toFaDigits(activeMembers)"
|
||||
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
|
||||
icon="users"
|
||||
tone="info"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="charts">
|
||||
<section class="card wide">
|
||||
<h3>روند درصد انجام تیم</h3>
|
||||
<UiChart :option="trendOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>وضعیت کارها</h3>
|
||||
<UiChart :option="donutOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>مقایسه اعضا</h3>
|
||||
<UiChart v-if="activeMembers" :option="membersOpt" :height="membersChartHeight" :loading="loading" />
|
||||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||||
</section>
|
||||
<section class="card wide">
|
||||
<h3>انجام به تفکیک دستهبندی</h3>
|
||||
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
|
||||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||||
</section>
|
||||
</div>
|
||||
<a-row :gutter="[24, 24]" :style="{ marginTop: '24px' }">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">روند درصد انجام تیم</a-typography-title>
|
||||
<a-flex align="end" :gap="2" :style="{ height: '180px', marginTop: '16px' }">
|
||||
<a-tooltip
|
||||
v-for="p in d?.trend ?? []"
|
||||
:key="p.date"
|
||||
:title="`${formatDayNumeric(p.date)} — ${formatPercent(p.percent)} (${toFaDigits(p.completed)} از ${toFaDigits(p.total)})`"
|
||||
>
|
||||
<a-flex
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: '3px',
|
||||
height: `${Math.max(2, Math.round(p.percent ?? 0))}%`,
|
||||
background: p.percent == null ? 'var(--ant-color-fill-secondary)' : 'var(--ant-color-primary)',
|
||||
borderRadius: '2px 2px 0 0',
|
||||
}"
|
||||
/>
|
||||
</a-tooltip>
|
||||
<a-typography-text v-if="!d?.trend?.length" type="secondary">
|
||||
دادهای برای نمایش نیست.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">وضعیت کارها</a-typography-title>
|
||||
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<div v-for="r in statusRows" :key="r.key">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text>{{ r.label }}</a-typography-text>
|
||||
<a-typography-text strong>
|
||||
{{ toFaDigits(r.value) }} ({{ toFaDigits(r.percent) }}٪)
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress :percent="r.percent" :show-info="false" :stroke-color="r.color" />
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">مقایسه اعضا</a-typography-title>
|
||||
<a-flex v-if="memberRows.length" vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<div v-for="m in memberRows" :key="m.user.id">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text>{{ m.user.fullName }}</a-typography-text>
|
||||
<a-typography-text strong>
|
||||
{{ formatPercent(m.percent) }} · {{ toFaDigits(m.completed) }} از {{ toFaDigits(m.total) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, Math.round(m.percent ?? 0)))"
|
||||
:show-info="false"
|
||||
:stroke-color="TONE_COLORS[percentTone(m.percent)]"
|
||||
/>
|
||||
</div>
|
||||
</a-flex>
|
||||
<a-empty v-else description="دادهای برای نمایش نیست." :style="{ marginTop: '16px' }" />
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">انجام به تفکیک دستهبندی</a-typography-title>
|
||||
<a-flex v-if="d?.byCategory.length" vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<div v-for="c in d.byCategory" :key="c.categoryId ?? c.name">
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text>{{ c.name }}</a-typography-text>
|
||||
<a-typography-text strong>
|
||||
{{ formatPercent(c.percent) }} · {{ toFaDigits(c.completed) }} از {{ toFaDigits(c.total) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, Math.round(c.percent ?? 0)))"
|
||||
:show-info="false"
|
||||
:stroke-color="c.color"
|
||||
/>
|
||||
</div>
|
||||
</a-flex>
|
||||
<a-empty v-else description="دادهای برای نمایش نیست." :style="{ marginTop: '16px' }" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
|
||||
<section class="card">
|
||||
<h3>عملکرد اعضا</h3>
|
||||
<a-divider orientation="right">عملکرد اعضا</a-divider>
|
||||
<TeamPerformanceTable :members="d?.members ?? []" @select="(id) => (f.userId = id)" />
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<WeeklyReport
|
||||
v-else-if="section === 'weekly'"
|
||||
view="team"
|
||||
:filters="query"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
/>
|
||||
<MonthlyReport
|
||||
v-else-if="section === 'monthly'"
|
||||
view="team"
|
||||
:filters="query"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
/>
|
||||
<WeeklyReport
|
||||
v-else-if="section === 'weekly'"
|
||||
view="team"
|
||||
:filters="query"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
/>
|
||||
<MonthlyReport
|
||||
v-else-if="section === 'monthly'"
|
||||
view="team"
|
||||
:filters="query"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
/>
|
||||
|
||||
<!-- گزارش اجرای روتینها -->
|
||||
<section v-else class="card">
|
||||
<div class="exec-head">
|
||||
<h3>گزارش اجرای روتینها</h3>
|
||||
<UiSelect v-model="execSort" size="sm" class="exec-sort">
|
||||
<option value="percent">مرتبسازی: کمترین اجرا</option>
|
||||
<option value="occurrences">مرتبسازی: بیشترین تکرار</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="crm-table-container" :class="{ dim: loading }">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>روتین</th>
|
||||
<th>مسئول</th>
|
||||
<th>تکرار</th>
|
||||
<th>دفعات</th>
|
||||
<th>انجام شده</th>
|
||||
<th>انجام نشده</th>
|
||||
<th>رد شده</th>
|
||||
<th>درصد اجرا</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in execRows" :key="r.routineId">
|
||||
<td><strong>{{ r.title }}</strong></td>
|
||||
<td class="nowrap">{{ r.assigneeLabel }}</td>
|
||||
<td class="nowrap">{{ r.recurrenceLabel }}</td>
|
||||
<td>{{ toFaDigits(r.occurrences) }}</td>
|
||||
<td class="ok">{{ toFaDigits(r.completed) }}</td>
|
||||
<td :class="{ bad: r.notDone > 0 }">{{ toFaDigits(r.notDone) }}</td>
|
||||
<td>{{ toFaDigits(r.skipped) }}</td>
|
||||
<td>
|
||||
<span class="pct" :class="`tone-${percentTone(r.percent)}`">
|
||||
<strong>{{ formatPercent(r.percent) }}</strong>
|
||||
<i><b :style="{ width: `${r.percent ?? 0}%` }" /></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!execRows.length && !loading">
|
||||
<td colspan="8" class="empty">در این بازه روتینی اجرا نشده است.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template v-else>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">گزارش اجرای روتینها</a-typography-title>
|
||||
<a-select
|
||||
v-model:value="execSort"
|
||||
:options="execSortOptions"
|
||||
:style="{ width: '240px' }"
|
||||
/>
|
||||
</a-flex>
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:columns="execColumns"
|
||||
:data-source="execRows"
|
||||
:pagination="false"
|
||||
:row-key="(r: RoutineExecutionRowDto) => r.routineId"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty description="در این بازه روتینی اجرا نشده است." />
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<a-typography-text strong>{{ record.title }}</a-typography-text>
|
||||
</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>
|
||||
</a-spin>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.sections {
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.f-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.f-item.date {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.f-item label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reset {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.exec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.exec-sort {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.f-item,
|
||||
.f-item.date {
|
||||
min-width: calc(50% - var(--sp-3));
|
||||
flex: 1 1 calc(50% - var(--sp-3));
|
||||
}
|
||||
|
||||
.exec-sort {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineCalendarDto, RoutineTaskDto, RoutineTodayDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||||
import DailyScoreCard from '@/components/routines/DailyScoreCard.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -21,6 +19,25 @@ import {
|
||||
} from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
Affix as AAffix,
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Progress as AProgress,
|
||||
Result as AResult,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Skeleton as ASkeleton,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CalendarOutlined, WarningOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
@@ -105,6 +122,13 @@ const filterOptions = computed(() => [
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const showUserPicker = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{ value: '', label: 'کارهای خودم' },
|
||||
...users.value
|
||||
.filter((x) => x.id !== auth.user?.id)
|
||||
.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]);
|
||||
|
||||
const greeting = computed(() => {
|
||||
if (!viewingSelf.value) {
|
||||
const u = users.value.find((x) => x.id === userId.value);
|
||||
@@ -115,415 +139,165 @@ const greeting = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="today">
|
||||
<!-- سربرگ روز -->
|
||||
<section class="day-hero">
|
||||
<div class="hero-copy">
|
||||
<h2>کارهای امروز</h2>
|
||||
<p class="sub">{{ greeting }}</p>
|
||||
<p class="date">
|
||||
<UiIcon name="calendar" :size="14" />
|
||||
{{ formatDayLong(data?.date ?? todayKey()) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-progress" :aria-label="`${stats.completed} از ${stats.total} کار انجام شده`">
|
||||
<div class="ring" :style="{ '--p': progressPct }">
|
||||
<span>{{ formatPercent(stats.percent) }}</span>
|
||||
</div>
|
||||
<div class="progress-copy">
|
||||
<strong>{{ toFaDigits(stats.completed) }} از {{ toFaDigits(stats.total) }}</strong>
|
||||
<span>کار امروز انجام شده</span>
|
||||
<i class="pbar"><b :style="{ width: `${progressPct}%` }" /></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showUserPicker" class="hero-user">
|
||||
<UiSelect v-model="userId" size="sm">
|
||||
<option value="">کارهای خودم</option>
|
||||
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- کارتهای آماری -->
|
||||
<section class="stat-grid">
|
||||
<KpiCard label="انجام شده" :value="toFaDigits(stats.completed)" icon="check" tone="success" />
|
||||
<KpiCard label="باقیمانده" :value="toFaDigits(stats.remaining)" icon="clock" tone="info" />
|
||||
<KpiCard
|
||||
label="عقبافتاده"
|
||||
:value="toFaDigits(stats.overdue)"
|
||||
icon="warning"
|
||||
:tone="stats.overdue ? 'danger' : 'neutral'"
|
||||
/>
|
||||
<KpiCard label="درصد تکمیل" :value="formatPercent(stats.percent)" icon="target" tone="accent" />
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
|
||||
<div class="layout">
|
||||
<section class="main">
|
||||
<div class="list-head">
|
||||
<UiSegmented v-model="filter" :options="filterOptions" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="skeleton">
|
||||
<span v-for="i in 4" :key="i" />
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!data?.tasks.length"
|
||||
icon="calendar-sun"
|
||||
title="برای امروز کاری ثبت نشده"
|
||||
description="روتینهای فعال بهصورت خودکار هر روز کار میسازند. میتوانید یک کار دستی هم اضافه کنید."
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="openTasks.length" class="group">
|
||||
<h3 class="group-title">باقیمانده <em>{{ toFaDigits(openTasks.length) }}</em></h3>
|
||||
<TransitionGroup name="rt-list" tag="div" class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in openTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-delay
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<div v-else-if="filter !== 'done'" class="all-done">
|
||||
<UiIcon name="check" :size="20" />
|
||||
همه کارهای امروز انجام شد.
|
||||
</div>
|
||||
|
||||
<div v-if="closedTasks.length" class="group">
|
||||
<h3 class="group-title">انجامشده / بسته <em>{{ toFaDigits(closedTasks.length) }}</em></h3>
|
||||
<TransitionGroup name="rt-list" tag="div" class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in closedTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<aside class="side">
|
||||
<DailyScoreCard :score="data?.score ?? null" :days="recent" />
|
||||
|
||||
<section v-if="data?.overdue.length" class="overdue-card">
|
||||
<header>
|
||||
<h3>
|
||||
<UiIcon name="warning" :size="15" />
|
||||
عقبافتاده از روزهای قبل
|
||||
</h3>
|
||||
<RouterLink to="/routines/overdue">همه ({{ toFaDigits(data.overdue.length) }})</RouterLink>
|
||||
</header>
|
||||
<div class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in data.overdue.slice(0, 4)"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
<a-flex vertical :gap="16">
|
||||
<a-card :bordered="false">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="16">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">کارهای امروز</a-typography-title>
|
||||
<a-typography-text type="secondary">{{ greeting }}</a-typography-text>
|
||||
<a-typography-text strong :style="{ color: 'var(--ant-color-primary)' }">
|
||||
<CalendarOutlined />
|
||||
{{ formatDayLong(data?.date ?? todayKey()) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex
|
||||
align="center"
|
||||
:gap="16"
|
||||
:aria-label="`${stats.completed} از ${stats.total} کار انجام شده`"
|
||||
>
|
||||
<a-progress
|
||||
type="circle"
|
||||
:percent="Math.max(0, Math.min(100, Math.round(progressPct)))"
|
||||
:size="76"
|
||||
:format="() => formatPercent(stats.percent)"
|
||||
/>
|
||||
<a-flex vertical :gap="0" :style="{ minWidth: '10rem' }">
|
||||
<a-typography-title :level="4" :style="{ margin: 0 }">
|
||||
{{ toFaDigits(stats.completed) }} از {{ toFaDigits(stats.total) }}
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">کار امروز انجام شده</a-typography-text>
|
||||
<a-progress
|
||||
:percent="Math.max(0, Math.min(100, progressPct))"
|
||||
:show-info="false"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<a-select
|
||||
v-if="showUserPicker"
|
||||
v-model:value="userId"
|
||||
:options="userOptions"
|
||||
:style="{ width: '220px' }"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard label="انجام شده" :value="toFaDigits(stats.completed)" icon="check" tone="success" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard label="باقیمانده" :value="toFaDigits(stats.remaining)" icon="clock" tone="info" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard
|
||||
label="عقبافتاده"
|
||||
:value="toFaDigits(stats.overdue)"
|
||||
icon="warning"
|
||||
:tone="stats.overdue ? 'danger' : 'neutral'"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :lg="6">
|
||||
<KpiCard label="درصد تکمیل" :value="formatPercent(stats.percent)" icon="target" tone="accent" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="actions.error.value" type="error" show-icon :message="actions.error.value" />
|
||||
|
||||
<a-row :gutter="[24, 24]" align="top">
|
||||
<a-col :xs="24" :lg="16" :xl="17">
|
||||
<a-card :bordered="false">
|
||||
<a-segmented v-model:value="filter" :options="filterOptions" />
|
||||
|
||||
<a-skeleton v-if="loading" active :style="{ marginTop: '16px' }" />
|
||||
|
||||
<a-empty
|
||||
v-else-if="!data?.tasks.length"
|
||||
:style="{ marginTop: '24px' }"
|
||||
description="برای امروز کاری ثبت نشده؛ روتینهای فعال بهصورت خودکار هر روز کار میسازند. میتوانید یک کار دستی هم اضافه کنید."
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="openTasks.length">
|
||||
<a-divider orientation="right">
|
||||
باقیمانده ({{ toFaDigits(openTasks.length) }})
|
||||
</a-divider>
|
||||
<a-flex vertical :gap="8">
|
||||
<RoutineTaskRow
|
||||
v-for="t in openTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-delay
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-result
|
||||
v-else-if="filter !== 'done'"
|
||||
status="success"
|
||||
title="همه کارهای امروز انجام شد."
|
||||
/>
|
||||
|
||||
<template v-if="closedTasks.length">
|
||||
<a-divider orientation="right">
|
||||
انجامشده / بسته ({{ toFaDigits(closedTasks.length) }})
|
||||
</a-divider>
|
||||
<a-flex vertical :gap="8">
|
||||
<RoutineTaskRow
|
||||
v-for="t in closedTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</a-flex>
|
||||
</template>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="8" :xl="7">
|
||||
<a-affix :offset-top="24">
|
||||
<a-flex vertical :gap="16">
|
||||
<DailyScoreCard :score="data?.score ?? null" :days="recent" />
|
||||
|
||||
<a-card v-if="data?.overdue.length" :bordered="false">
|
||||
<template #title>
|
||||
<a-typography-text strong type="danger">
|
||||
<WarningOutlined />
|
||||
عقبافتاده از روزهای قبل
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template #extra>
|
||||
<RouterLink to="/routines/overdue" custom v-slot="{ href, navigate }">
|
||||
<a-typography-link :href="href" @click="navigate">
|
||||
همه ({{ toFaDigits(data.overdue.length) }})
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<a-flex vertical :gap="8">
|
||||
<RoutineTaskRow
|
||||
v-for="t in data.overdue.slice(0, 4)"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-affix>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.today {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.day-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
|
||||
background: linear-gradient(135deg, var(--accent-subtle), var(--surface) 70%);
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ink-secondary);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 8px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.hero-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.ring {
|
||||
--p: 0;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line-light) 0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ring span {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.progress-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.progress-copy strong {
|
||||
font-size: var(--fs-xl);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.progress-copy span {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pbar {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--line-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pbar b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: inherit;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.hero-user {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-title em {
|
||||
font-style: normal;
|
||||
color: var(--muted);
|
||||
font-weight: var(--fw-normal);
|
||||
}
|
||||
|
||||
.rows {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.all-done {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.side {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
position: sticky;
|
||||
top: var(--sp-4);
|
||||
}
|
||||
|
||||
.overdue-card {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 20%, var(--line));
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.overdue-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overdue-card h3 {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.overdue-card a {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.skeleton span {
|
||||
height: 72px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(90deg, var(--surface-subtle), var(--line-light), var(--surface-subtle));
|
||||
background-size: 200% 100%;
|
||||
animation: sk 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sk {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rt-list-move,
|
||||
.rt-list-enter-active,
|
||||
.rt-list-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.rt-list-enter-from,
|
||||
.rt-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.rt-list-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.day-hero {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-copy {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.hero-user {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,150 +1,577 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Statistic as AStatistic,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
AccountBookOutlined,
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
BankOutlined,
|
||||
CalendarOutlined,
|
||||
FallOutlined,
|
||||
RiseOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart, PieChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import type { ComposeOption } from 'echarts/core';
|
||||
import type { BarSeriesOption, PieSeriesOption } from 'echarts/charts';
|
||||
import type {
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
TooltipComponentOption,
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import {
|
||||
TREASURY_DASHBOARD_MOCK,
|
||||
isTreasuryDashboardEmpty,
|
||||
} from './treasury-dashboard-mock';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
use([CanvasRenderer, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
type ChartOption = ComposeOption<
|
||||
| BarSeriesOption
|
||||
| PieSeriesOption
|
||||
| GridComponentOption
|
||||
| TooltipComponentOption
|
||||
| LegendComponentOption
|
||||
>;
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
function num(value: string | number | null | undefined) {
|
||||
const n = Number(value ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
type MetricKey = keyof Pick<
|
||||
TreasuryDashboardDto,
|
||||
| 'bookedIncome'
|
||||
| 'actualReceipts'
|
||||
| 'bookedExpense'
|
||||
| 'actualPayments'
|
||||
| 'operatingProfit'
|
||||
| 'receivables'
|
||||
| 'payables'
|
||||
| 'totalBalance'
|
||||
>;
|
||||
|
||||
type MetricCard = {
|
||||
key: MetricKey;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: Component;
|
||||
};
|
||||
|
||||
const data = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(true);
|
||||
const usingMock = ref(false);
|
||||
|
||||
const cards = [
|
||||
{ key: 'bookedIncome', label: 'درآمد ثبتشده', hint: 'تعهدی' },
|
||||
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
|
||||
{ key: 'bookedExpense', label: 'هزینه ثبتشده', hint: 'تعهدی' },
|
||||
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
|
||||
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبتشده − هزینه ثبتشده' },
|
||||
{ key: 'receivables', label: 'مطالبات وصولنشده', hint: 'درآمد بدون دریافت کامل' },
|
||||
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
|
||||
{ key: 'totalBalance', label: 'موجودی حسابها', hint: 'صندوق و بانک' },
|
||||
] as const;
|
||||
const heroCards: MetricCard[] = [
|
||||
{
|
||||
key: 'totalBalance',
|
||||
label: 'موجودی حسابها',
|
||||
hint: 'صندوق و بانک',
|
||||
icon: WalletOutlined,
|
||||
},
|
||||
{
|
||||
key: 'operatingProfit',
|
||||
label: 'سود یا زیان عملیاتی',
|
||||
hint: 'درآمد ثبتشده − هزینه ثبتشده',
|
||||
icon: RiseOutlined,
|
||||
},
|
||||
{
|
||||
key: 'receivables',
|
||||
label: 'مطالبات وصولنشده',
|
||||
hint: 'درآمد بدون دریافت کامل',
|
||||
icon: CalendarOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
const secondaryCards: MetricCard[] = [
|
||||
{
|
||||
key: 'bookedIncome',
|
||||
label: 'درآمد ثبتشده',
|
||||
hint: 'تعهدی',
|
||||
icon: ArrowDownOutlined,
|
||||
},
|
||||
{
|
||||
key: 'actualReceipts',
|
||||
label: 'دریافت واقعی',
|
||||
hint: 'ورود به حساب',
|
||||
icon: BankOutlined,
|
||||
},
|
||||
{
|
||||
key: 'bookedExpense',
|
||||
label: 'هزینه ثبتشده',
|
||||
hint: 'تعهدی',
|
||||
icon: FallOutlined,
|
||||
},
|
||||
{
|
||||
key: 'actualPayments',
|
||||
label: 'پرداخت واقعی',
|
||||
hint: 'خروج از حساب',
|
||||
icon: ArrowUpOutlined,
|
||||
},
|
||||
{
|
||||
key: 'payables',
|
||||
label: 'تعهدات پرداخت',
|
||||
hint: 'هزینه بدون پرداخت کامل',
|
||||
icon: AccountBookOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
const softCardStyle = computed(() => ({
|
||||
background: token.value.colorPrimaryBg,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadow,
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
/** کارت سفید بدون بردر (KPI فرعی و چارتها) */
|
||||
const secondaryCardStyle = computed(() => ({
|
||||
background: token.value.colorBgContainer,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadow,
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
/** Soft teal family — primary + muted companions (not loud rainbow) */
|
||||
const chartColors = computed(() => {
|
||||
const primary = token.value.colorPrimary;
|
||||
return {
|
||||
primary,
|
||||
primarySoft: token.value.colorPrimaryBg,
|
||||
muted: token.value.colorTextQuaternary || '#a7f3d0',
|
||||
ink: token.value.colorTextSecondary,
|
||||
axis: token.value.colorBorderSecondary,
|
||||
tooltipBg: token.value.colorBgElevated,
|
||||
font: token.value.fontFamily,
|
||||
};
|
||||
});
|
||||
|
||||
const flowChartOption = computed<ChartOption>(() => {
|
||||
if (!data.value) return {};
|
||||
const c = chartColors.value;
|
||||
const categories = ['درآمد تعهدی', 'دریافت واقعی', 'هزینه تعهدی', 'پرداخت واقعی'];
|
||||
const values = [
|
||||
num(data.value.bookedIncome),
|
||||
num(data.value.actualReceipts),
|
||||
num(data.value.bookedExpense),
|
||||
num(data.value.actualPayments),
|
||||
];
|
||||
return {
|
||||
color: [c.primary, '#34d399', '#6ee7b7', '#a7f3d0'],
|
||||
textStyle: { fontFamily: c.font, color: c.ink },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: c.tooltipBg,
|
||||
borderColor: c.axis,
|
||||
textStyle: { fontFamily: c.font, color: c.ink },
|
||||
valueFormatter: (v) => toman(Number(v)),
|
||||
},
|
||||
grid: { left: 8, right: 12, top: 24, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
axisLine: { lineStyle: { color: c.axis } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontFamily: c.font,
|
||||
color: c.ink,
|
||||
interval: 0,
|
||||
rotate: 0,
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: c.axis, type: 'dashed' } },
|
||||
axisLabel: {
|
||||
fontFamily: c.font,
|
||||
color: c.ink,
|
||||
formatter: (v: number) =>
|
||||
new Intl.NumberFormat('fa-IR', { notation: 'compact' }).format(v),
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: values.map((value, index) => ({
|
||||
value,
|
||||
itemStyle: {
|
||||
color: [c.primary, '#34d399', '#6ee7b7', '#a7f3d0'][index],
|
||||
borderRadius: [8, 8, 0, 0],
|
||||
},
|
||||
})),
|
||||
barMaxWidth: 36,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const mixChartOption = computed<ChartOption>(() => {
|
||||
if (!data.value) return {};
|
||||
const c = chartColors.value;
|
||||
const slices = [
|
||||
{ name: 'موجودی حسابها', value: Math.max(0, num(data.value.totalBalance)) },
|
||||
{ name: 'مطالبات', value: Math.max(0, num(data.value.receivables)) },
|
||||
{ name: 'تعهدات', value: Math.max(0, num(data.value.payables)) },
|
||||
];
|
||||
const hasValue = slices.some((s) => s.value > 0);
|
||||
return {
|
||||
color: [c.primary, '#34d399', '#a7f3d0'],
|
||||
textStyle: { fontFamily: c.font, color: c.ink },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: c.tooltipBg,
|
||||
borderColor: c.axis,
|
||||
textStyle: { fontFamily: c.font, color: c.ink },
|
||||
formatter: (params: unknown) => {
|
||||
const p = params as { name?: string; value?: number; percent?: number };
|
||||
return `${p.name}<br/>${toman(p.value)} (${p.percent ?? 0}٪)`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
icon: 'circle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: { fontFamily: c.font, color: c.ink, fontSize: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '72%'],
|
||||
center: ['50%', '46%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 8,
|
||||
borderColor: token.value.colorBgContainer,
|
||||
borderWidth: 3,
|
||||
},
|
||||
label: { show: false },
|
||||
data: hasValue
|
||||
? slices
|
||||
: [{ name: 'بدون داده', value: 1, itemStyle: { color: c.primarySoft } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
usingMock.value = false;
|
||||
try {
|
||||
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
|
||||
data.value = res.data;
|
||||
if (isTreasuryDashboardEmpty(res.data)) {
|
||||
data.value = TREASURY_DASHBOARD_MOCK;
|
||||
usingMock.value = true;
|
||||
} else {
|
||||
data.value = res.data;
|
||||
}
|
||||
} catch {
|
||||
error.value = 'بارگذاری داشبورد مالی ناموفق بود.';
|
||||
data.value = TREASURY_DASHBOARD_MOCK;
|
||||
usingMock.value = true;
|
||||
error.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert
|
||||
v-if="usingMock"
|
||||
type="info"
|
||||
show-icon
|
||||
message="در حال نمایش دادهٔ نمونه (ماک) برای پیشنمایش داشبورد هستید."
|
||||
description="وقتی سند و حساب واقعی ثبت شود، همین صفحه دادهٔ واقعی API را نشان میدهد."
|
||||
/>
|
||||
|
||||
<template v-if="data">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:xl="6"
|
||||
>
|
||||
<a-card :bordered="false">
|
||||
<a-statistic :title="card.label" :value="toman(data[card.key])" />
|
||||
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.upcomingInstallments.length"
|
||||
description="قسط بازی نیست."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.upcomingInstallments"
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="data">
|
||||
<a-row :gutter="[token.marginSM, token.marginSM]">
|
||||
<a-col
|
||||
v-for="card in heroCards"
|
||||
:key="card.key"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:lg="8"
|
||||
>
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:body-style="{ padding: `${token.padding}px` }"
|
||||
:style="softCardStyle"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>قسط {{ item.position }}</template>
|
||||
<template #description>
|
||||
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-divider orientation="right">پرداختهای آینده</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.upcomingPayables.length"
|
||||
description="تعهد پرداخت بازی نیست."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.upcomingPayables"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>مانده {{ toman(item.remaining) }}</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-flex align="center" justify="space-between" :gap="token.marginSM">
|
||||
<div :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text
|
||||
strong
|
||||
:style="{
|
||||
fontSize: '22px',
|
||||
lineHeight: 1.3,
|
||||
color: token.colorPrimary,
|
||||
}"
|
||||
>
|
||||
{{ toman(data[card.key]) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ card.hint }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-avatar
|
||||
:size="48"
|
||||
:style="{
|
||||
background: token.colorPrimary,
|
||||
color: token.colorTextLightSolid,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<template #icon><component :is="card.icon" /></template>
|
||||
</a-avatar>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="right">آخرین اسناد</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.recent.length"
|
||||
description="هنوز سندی ثبت نشده است."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.recent"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-flex>
|
||||
<a-row :gutter="[token.marginSM, token.marginSM]" :style="{ marginTop: `${token.margin}px` }">
|
||||
<a-col
|
||||
v-for="card in secondaryCards"
|
||||
:key="card.key"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="8"
|
||||
>
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:body-style="{ padding: `${token.paddingSM}px` }"
|
||||
:style="secondaryCardStyle"
|
||||
>
|
||||
<a-flex align="center" justify="space-between" :gap="8">
|
||||
<div :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ card.label }}
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text
|
||||
strong
|
||||
:style="{ fontSize: '15px', color: token.colorPrimary }"
|
||||
>
|
||||
{{ toman(data[card.key]) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '11px' }">
|
||||
{{ card.hint }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-avatar
|
||||
:size="36"
|
||||
:style="{
|
||||
background: token.colorPrimaryBg,
|
||||
color: token.colorPrimary,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<template #icon><component :is="card.icon" /></template>
|
||||
</a-avatar>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[token.marginSM, token.marginSM]" :style="{ marginTop: `${token.margin}px` }">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:style="secondaryCardStyle"
|
||||
>
|
||||
<a-typography-text strong>جریان تعهدی و نقدی</a-typography-text>
|
||||
<a-typography-text
|
||||
type="secondary"
|
||||
:style="{ display: 'block', marginBottom: `${token.marginSM}px`, fontSize: '12px' }"
|
||||
>
|
||||
مقایسه درآمد/هزینه ثبتشده با دریافت و پرداخت واقعی
|
||||
</a-typography-text>
|
||||
<VChart
|
||||
:option="flowChartOption"
|
||||
autoresize
|
||||
:style="{ height: '260px', width: '100%' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:style="secondaryCardStyle"
|
||||
>
|
||||
<a-typography-text strong>ترکیب وضعیت مالی</a-typography-text>
|
||||
<a-typography-text
|
||||
type="secondary"
|
||||
:style="{ display: 'block', marginBottom: `${token.marginSM}px`, fontSize: '12px' }"
|
||||
>
|
||||
سهم موجودی، مطالبات و تعهدات
|
||||
</a-typography-text>
|
||||
<VChart
|
||||
:option="mixChartOption"
|
||||
autoresize
|
||||
:style="{ height: '260px', width: '100%' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[token.marginSM, token.marginSM]" :style="{ marginTop: `${token.margin}px` }">
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
title="اقساط نزدیک یا معوق"
|
||||
:style="secondaryCardStyle"
|
||||
:body-style="{ paddingBlock: `${token.paddingSM}px` }"
|
||||
>
|
||||
<a-empty
|
||||
v-if="!data.upcomingInstallments.length"
|
||||
description="قسط بازی نیست."
|
||||
:image-style="{ height: '48px' }"
|
||||
/>
|
||||
<a-flex v-else vertical :gap="token.marginXS">
|
||||
<a-flex
|
||||
v-for="item in data.upcomingInstallments"
|
||||
:key="item.id"
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
:gap="8"
|
||||
>
|
||||
<div :style="{ minWidth: 0 }">
|
||||
<a-typography-text>قسط {{ item.position }}</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ item.status === 'overdue' ? 'معوق' : item.status === 'partial' ? 'جزئی' : 'باز' }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
<a-typography-text strong :style="{ color: token.colorPrimary, whiteSpace: 'nowrap' }">
|
||||
{{ toman(item.amount) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
title="پرداختهای آینده"
|
||||
:style="secondaryCardStyle"
|
||||
:body-style="{ paddingBlock: `${token.paddingSM}px` }"
|
||||
>
|
||||
<a-empty
|
||||
v-if="!data.upcomingPayables.length"
|
||||
description="تعهد پرداخت بازی نیست."
|
||||
:image-style="{ height: '48px' }"
|
||||
/>
|
||||
<a-flex v-else vertical :gap="token.marginXS">
|
||||
<a-flex
|
||||
v-for="item in data.upcomingPayables"
|
||||
:key="item.id"
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
:gap="8"
|
||||
>
|
||||
<div :style="{ minWidth: 0 }">
|
||||
<a-typography-text>{{ item.title }}</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
مانده {{ toman(item.remaining) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
<a-typography-text strong :style="{ color: token.colorPrimary, whiteSpace: 'nowrap' }">
|
||||
{{ toman(item.amount) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
title="آخرین اسناد"
|
||||
:style="secondaryCardStyle"
|
||||
:body-style="{ paddingBlock: `${token.paddingSM}px` }"
|
||||
>
|
||||
<a-empty
|
||||
v-if="!data.recent.length"
|
||||
description="هنوز سندی ثبت نشده است."
|
||||
:image-style="{ height: '48px' }"
|
||||
/>
|
||||
<a-flex v-else vertical :gap="token.marginXS">
|
||||
<a-flex
|
||||
v-for="item in data.recent"
|
||||
:key="item.id"
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
:gap="8"
|
||||
>
|
||||
<div :style="{ minWidth: 0 }">
|
||||
<a-typography-text>{{ item.title }}</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
<a-typography-text strong :style="{ color: token.colorPrimary, whiteSpace: 'nowrap' }">
|
||||
{{ toman(item.amount) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
FinancialDocumentDto,
|
||||
FinancialInstallmentDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
function daysFromNow(days: number) {
|
||||
const d = new Date();
|
||||
d.setHours(12, 0, 0, 0);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function doc(
|
||||
partial: Pick<
|
||||
FinancialDocumentDto,
|
||||
'id' | 'kind' | 'title' | 'amount' | 'settledAmount' | 'remaining' | 'status' | 'categoryName' | 'partyName'
|
||||
> &
|
||||
Partial<FinancialDocumentDto>,
|
||||
): FinancialDocumentDto {
|
||||
return {
|
||||
bookedAt: daysFromNow(-3),
|
||||
dueAt: daysFromNow(10),
|
||||
categoryId: null,
|
||||
personId: null,
|
||||
accountId: null,
|
||||
accountName: 'صندوق اصلی',
|
||||
method: 'transfer',
|
||||
reference: null,
|
||||
notes: null,
|
||||
createdAt: daysFromNow(-2),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function installment(
|
||||
partial: FinancialInstallmentDto,
|
||||
): FinancialInstallmentDto {
|
||||
return partial;
|
||||
}
|
||||
|
||||
/** دیتای نمایشی برای پیشنمایش داشبورد وقتی API خالی است */
|
||||
export const TREASURY_DASHBOARD_MOCK: TreasuryDashboardDto = {
|
||||
bookedIncome: '185000000',
|
||||
actualReceipts: '142500000',
|
||||
bookedExpense: '98000000',
|
||||
actualPayments: '87500000',
|
||||
operatingProfit: '87000000',
|
||||
receivables: '42500000',
|
||||
payables: '18500000',
|
||||
totalBalance: '256000000',
|
||||
upcomingInstallments: [
|
||||
installment({
|
||||
id: 'mock-inst-1',
|
||||
position: 2,
|
||||
dueDate: daysFromNow(-2),
|
||||
amount: '12000000',
|
||||
paidAmount: '0',
|
||||
remaining: '12000000',
|
||||
status: 'overdue',
|
||||
}),
|
||||
installment({
|
||||
id: 'mock-inst-2',
|
||||
position: 3,
|
||||
dueDate: daysFromNow(5),
|
||||
amount: '12000000',
|
||||
paidAmount: '4000000',
|
||||
remaining: '8000000',
|
||||
status: 'partial',
|
||||
}),
|
||||
installment({
|
||||
id: 'mock-inst-3',
|
||||
position: 4,
|
||||
dueDate: daysFromNow(35),
|
||||
amount: '12000000',
|
||||
paidAmount: '0',
|
||||
remaining: '12000000',
|
||||
status: 'open',
|
||||
}),
|
||||
],
|
||||
upcomingPayables: [
|
||||
doc({
|
||||
id: 'mock-pay-1',
|
||||
kind: 'expense',
|
||||
title: 'اجاره دفتر — مهر',
|
||||
amount: '45000000',
|
||||
settledAmount: '30000000',
|
||||
remaining: '15000000',
|
||||
status: 'partial',
|
||||
categoryName: 'اجاره',
|
||||
partyName: 'موجر ساختمان',
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-pay-2',
|
||||
kind: 'expense',
|
||||
title: 'بیمه تکمیلی کارکنان',
|
||||
amount: '8500000',
|
||||
settledAmount: '5000000',
|
||||
remaining: '3500000',
|
||||
status: 'partial',
|
||||
categoryName: 'بیمه',
|
||||
partyName: 'بیمه ایران',
|
||||
}),
|
||||
],
|
||||
recent: [
|
||||
doc({
|
||||
id: 'mock-doc-1',
|
||||
kind: 'income',
|
||||
title: 'پیشپرداخت پروژه آلفا',
|
||||
amount: '75000000',
|
||||
settledAmount: '75000000',
|
||||
remaining: '0',
|
||||
status: 'settled',
|
||||
categoryName: 'فروش خدمات',
|
||||
partyName: 'شرکت آریا',
|
||||
bookedAt: daysFromNow(-1),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-2',
|
||||
kind: 'expense',
|
||||
title: 'خرید تجهیزات شبکه',
|
||||
amount: '18500000',
|
||||
settledAmount: '18500000',
|
||||
remaining: '0',
|
||||
status: 'settled',
|
||||
categoryName: 'تجهیزات',
|
||||
partyName: 'فروشگاه سختافزار',
|
||||
bookedAt: daysFromNow(-2),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-3',
|
||||
kind: 'income',
|
||||
title: 'قسط قرارداد بتا',
|
||||
amount: '12000000',
|
||||
settledAmount: '0',
|
||||
remaining: '12000000',
|
||||
status: 'open',
|
||||
categoryName: 'فروش خدمات',
|
||||
partyName: 'گروه سپهر',
|
||||
bookedAt: daysFromNow(-4),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-4',
|
||||
kind: 'expense',
|
||||
title: 'حقوق مهر — تیم فروش',
|
||||
amount: '42000000',
|
||||
settledAmount: '42000000',
|
||||
remaining: '0',
|
||||
status: 'settled',
|
||||
categoryName: 'حقوق',
|
||||
partyName: null,
|
||||
bookedAt: daysFromNow(-5),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-5',
|
||||
kind: 'income',
|
||||
title: 'فروش پکیج پشتیبانی',
|
||||
amount: '28000000',
|
||||
settledAmount: '10000000',
|
||||
remaining: '18000000',
|
||||
status: 'partial',
|
||||
categoryName: 'پشتیبانی',
|
||||
partyName: 'کارخانه نوین',
|
||||
bookedAt: daysFromNow(-6),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export function isTreasuryDashboardEmpty(data: TreasuryDashboardDto | null | undefined) {
|
||||
if (!data) return true;
|
||||
const keys = [
|
||||
data.bookedIncome,
|
||||
data.actualReceipts,
|
||||
data.bookedExpense,
|
||||
data.actualPayments,
|
||||
data.totalBalance,
|
||||
data.receivables,
|
||||
data.payables,
|
||||
] as const;
|
||||
const allZero = keys.every((v) => !Number(v));
|
||||
return (
|
||||
allZero &&
|
||||
data.recent.length === 0 &&
|
||||
data.upcomingInstallments.length === 0 &&
|
||||
data.upcomingPayables.length === 0
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
@@ -13,7 +14,7 @@ export default defineConfig(({ mode }) => {
|
||||
console.log(`[montact] UI → ${apiTarget}${usingLocalApi ? ' (API لوکال)' : ' (سرور اصلی)'}`);
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Generated
+597
@@ -72,7 +72,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"
|
||||
@@ -702,6 +704,17 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"dev": true,
|
||||
@@ -1143,6 +1156,300 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.11.1",
|
||||
"@emnapi/runtime": "^1.11.1",
|
||||
"@emnapi/wasi-threads": "^1.2.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
@@ -2726,6 +3033,16 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-align": {
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
|
||||
@@ -3930,6 +4247,279 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"dev": true,
|
||||
@@ -5387,6 +5977,13 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"dev": true,
|
||||
|
||||
@@ -3467,13 +3467,55 @@ export interface HomeDashboardTopCustomerDto {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardSeriesPointDto {
|
||||
monthKey: string;
|
||||
monthLabel: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardSliceDto {
|
||||
label: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardFunnelStageDto {
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardSalesSummaryDto {
|
||||
openCount: number;
|
||||
wonCount: number;
|
||||
lostCount: number;
|
||||
openAmount: number;
|
||||
wonAmount: number;
|
||||
lostAmount: number;
|
||||
winRate: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardActivityTypeDto {
|
||||
type: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardDto {
|
||||
currency: string;
|
||||
kpis: HomeDashboardKpiDto[];
|
||||
alerts: HomeDashboardAlertDto[];
|
||||
salesTrend: HomeDashboardTrendPointDto[];
|
||||
/** رشد مشتریان جدید بهازای ماه شمسی */
|
||||
customerGrowth: HomeDashboardSeriesPointDto[];
|
||||
sources: HomeDashboardSourceDto[];
|
||||
topCustomers: HomeDashboardTopCustomerDto[];
|
||||
salesSummary: HomeDashboardSalesSummaryDto;
|
||||
funnel: HomeDashboardFunnelStageDto[];
|
||||
lostReasons: HomeDashboardSliceDto[];
|
||||
activityByType: HomeDashboardActivityTypeDto[];
|
||||
}
|
||||
|
||||
/** تابلوی پروژه / کارت */
|
||||
|
||||
Reference in New Issue
Block a user