4 Commits
Author SHA1 Message Date
MahdiandCursor b11affbdfd fix(web): load IRANSans outside the Tailwind pipeline
Tailwind rewrites url() values that contain parentheses, so the FaNum font files never resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:32:30 +03:30
Mahdi 7aaa15fad2 merge: bring latest origin/main into dev (keep local UI)
Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
2026-11-03 16:55:42 +03:30
Mahdi 39c62834e7 fix: resolve Ant switch and unused board menu type errors blocking push 2026-11-03 16:53:49 +03:30
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30
81 changed files with 20071 additions and 26523 deletions
+11 -3
View File
@@ -41,11 +41,19 @@ Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) rema
6. **24-column grid only**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale**
App root is wrapped with `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">`. Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
7. **RTL + locale + StyleProvider**
App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme).
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,9 +38,13 @@ Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` |
| RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow
1. Confirm the **single** route/view the user named.
+65
View File
@@ -0,0 +1,65 @@
---
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
globs: apps/web/src/**/*.{vue,ts,tsx,css}
alwaysApply: true
---
# قرارداد حاکمیتی و غیرقابل‌مذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب می‌شود:
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
1. **ممنوعیت ویرایش دسته‌جمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرت‌نکرده:** فایل‌های داخل `apps/web/src/components/ui/Ui*.vue` و استایل‌های سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحه‌ای که کاربر دستور بازنویسی آن را می‌دهد، تمام ایمپورت‌های `@/components/ui` و کلاس‌های قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننت‌های رسمی `<a-...>` جایگزین گردند.
4. **کامپوننت‌های تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسی‌شده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آن‌ها را حفظ کن اما مطمئن شو ظاهر ورودی آن‌ها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً هم‌تراز با فیلدهای Ant Design نمایش داده می‌شود.
## ۲. لیست سیاه سینتکس و تگ‌های ممنوعه در صفحات بازنویسی‌شده (Banned Syntax)
در هر فایلی که بازنویسی یا ایجاد می‌کنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
- **تگ‌های خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
- **کلاس‌های لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
- **کلاس‌های Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاس‌های ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترل‌ها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
1. **ممنوعیت تکه‌تکه کردن فرم به کارت‌های متعدد (Anti-Card-Fragmentation):**
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
- برای تفکیک بخش‌های منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیاده‌سازی شود، نه با `div` و `span` دستی.
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
- تمام فرم‌ها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دسته‌بندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
- **فیلدهای تمام‌عرض** (توضیحات، یادداشت، چک‌لیست، برچسب‌ها): `<a-col :span="24">`.
- **سوییچ‌ها و چک‌باکس‌های هم‌خانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
4. **مدیریت Margin و Padding بدون CSS دستی:**
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل می‌شود.
- فاصلهٔ افقی/عمودی دکمه‌ها و المان‌ها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل می‌شود.
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننت‌های Ant Design Vue خطاست:
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
- **الزام عرض ۱۰۰٪ روی کنترل‌های خاص:** کامپوننت‌های `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
- **فیدبک و پیام‌ها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
## ۵. حاکمیت مطلق توکن‌ها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصله‌های دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترل‌ها، پدینگ و رنگ‌ها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف می‌شوند.
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
4. استفاده از `:deep(.ant-...)` برای دست‌کاری ظاهر کامپوننت‌های انت ممنوع است.
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دست‌نخورده مانده‌اند؟
- [ ] آیا تمام تگ‌های خام فرم، کامپوننت‌های `Ui*`، کلاس‌های `.btn`/`.field`/`.crm-*` و کلاس‌های ناموجود مثل `w-full` از این صفحه حذف شده‌اند؟
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمام‌عرض خارج شده‌اند؟
- [ ] آیا تمام بایندینگ‌ها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکت‌ها و شمارنده‌ها استفاده می‌کنند؟
- [ ] آیا `<style scoped>` کاملاً از رنگ‌های Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاک‌سازی شده است؟
+264 -20
View File
@@ -1533,6 +1533,7 @@ export class ReportsService {
async home(actor: Actor): Promise<HomeDashboardDto> {
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 'بدون دلیل ثبت‌شده';
+2
View File
@@ -22,7 +22,9 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^5.2.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
+51 -113
View File
@@ -4,8 +4,20 @@ import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { ThunderboltOutlined } from '@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>
+59 -108
View File
@@ -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 {
+95 -166
View File
@@ -5,6 +5,20 @@ import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } =
Typography;
const props = defineProps<{
noteId: string;
@@ -148,193 +162,108 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
</script>
<template>
<section class="ai-panel">
<header>
<strong>پیشنهاد {{ brand.aiName }}</strong>
<button
<a-card :bordered="true" :style="{ marginTop: '12px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text>
<a-button
v-if="!isPending"
type="button"
class="link"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? '…' : 'تحلیل مجدد' }}
</button>
<button
</a-button>
<a-button
v-else-if="!suggestion"
type="button"
class="link"
type="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</button>
</header>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
<p v-else-if="suggestion?.status === 'failed'" class="error">
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
<button type="button" class="link" @click="analyze">تلاش دوباره</button>
</p>
<p v-else-if="!suggestion" class="muted">
<button type="button" class="link" :disabled="loading" @click="analyze">
تحلیل این یادداشت با AI
</button>
</p>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" />
<a-typography-text v-else-if="loading && !suggestion" type="secondary">
در حال تحلیل یادداشت…
</a-typography-text>
<a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4">
<a-alert
type="error"
show-icon
:message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
/>
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
</a-flex>
<a-button
v-else-if="!suggestion"
type="link"
:disabled="loading"
@click="analyze"
>
تحلیل این یادداشت با AI
</a-button>
<template v-else-if="isPending && payload">
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
<a-typography-paragraph
v-if="formatPayloadSummary(payload)"
type="secondary"
:style="{ marginTop: '8px' }"
>
{{ formatPayloadSummary(payload) }}
</a-typography-paragraph>
<div v-if="payload.nextAction" class="block">
<label class="check">
<input v-model="applyNextAction" type="checkbox" />
کار بعدی
</label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div>
<a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }">
<a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox>
<a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" />
</a-flex>
<div v-if="memoryItems.length" class="block">
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p>
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
<label
<a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">
حافظه مشتری (با تأیید شما ذخیره می‌شود)
</a-typography-title>
<a-alert
v-if="!hasPerson"
type="warning"
show-icon
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
/>
<a-flex
v-for="(_, index) in memoryItems"
:key="index"
class="memory-row"
:gap="8"
align="flex-start"
>
<input
v-model="selectedMemory[index]"
type="checkbox"
<a-checkbox
v-model:checked="selectedMemory[index]"
:disabled="!hasPerson"
/>
<textarea v-model="memoryEdits[index]" rows="2" />
</label>
</div>
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" />
</a-flex>
</a-flex>
<div class="actions">
<button type="button" class="btn" :disabled="applying" @click="apply">
{{ applying ? '…' : 'اعمال انتخاب‌شده' }}
</button>
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
رد کردن
</button>
</div>
<a-space :size="8" wrap :style="{ marginTop: '12px' }">
<a-button type="primary" :disabled="applying" :loading="applying" @click="apply">
اعمال انتخاب‌شده
</a-button>
<a-button :disabled="applying" @click="dismiss">رد کردن</a-button>
</a-space>
</template>
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
</section>
<a-typography-text
v-else-if="suggestion?.status === 'applied'"
type="success"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد اعمال شد.
</a-typography-text>
<a-typography-text
v-else-if="suggestion?.status === 'dismissed'"
type="secondary"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد رد شد.
</a-typography-text>
</a-card>
</template>
<style scoped>
.ai-panel {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
min-width: 0;
overflow-wrap: anywhere;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.link {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.block {
display: grid;
gap: 0.45rem;
}
.block-title {
margin: 0;
font-weight: 600;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
.memory-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem;
align-items: start;
}
input[type='text'],
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.actions .btn {
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 520px) {
.actions {
flex-direction: column;
}
.actions .btn,
.actions .btn-ghost {
width: 100%;
}
}
.summary {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.muted {
margin: 0;
color: var(--muted);
}
.ok {
color: #067647;
}
.warn {
margin: 0;
color: #b54708;
font-size: 0.88rem;
}
.error {
margin: 0;
color: #b42318;
}
</style>
+82 -90
View File
@@ -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>
+178 -402
View File
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
import { SearchOutlined, SendOutlined } from '@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>
+78 -106
View File
@@ -1,125 +1,97 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
import {
CalendarOutlined,
CheckCircleOutlined,
FundOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@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>
+1 -1
View File
@@ -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>
+80 -129
View File
@@ -1,14 +1,32 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
import { computed, type Component } from 'vue';
import {
Avatar as AAvatar,
Card as ACard,
Flex as AFlex,
Statistic as AStatistic,
Typography,
} from 'ant-design-vue';
import {
AimOutlined,
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
FlagOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@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
View File
@@ -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 {
+1
View File
@@ -4,6 +4,7 @@ import { brand } from '@mento/shared';
import App from './App.vue';
import { router } from './router';
import { vTableCols } from './lib/tableColumns';
import './styles/fonts.css';
import './styles/main.css';
document.title = `${brand.nameFa} | ${brand.fullName}`;
+5 -1
View File
@@ -1,4 +1,8 @@
@import "./fonts.css";
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@source "../";
@import "./tokens.css";
:root {
+10 -1
View File
@@ -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,
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+364 -248
View File
@@ -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>
+193 -262
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+152 -77
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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>
+189 -317
View File
@@ -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>
+372 -516
View File
@@ -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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+431 -296
View File
@@ -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
+373 -480
View File
@@ -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
View File
@@ -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
+358
View File
@@ -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>
+126 -205
View File
@@ -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>
+380 -459
View File
@@ -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>
+87 -128
View File
@@ -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>
+320 -341
View File
@@ -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>
+184 -410
View File
@@ -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>
+542 -115
View File
@@ -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
);
}
+2 -1
View File
@@ -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)),
+597
View File
@@ -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,
+42
View File
@@ -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[];
}
/** تابلوی پروژه / کارت */