Add deeper reports: stage conversion, period compare, and deal drill-down.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user