Add deeper reports: stage conversion, period compare, and deal drill-down.

This commit is contained in:
2026-09-27 10:39:09 +03:30
parent d133c8ca34
commit 6a9e864b07
5 changed files with 889 additions and 60 deletions
+429 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
@@ -15,12 +16,20 @@ import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiModal,
UiPageHeader,
UiChart,
CHART_PALETTE,
type UiChartOption,
} from '@/components/ui';
import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared';
import type {
OrganizationDto,
ReportsDashboardDto,
ReportsDrillKind,
ReportsDrilldownDto,
ReportsFunnelStageDto,
ReportsPeriodCompareRowDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
@@ -33,6 +42,7 @@ const PERIODS: Array<{ key: PeriodKey; label: string }> = [
{ key: 'year', label: 'سال' },
];
const router = useRouter();
const loading = ref(false);
const error = ref('');
const data = ref<ReportsDashboardDto | null>(null);
@@ -42,10 +52,27 @@ const from = ref('');
const to = ref('');
const showCustomDates = ref(false);
const drillOpen = ref(false);
const drillLoading = ref(false);
const drillError = ref('');
const drill = ref<ReportsDrilldownDto | null>(null);
const currency = computed(
() => data.value?.currency || org.value?.baseCurrency || 'IRR',
);
const funnelByPipeline = computed(() => {
const rows = data.value?.funnel ?? [];
const map = new Map<string, { name: string; stages: ReportsFunnelStageDto[] }>();
for (const row of rows) {
const key = row.pipelineId || row.pipelineName;
const cur = map.get(key) ?? { name: row.pipelineName, stages: [] };
cur.stages.push(row);
map.set(key, cur);
}
return [...map.values()];
});
function money(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return '—';
return formatMoney(n, currency.value);
@@ -66,6 +93,15 @@ function formatChange(p: number | null | undefined) {
return `${sign}${toFaDigits(p)}٪`;
}
function formatCompareValue(
value: number,
format: ReportsPeriodCompareRowDto['format'],
) {
if (format === 'money') return money(value);
if (format === 'percent') return `${toFaDigits(value)}٪`;
return num(value);
}
function rangeLabel() {
if (!from.value || !to.value) return '—';
try {
@@ -77,6 +113,47 @@ function rangeLabel() {
}
}
async function openDrill(kind: ReportsDrillKind, id?: string) {
drillOpen.value = true;
drillLoading.value = true;
drillError.value = '';
drill.value = null;
try {
const { data: res } = await api.get<ReportsDrilldownDto>(
'/reports/drilldown',
{
params: {
kind,
id: id || undefined,
from: from.value || undefined,
to: to.value || undefined,
},
},
);
drill.value = res;
} catch (e) {
drillError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری فهرست ناموفق بود.',
)
: 'بارگذاری فهرست ناموفق بود.';
} finally {
drillLoading.value = false;
}
}
function closeDrill() {
drillOpen.value = false;
drill.value = null;
drillError.value = '';
}
function openDeal(link: string) {
closeDrill();
void router.push(link);
}
function applyPeriod(key: PeriodKey) {
period.value = key;
showCustomDates.value = key === 'custom';
@@ -581,6 +658,89 @@ onMounted(() => {
</article>
</div>
<!-- Deep analytics (BI phase 1) -->
<div class="deep-grid">
<UiCard class="chart-card" title="تبدیل مراحل کاریز">
<p class="deep-hint">
سهم هر مرحله از معاملات باز و نرخ عبور نسبت به مرحله قبل. برای دیدن پرونده‌ها روی مرحله کلیک کنید.
</p>
<div v-if="funnelByPipeline.length" class="funnel-wrap">
<div
v-for="pipe in funnelByPipeline"
:key="pipe.name"
class="funnel-pipe"
>
<h4 class="funnel-pipe-title">{{ pipe.name }}</h4>
<button
v-for="s in pipe.stages"
:key="s.stageId"
type="button"
class="funnel-row"
@click="openDrill('stage_open', s.stageId)"
>
<div class="funnel-row-top">
<strong>{{ s.stageName }}</strong>
<span>{{ num(s.count) }} · {{ money(s.amount) }}</span>
</div>
<div class="funnel-bar-track">
<div
class="funnel-bar-fill"
:style="{
width: `${Math.max(s.shareOfPipelinePercent, s.count ? 4 : 0)}%`,
}"
/>
</div>
<div class="funnel-row-meta">
<small>سهم کاریز: {{ toFaDigits(s.shareOfPipelinePercent) }}٪</small>
<small v-if="s.conversionFromPrevPercent != null">
تبدیل از قبل: {{ toFaDigits(s.conversionFromPrevPercent) }}٪
</small>
<small v-else>شروع کاریز</small>
</div>
</button>
</div>
</div>
<p v-else class="empty-msg">معامله بازی در کاریز نیست.</p>
</UiCard>
<UiCard class="chart-card" title="مقایسه با دوره قبل">
<p class="deep-hint">
بازه فعلی در برابر دوره هم‌طول قبلی. ردیف‌های قابل‌کلیک فهرست معاملات را باز می‌کنند.
</p>
<table v-if="data.periodCompare?.length" class="compare-table">
<thead>
<tr>
<th>شاخص</th>
<th>فعلی</th>
<th>قبل</th>
<th>تغییر</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in data.periodCompare"
:key="row.key"
:class="{ clickable: Boolean(row.drillKind) }"
@click="row.drillKind && openDrill(row.drillKind)"
>
<td>{{ row.label }}</td>
<td>{{ formatCompareValue(row.current, row.format) }}</td>
<td>{{ formatCompareValue(row.previous, row.format) }}</td>
<td
:class="{
up: (row.changePercent ?? 0) > 0,
down: (row.changePercent ?? 0) < 0,
}"
>
{{ formatChange(row.changePercent) }}
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-msg">داده‌ای برای مقایسه نیست.</p>
</UiCard>
</div>
<div class="charts-grid">
<!-- Sales trend (wide) -->
<UiCard class="chart-card span-2" title="روند فروش">
@@ -597,8 +757,16 @@ onMounted(() => {
<UiChart
v-if="data.sources?.length"
:option="sourcesOption"
height="260px"
height="220px"
/>
<ul v-if="data.sources?.length" class="drill-links">
<li v-for="s in data.sources" :key="s.label">
<button type="button" @click="openDrill('source', s.label)">
{{ s.label }}
<span>{{ num(s.count) }}</span>
</button>
</li>
</ul>
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
</UiCard>
@@ -607,8 +775,16 @@ onMounted(() => {
<UiChart
v-if="data.lostReasons?.length"
:option="lostReasonsOption"
height="260px"
height="220px"
/>
<ul v-if="data.lostReasons?.length" class="drill-links">
<li v-for="s in data.lostReasons" :key="s.label">
<button type="button" @click="openDrill('lost_reason', s.label)">
{{ s.label }}
<span>{{ num(s.count) }}</span>
</button>
</li>
</ul>
<p v-else class="empty-msg">معامله شکست‌خورده‌ای در این بازه نیست.</p>
</UiCard>
@@ -664,14 +840,24 @@ onMounted(() => {
<!-- Team performance -->
<UiCard class="chart-card" title="عملکرد تیم">
<p class="deep-hint">برای دیدن معاملات برنده هر نفر کلیک کنید.</p>
<ul v-if="data.team?.length" class="team-list">
<li v-for="u in data.team.slice(0, 8)" :key="u.userId">
<span class="avatar">{{ u.fullName.slice(0, 1) }}</span>
<div class="team-meta">
<strong>{{ u.fullName }}</strong>
<small>{{ num(u.activitiesDone) }} فعالیت انجام‌شده</small>
</div>
<em class="team-won">{{ money(u.wonAmount) }}</em>
<button
type="button"
class="team-row-btn"
@click="openDrill('team_won', u.userId)"
>
<span class="avatar">{{ u.fullName.slice(0, 1) }}</span>
<div class="team-meta">
<strong>{{ u.fullName }}</strong>
<small>
{{ num(u.wonCount) }} معامله ·
{{ num(u.activitiesDone) }} فعالیت
</small>
</div>
<em class="team-won">{{ money(u.wonAmount) }}</em>
</button>
</li>
</ul>
<p v-else class="empty-msg">داده تیمی نیست.</p>
@@ -732,10 +918,244 @@ onMounted(() => {
</UiCard>
</div>
</template>
<UiModal
:open="drillOpen"
:title="drill?.title || 'فهرست پرونده‌ها'"
@close="closeDrill"
>
<div v-if="drillLoading" class="loading-wrap">
<div class="spinner"></div>
<span>در حال بارگذاری...</span>
</div>
<p v-else-if="drillError" class="error">{{ drillError }}</p>
<template v-else-if="drill">
<p class="deep-hint">
{{ toFaDigits(drill.total) }} مورد
<span v-if="drill.total > drill.items.length">
(نمایش {{ toFaDigits(drill.items.length) }} مورد اول)
</span>
</p>
<ul v-if="drill.items.length" class="drill-list">
<li v-for="item in drill.items" :key="item.id">
<button type="button" class="drill-item" @click="openDeal(item.link)">
<div>
<strong>{{ item.title }}</strong>
<small>
{{ item.statusLabel }}
<template v-if="item.stageName"> · {{ item.stageName }}</template>
<template v-if="item.ownerName"> · {{ item.ownerName }}</template>
</small>
</div>
<em>{{ formatMoney(item.amount, item.currency || currency) }}</em>
</button>
</li>
</ul>
<p v-else class="empty-msg">موردی یافت نشد.</p>
</template>
</UiModal>
</section>
</template>
<style scoped>
.deep-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.deep-hint {
margin: 0 0 var(--sp-3);
color: var(--muted);
font-size: var(--fs-xs);
line-height: 1.6;
}
.funnel-wrap {
display: grid;
gap: var(--sp-4);
}
.funnel-pipe-title {
margin: 0 0 var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
}
.funnel-row {
display: grid;
gap: 0.35rem;
width: 100%;
text-align: start;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: 0.65rem 0.75rem;
margin-bottom: 0.45rem;
cursor: pointer;
font: inherit;
color: inherit;
}
.funnel-row:hover {
border-color: var(--accent, #0f766e);
}
.funnel-row-top,
.funnel-row-meta {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.funnel-row-meta {
color: var(--muted);
font-size: var(--fs-xs);
}
.funnel-bar-track {
height: 6px;
border-radius: 999px;
background: var(--surface-subtle, #f1f5f9);
overflow: hidden;
}
.funnel-bar-fill {
height: 100%;
background: var(--accent, #0f766e);
border-radius: 999px;
}
.compare-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.compare-table th,
.compare-table td {
padding: 0.55rem 0.35rem;
border-bottom: 1px solid var(--line);
text-align: start;
}
.compare-table th {
color: var(--muted);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.compare-table tr.clickable {
cursor: pointer;
}
.compare-table tr.clickable:hover td {
background: var(--surface-subtle, #f8fafc);
}
.compare-table .up {
color: #0f766e;
font-weight: 600;
}
.compare-table .down {
color: #b91c1c;
font-weight: 600;
}
.drill-links {
list-style: none;
margin: var(--sp-3) 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.drill-links button {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.75rem;
border: 0;
background: var(--surface-subtle, #f8fafc);
border-radius: 8px;
padding: 0.45rem 0.6rem;
font: inherit;
cursor: pointer;
color: inherit;
}
.drill-links button:hover {
background: #eef2f7;
}
.team-row-btn {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: transparent;
padding: 0.35rem 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: start;
}
.team-row-btn:hover strong {
color: var(--accent-deep, #0f766e);
}
.drill-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.drill-item {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
padding: 0.7rem 0.8rem;
font: inherit;
cursor: pointer;
text-align: start;
color: inherit;
}
.drill-item:hover {
border-color: var(--accent, #0f766e);
}
.drill-item small {
display: block;
margin-top: 0.2rem;
color: var(--muted);
}
.drill-item em {
font-style: normal;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 900px) {
.deep-grid {
grid-template-columns: 1fr;
}
}
.filter-toolbar {
display: flex;
align-items: center;