Add Didar-style conversion rate to sales reports.

Show won-over-incoming deals for the selected period with time, user, and source breakdowns alongside the existing win-rate metric.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 15:36:34 +03:30
co-authored by Cursor
parent c5c903d4b4
commit 22980e5152
5 changed files with 467 additions and 1 deletions
+272
View File
@@ -712,6 +712,25 @@ function exportReport() {
...d.team.map(
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
),
'',
'نرخ تبدیل (ورودی به موفق)',
`کل,${d.conversionRate?.conversionPercent ?? ''}٪`,
`ورودی,${d.conversionRate?.incomingCount ?? 0}`,
`موفق,${d.conversionRate?.wonCount ?? 0}`,
'',
'نرخ تبدیل بر اساس زمان',
'بازه,ورودی,موفق,نرخ٪',
...(d.conversionRate?.byTime ?? []).map(
(r) =>
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
),
'',
'نرخ تبدیل بر اساس کاربر',
'کاربر,ورودی,موفق,نرخ٪',
...(d.conversionRate?.byUser ?? []).map(
(r) =>
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
),
];
const blob = new Blob(['\ufeff' + lines.join('\n')], {
type: 'text/csv;charset=utf-8',
@@ -879,6 +898,86 @@ const monthlyOption = computed<UiChartOption>(() => {
};
});
const conversionTimeOption = computed<UiChartOption>(() => {
const rows = data.value?.conversionRate?.byTime ?? [];
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const name = list[0]?.name ?? '';
const row = rows.find((r) => r.label === name);
if (!row) return name;
return `${name}<br/>نرخ تبدیل: <strong>${toFaDigits(row.conversionPercent)}٪</strong><br/>موفق: ${num(row.wonCount)} از ${num(row.incomingCount)}`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '14%', containLabel: true },
legend: {
top: 0,
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
xAxis: {
type: 'category',
data: rows.map((r) => r.label),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: [
{
type: 'value',
name: 'تعداد',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => num(v),
},
},
{
type: 'value',
name: 'نرخ ٪',
min: 0,
max: 100,
axisLine: { show: false },
splitLine: { show: false },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => `${toFaDigits(v)}٪`,
},
},
],
series: [
{
name: 'ورودی',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: '#94a3b8', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.incomingCount),
},
{
name: 'موفق',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.wonCount),
},
{
name: 'نرخ تبدیل',
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: true,
symbolSize: 7,
itemStyle: { color: '#0f766e' },
lineStyle: { width: 2, color: '#0f766e' },
data: rows.map((r) => r.conversionPercent),
},
],
};
});
const topProductsOption = computed<UiChartOption>(() => {
const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse();
return {
@@ -1386,6 +1485,113 @@ const activityMax = computed(() =>
</UiCard>
</div>
<div class="deep-grid conversion-grid">
<UiCard class="chart-card span-2" title="نرخ تبدیل">
<p class="deep-hint">
از معاملات تازه‌ثبت‌شده در این بازه، چند درصد تا الان به فروش موفق رسیده‌اند
(متفاوت از «نرخ برد» که فقط معاملات بسته‌شده را می‌بیند).
</p>
<div
v-if="data.conversionRate"
class="conversion-summary"
>
<div class="conversion-stat">
<span>نرخ تبدیل</span>
<strong>
{{ toFaDigits(data.conversionRate.conversionPercent) }}٪
</strong>
<small>
{{ formatChange(data.conversionRate.changePercent) }} نسبت به دوره قبل
</small>
</div>
<div class="conversion-stat">
<span>ورودی دوره</span>
<strong>{{ num(data.conversionRate.incomingCount) }}</strong>
<small>
دوره قبل {{ num(data.conversionRate.previousIncomingCount) }}
</small>
</div>
<div class="conversion-stat">
<span>موفق از همین ورودی‌ها</span>
<strong>{{ num(data.conversionRate.wonCount) }}</strong>
<small>
دوره قبل {{ num(data.conversionRate.previousWonCount) }}
</small>
</div>
</div>
<UiChart
v-if="data.conversionRate?.byTime?.length"
:option="conversionTimeOption"
height="240px"
/>
<p v-else class="empty-msg">در این بازه معامله ورودی ثبت نشده است.</p>
</UiCard>
<UiCard class="chart-card" title="نرخ تبدیل بر اساس کاربر">
<p class="deep-hint">
کیفیت تبدیل فرصت‌های هر فروشنده در همین بازه.
</p>
<ul
v-if="data.conversionRate?.byUser?.length"
class="conversion-list"
>
<li
v-for="u in data.conversionRate.byUser.slice(0, 10)"
:key="u.key"
>
<div class="conversion-list-top">
<strong>{{ u.label }}</strong>
<em>{{ toFaDigits(u.conversionPercent) }}٪</em>
</div>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(4, Math.min(100, u.conversionPercent))}%`,
}"
/>
</div>
<small>
{{ num(u.wonCount) }} موفق از {{ num(u.incomingCount) }} ورودی
</small>
</li>
</ul>
<p v-else class="empty-msg">داده کاربری نیست.</p>
</UiCard>
<UiCard class="chart-card" title="نرخ تبدیل بر اساس منبع">
<p class="deep-hint">
کدام شیوه آشنایی / منبع ورودی بهتر به فروش می‌رسد.
</p>
<ul
v-if="data.conversionRate?.bySource?.length"
class="conversion-list"
>
<li
v-for="s in data.conversionRate.bySource.slice(0, 10)"
:key="s.key"
>
<div class="conversion-list-top">
<strong>{{ s.label }}</strong>
<em>{{ toFaDigits(s.conversionPercent) }}٪</em>
</div>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(4, Math.min(100, s.conversionPercent))}%`,
}"
/>
</div>
<small>
{{ num(s.wonCount) }} موفق از {{ num(s.incomingCount) }} ورودی
</small>
</li>
</ul>
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
</UiCard>
</div>
<div class="charts-grid">
<!-- Sales trend (wide) -->
<UiCard class="chart-card span-2" title="روند فروش">
@@ -1757,6 +1963,68 @@ const activityMax = computed(() =>
line-height: 1.6;
}
.conversion-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.conversion-stat {
display: grid;
gap: 0.25rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-subtle, #f8fafc);
}
.conversion-stat span {
font-size: var(--fs-xs);
color: var(--muted);
}
.conversion-stat strong {
font-size: 1.35rem;
color: var(--accent-deep, #0f766e);
}
.conversion-stat small {
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.conversion-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.conversion-list li {
display: grid;
gap: 0.3rem;
}
.conversion-list-top {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
}
.conversion-list-top em {
font-style: normal;
font-weight: 700;
color: var(--accent-deep, #0f766e);
}
.conversion-list small {
color: var(--muted);
font-size: var(--fs-xs);
}
.funnel-wrap {
display: grid;
gap: var(--sp-4);
@@ -1939,6 +2207,10 @@ const activityMax = computed(() =>
.deep-grid {
grid-template-columns: 1fr;
}
.conversion-summary {
grid-template-columns: 1fr;
}
}
.filter-toolbar {