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:
@@ -84,6 +84,7 @@ export class ReportsService {
|
|||||||
status: { in: ['won', 'lost'] },
|
status: { in: ['won', 'lost'] },
|
||||||
updatedAt: { gte: dealEarliest },
|
updatedAt: { gte: dealEarliest },
|
||||||
},
|
},
|
||||||
|
{ createdAt: { gte: dealEarliest } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -658,6 +659,154 @@ export class ReportsService {
|
|||||||
const winRatePct = Math.round(winRate * 1000) / 10;
|
const winRatePct = Math.round(winRate * 1000) / 10;
|
||||||
const winRatePrevPct = Math.round(winRatePrev * 1000) / 10;
|
const winRatePrevPct = Math.round(winRatePrev * 1000) / 10;
|
||||||
|
|
||||||
|
const incomingCur = deals.filter(
|
||||||
|
(d) => d.createdAt >= start && d.createdAt <= end,
|
||||||
|
);
|
||||||
|
const incomingPrev = deals.filter(
|
||||||
|
(d) => d.createdAt >= prevStart && d.createdAt <= prevEnd,
|
||||||
|
);
|
||||||
|
const convWonCur = incomingCur.filter((d) => d.status === 'won').length;
|
||||||
|
const convWonPrev = incomingPrev.filter((d) => d.status === 'won').length;
|
||||||
|
const conversionPercent = incomingCur.length
|
||||||
|
? Math.round((convWonCur / incomingCur.length) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
const previousConversionPercent = incomingPrev.length
|
||||||
|
? Math.round((convWonPrev / incomingPrev.length) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const conversionByTime: ReportsDashboardDto['conversionRate']['byTime'] =
|
||||||
|
[];
|
||||||
|
if (spanDays <= 45) {
|
||||||
|
for (let i = 0; i < spanDays; i += 1) {
|
||||||
|
const dayStart = startOfDay(
|
||||||
|
new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
|
||||||
|
);
|
||||||
|
const dayEnd = endOfDay(dayStart);
|
||||||
|
if (dayStart > end) break;
|
||||||
|
const dayIncoming = incomingCur.filter(
|
||||||
|
(d) => d.createdAt >= dayStart && d.createdAt <= dayEnd,
|
||||||
|
);
|
||||||
|
const dayWon = dayIncoming.filter((d) => d.status === 'won').length;
|
||||||
|
const j = dateToJalali(dayStart);
|
||||||
|
const key = `${dayStart.getFullYear()}-${String(dayStart.getMonth() + 1).padStart(2, '0')}-${String(dayStart.getDate()).padStart(2, '0')}`;
|
||||||
|
conversionByTime.push({
|
||||||
|
key,
|
||||||
|
label: `${j.jd} ${jalaliMonthName(j.jm)}`,
|
||||||
|
incomingCount: dayIncoming.length,
|
||||||
|
wonCount: dayWon,
|
||||||
|
conversionPercent: dayIncoming.length
|
||||||
|
? Math.round((dayWon / dayIncoming.length) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const buckets = new Map<
|
||||||
|
string,
|
||||||
|
{ label: string; incoming: number; won: number }
|
||||||
|
>();
|
||||||
|
for (const d of incomingCur) {
|
||||||
|
const j = dateToJalali(d.createdAt);
|
||||||
|
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
|
||||||
|
let bucket = buckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = {
|
||||||
|
label: `${jalaliMonthName(j.jm)} ${j.jy}`,
|
||||||
|
incoming: 0,
|
||||||
|
won: 0,
|
||||||
|
};
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.incoming += 1;
|
||||||
|
if (d.status === 'won') bucket.won += 1;
|
||||||
|
}
|
||||||
|
for (const [key, b] of [...buckets.entries()].sort((a, b) =>
|
||||||
|
a[0].localeCompare(b[0]),
|
||||||
|
)) {
|
||||||
|
conversionByTime.push({
|
||||||
|
key,
|
||||||
|
label: b.label,
|
||||||
|
incomingCount: b.incoming,
|
||||||
|
wonCount: b.won,
|
||||||
|
conversionPercent: b.incoming
|
||||||
|
? Math.round((b.won / b.incoming) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversionByUserMap = new Map<
|
||||||
|
string,
|
||||||
|
{ label: string; incoming: number; won: number }
|
||||||
|
>();
|
||||||
|
for (const u of users) {
|
||||||
|
conversionByUserMap.set(u.id, {
|
||||||
|
label: u.fullName,
|
||||||
|
incoming: 0,
|
||||||
|
won: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const d of incomingCur) {
|
||||||
|
if (!d.ownerId) continue;
|
||||||
|
const row = conversionByUserMap.get(d.ownerId);
|
||||||
|
if (!row) continue;
|
||||||
|
row.incoming += 1;
|
||||||
|
if (d.status === 'won') row.won += 1;
|
||||||
|
}
|
||||||
|
const conversionByUser: ReportsDashboardDto['conversionRate']['byUser'] = [
|
||||||
|
...conversionByUserMap.entries(),
|
||||||
|
]
|
||||||
|
.map(([key, row]) => ({
|
||||||
|
key,
|
||||||
|
label: row.label,
|
||||||
|
incomingCount: row.incoming,
|
||||||
|
wonCount: row.won,
|
||||||
|
conversionPercent: row.incoming
|
||||||
|
? Math.round((row.won / row.incoming) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
.filter((r) => r.incomingCount > 0)
|
||||||
|
.sort((a, b) => b.conversionPercent - a.conversionPercent);
|
||||||
|
|
||||||
|
const conversionBySourceMap = new Map<
|
||||||
|
string,
|
||||||
|
{ incoming: number; won: number }
|
||||||
|
>();
|
||||||
|
for (const d of incomingCur) {
|
||||||
|
const label = normalizeSource(d.source);
|
||||||
|
const row = conversionBySourceMap.get(label) ?? {
|
||||||
|
incoming: 0,
|
||||||
|
won: 0,
|
||||||
|
};
|
||||||
|
row.incoming += 1;
|
||||||
|
if (d.status === 'won') row.won += 1;
|
||||||
|
conversionBySourceMap.set(label, row);
|
||||||
|
}
|
||||||
|
const conversionBySource: ReportsDashboardDto['conversionRate']['bySource'] =
|
||||||
|
[...conversionBySourceMap.entries()]
|
||||||
|
.map(([label, row]) => ({
|
||||||
|
key: label,
|
||||||
|
label,
|
||||||
|
incomingCount: row.incoming,
|
||||||
|
wonCount: row.won,
|
||||||
|
conversionPercent: row.incoming
|
||||||
|
? Math.round((row.won / row.incoming) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.incomingCount - a.incomingCount);
|
||||||
|
|
||||||
|
const conversionRate: ReportsDashboardDto['conversionRate'] = {
|
||||||
|
incomingCount: incomingCur.length,
|
||||||
|
wonCount: convWonCur,
|
||||||
|
conversionPercent,
|
||||||
|
previousIncomingCount: incomingPrev.length,
|
||||||
|
previousWonCount: convWonPrev,
|
||||||
|
previousConversionPercent,
|
||||||
|
changePercent: pctChange(conversionPercent, previousConversionPercent),
|
||||||
|
byTime: conversionByTime,
|
||||||
|
byUser: conversionByUser,
|
||||||
|
bySource: conversionBySource,
|
||||||
|
};
|
||||||
|
|
||||||
const periodCompare: ReportsPeriodCompareRowDto[] = [
|
const periodCompare: ReportsPeriodCompareRowDto[] = [
|
||||||
{
|
{
|
||||||
key: 'sales_amount',
|
key: 'sales_amount',
|
||||||
@@ -694,6 +843,14 @@ export class ReportsService {
|
|||||||
previous: winRatePrevPct,
|
previous: winRatePrevPct,
|
||||||
changePercent: pctChange(winRatePct, winRatePrevPct),
|
changePercent: pctChange(winRatePct, winRatePrevPct),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'conversion_rate',
|
||||||
|
label: 'نرخ تبدیل',
|
||||||
|
format: 'percent',
|
||||||
|
current: conversionPercent,
|
||||||
|
previous: previousConversionPercent,
|
||||||
|
changePercent: pctChange(conversionPercent, previousConversionPercent),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'new_customers',
|
key: 'new_customers',
|
||||||
label: 'مشتریان جدید',
|
label: 'مشتریان جدید',
|
||||||
@@ -770,6 +927,11 @@ export class ReportsService {
|
|||||||
`نرخ برد معاملات در این دوره ${winRatePct}٪ بوده است.`,
|
`نرخ برد معاملات در این دوره ${winRatePct}٪ بوده است.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (incomingCur.length > 0) {
|
||||||
|
insights.push(
|
||||||
|
`نرخ تبدیل ورودیها ${conversionPercent}٪ است (${convWonCur} از ${incomingCur.length} معامله).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (invoiceCount > 0) {
|
if (invoiceCount > 0) {
|
||||||
insights.push(
|
insights.push(
|
||||||
`${invoiceCount} فاکتور صادر/پرداختشده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`,
|
`${invoiceCount} فاکتور صادر/پرداختشده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`,
|
||||||
@@ -837,6 +999,7 @@ export class ReportsService {
|
|||||||
winRate: winRatePct,
|
winRate: winRatePct,
|
||||||
},
|
},
|
||||||
funnel,
|
funnel,
|
||||||
|
conversionRate,
|
||||||
periodCompare,
|
periodCompare,
|
||||||
tickets: {
|
tickets: {
|
||||||
open: ticketOpen,
|
open: ticketOpen,
|
||||||
|
|||||||
@@ -712,6 +712,25 @@ function exportReport() {
|
|||||||
...d.team.map(
|
...d.team.map(
|
||||||
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
|
(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')], {
|
const blob = new Blob(['\ufeff' + lines.join('\n')], {
|
||||||
type: 'text/csv;charset=utf-8',
|
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 topProductsOption = computed<UiChartOption>(() => {
|
||||||
const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse();
|
const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse();
|
||||||
return {
|
return {
|
||||||
@@ -1386,6 +1485,113 @@ const activityMax = computed(() =>
|
|||||||
</UiCard>
|
</UiCard>
|
||||||
</div>
|
</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">
|
<div class="charts-grid">
|
||||||
<!-- Sales trend (wide) -->
|
<!-- Sales trend (wide) -->
|
||||||
<UiCard class="chart-card span-2" title="روند فروش">
|
<UiCard class="chart-card span-2" title="روند فروش">
|
||||||
@@ -1757,6 +1963,68 @@ const activityMax = computed(() =>
|
|||||||
line-height: 1.6;
|
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 {
|
.funnel-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
@@ -1939,6 +2207,10 @@ const activityMax = computed(() =>
|
|||||||
.deep-grid {
|
.deep-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversion-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toolbar {
|
.filter-toolbar {
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ AI پیشنهاد Next Action میدهد؛ اعمال فقط با تأیید
|
|||||||
|
|
||||||
### گزارشات و داشبورد تحلیلی
|
### گزارشات و داشبورد تحلیلی
|
||||||
فروش، قیف، فعالیت، تیم و تیکت با فیلتر تاریخ.
|
فروش، قیف، فعالیت، تیم و تیکت با فیلتر تاریخ.
|
||||||
|
نرخ تبدیل (سبک دیدار): از معاملات تازهوارد چند درصد موفق شدهاند — بهتفکیک زمان، کاربر و منبع.
|
||||||
|
|
||||||
### تنظیمات تیم
|
### تنظیمات تیم
|
||||||
دعوت کاربر و مدیریت نقشها.
|
دعوت کاربر و مدیریت نقشها.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
| تاریخ | مورد |
|
| تاریخ | مورد |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع |
|
||||||
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
|
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
|
||||||
| ۱۴۰۵/۷/۵ | همترازی چکلیست: API عمومی/وبهوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
|
| ۱۴۰۵/۷/۵ | همترازی چکلیست: API عمومی/وبهوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
|
||||||
| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز |
|
| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز |
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
|
|
||||||
| وضعیت | مورد | توضیح |
|
| وضعیت | مورد | توضیح |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [x] | گزارشات و داشبورد | فروش/قیف/فعالیت/تیم/تیکت + فیلتر تاریخ |
|
| [x] | گزارشات و داشبورد | فروش/قیف/فعالیت/تیم/تیکت + فیلتر تاریخ + نرخ تبدیل ورودی→موفق |
|
||||||
| [x] | اتوماسیون اگر/آنگاه | deal_won / ticket_resolved → فعالیت، اعلان، امتیاز |
|
| [x] | اتوماسیون اگر/آنگاه | deal_won / ticket_resolved → فعالیت، اعلان، امتیاز |
|
||||||
| [x] | SMS عملیاتی | OTP auth/پرتال؛ یادآور فعالیت (ترِز) |
|
| [x] | SMS عملیاتی | OTP auth/پرتال؛ یادآور فعالیت (ترِز) |
|
||||||
| [x] | API عمومی + وبهوک خروجی | کلید API؛ `public/v1` افراد/معاملات؛ endpoint وبهوک + تحویل دورهای |
|
| [x] | API عمومی + وبهوک خروجی | کلید API؛ `public/v1` افراد/معاملات؛ endpoint وبهوک + تحویل دورهای |
|
||||||
|
|||||||
@@ -2155,6 +2155,33 @@ export interface ReportsFunnelStageDto {
|
|||||||
shareOfPipelinePercent: number;
|
shareOfPipelinePercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یک ردیف نرخ تبدیل (ورودی → موفق) مثل دیدار */
|
||||||
|
export interface ReportsConversionRateRowDto {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
incomingCount: number;
|
||||||
|
wonCount: number;
|
||||||
|
/** درصد؛ اگر ورودی صفر باشد ۰ */
|
||||||
|
conversionPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نرخ تبدیل به سبک دیدار:
|
||||||
|
* از معاملات تازهساختهشده در بازه، چند درصد الان برنده شدهاند.
|
||||||
|
*/
|
||||||
|
export interface ReportsConversionRateDto {
|
||||||
|
incomingCount: number;
|
||||||
|
wonCount: number;
|
||||||
|
conversionPercent: number;
|
||||||
|
previousIncomingCount: number;
|
||||||
|
previousWonCount: number;
|
||||||
|
previousConversionPercent: number;
|
||||||
|
changePercent: number | null;
|
||||||
|
byTime: ReportsConversionRateRowDto[];
|
||||||
|
byUser: ReportsConversionRateRowDto[];
|
||||||
|
bySource: ReportsConversionRateRowDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportsPeriodCompareRowDto {
|
export interface ReportsPeriodCompareRowDto {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -2219,6 +2246,8 @@ export interface ReportsDashboardDto {
|
|||||||
winRate: number;
|
winRate: number;
|
||||||
};
|
};
|
||||||
funnel: ReportsFunnelStageDto[];
|
funnel: ReportsFunnelStageDto[];
|
||||||
|
/** نرخ تبدیل ورودی→موفق (سبک دیدار) */
|
||||||
|
conversionRate: ReportsConversionRateDto;
|
||||||
/** مقایسه صریح بازه فعلی با دوره قبل همطول */
|
/** مقایسه صریح بازه فعلی با دوره قبل همطول */
|
||||||
periodCompare: ReportsPeriodCompareRowDto[];
|
periodCompare: ReportsPeriodCompareRowDto[];
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|||||||
Reference in New Issue
Block a user