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
+19 -1
View File
@@ -1,4 +1,5 @@
import { IsOptional, IsString } from 'class-validator';
import { IsIn, IsOptional, IsString } from 'class-validator';
import { REPORTS_DRILL_KINDS } from '@mento/shared';
export class ReportsQueryDto {
@IsOptional()
@@ -9,3 +10,20 @@ export class ReportsQueryDto {
@IsString()
to?: string;
}
export class ReportsDrilldownQueryDto {
@IsIn([...REPORTS_DRILL_KINDS])
kind!: (typeof REPORTS_DRILL_KINDS)[number];
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
+21 -22
View File
@@ -1,7 +1,8 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ReportsQueryDto } from './dto';
import type { Actor } from '../common/record-scope';
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
import { ReportsService } from './reports.service';
@Controller('reports')
@@ -10,30 +11,28 @@ export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get('dashboard')
dashboard(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ReportsQueryDto,
dashboard(@CurrentUser() user: Actor, @Query() query: ReportsQueryDto) {
return this.reportsService.dashboard(user, {
from: query.from,
to: query.to,
});
}
@Get('drilldown')
drilldown(
@CurrentUser() user: Actor,
@Query() query: ReportsDrilldownQueryDto,
) {
return this.reportsService.dashboard(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to },
);
return this.reportsService.drilldown(user, {
kind: query.kind,
id: query.id,
from: query.from,
to: query.to,
});
}
@Get('home')
home(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
) {
return this.reportsService.home({
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
});
home(@CurrentUser() user: Actor) {
return this.reportsService.home(user);
}
}
+353 -21
View File
@@ -1,5 +1,12 @@
import { Injectable } from '@nestjs/common';
import type { HomeDashboardDto, ReportsDashboardDto } from '@mento/shared';
import { Injectable, BadRequestException } from '@nestjs/common';
import type {
HomeDashboardDto,
ReportsDashboardDto,
ReportsDrillKind,
ReportsDrilldownDto,
ReportsFunnelStageDto,
ReportsPeriodCompareRowDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -18,6 +25,8 @@ import {
startOfJalaliMonth,
} from '../common/jalali';
const DRILL_LIMIT = 50;
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
@@ -92,14 +101,14 @@ export class ReportsService {
}),
this.prisma.pipelineStage.findMany({
where: { pipeline: { organizationId: orgId } },
include: { pipeline: { select: { name: true } } },
include: { pipeline: { select: { id: true, name: true } } },
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
}),
this.prisma.activity.findMany({
where: {
organizationId: orgId,
...activityScope,
createdAt: { gte: start, lte: end },
createdAt: { gte: prevStart, lte: end },
},
select: {
id: true,
@@ -107,6 +116,7 @@ export class ReportsService {
status: true,
dueAt: true,
assigneeId: true,
createdAt: true,
},
}),
this.prisma.ticket.findMany({
@@ -213,6 +223,19 @@ export class ReportsService {
d.updatedAt >= prevStart &&
d.updatedAt <= prevEnd,
);
const lostPrev = deals.filter(
(d) =>
d.status === 'lost' &&
d.updatedAt >= prevStart &&
d.updatedAt <= prevEnd,
);
const activitiesCur = activities.filter(
(a) => a.createdAt >= start && a.createdAt <= end,
);
const activitiesPrev = activities.filter(
(a) => a.createdAt >= prevStart && a.createdAt <= prevEnd,
);
const sumBase = (
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
@@ -547,27 +570,58 @@ export class ReportsService {
openByStage.set(d.stageId, cur);
}
const funnel = stages.map((s) => {
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
return {
stageId: s.id,
stageName: s.name,
pipelineName: s.pipeline.name,
count: agg.count,
amount: Math.round(agg.amount),
};
});
const funnel: ReportsFunnelStageDto[] = [];
const stagesByPipeline = new Map<string, typeof stages>();
for (const s of stages) {
const list = stagesByPipeline.get(s.pipelineId) ?? [];
list.push(s);
stagesByPipeline.set(s.pipelineId, list);
}
for (const pipelineStages of stagesByPipeline.values()) {
const pipelineTotal =
pipelineStages.reduce(
(acc, s) => acc + (openByStage.get(s.id)?.count ?? 0),
0,
) || 1;
let prevCount: number | null = null;
for (const s of pipelineStages) {
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
const conversionFromPrevPercent =
prevCount == null
? null
: prevCount === 0
? agg.count > 0
? 100
: 0
: Math.round((agg.count / prevCount) * 1000) / 10;
funnel.push({
stageId: s.id,
stageName: s.name,
pipelineId: s.pipelineId,
pipelineName: s.pipeline.name,
count: agg.count,
amount: Math.round(agg.amount),
conversionFromPrevPercent,
shareOfPipelinePercent:
Math.round((agg.count / pipelineTotal) * 1000) / 10,
});
prevCount = agg.count;
}
}
const activityOpen = activities.filter((a) => a.status === 'open').length;
const activityDone = activities.filter((a) => a.status === 'done').length;
const activityOverdue = activities.filter(
const activityOpen = activitiesCur.filter((a) => a.status === 'open').length;
const activityDone = activitiesCur.filter((a) => a.status === 'done').length;
const activityDonePrev = activitiesPrev.filter(
(a) => a.status === 'done',
).length;
const activityOverdue = activitiesCur.filter(
(a) =>
a.status === 'open' &&
a.dueAt != null &&
a.dueAt.getTime() < now.getTime(),
).length;
const byTypeMap = new Map<string, number>();
for (const a of activities) {
for (const a of activitiesCur) {
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
}
@@ -584,12 +638,71 @@ export class ReportsService {
t.wonCount += 1;
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
}
for (const a of activities) {
for (const a of activitiesCur) {
if (a.status !== 'done' || !a.assigneeId) continue;
const t = teamMap.get(a.assigneeId);
if (t) t.activitiesDone += 1;
}
const closedPrev = wonPrev.length + lostPrev.length;
const winRatePrev = closedPrev ? wonPrev.length / closedPrev : 0;
const winRatePct = Math.round(winRate * 1000) / 10;
const winRatePrevPct = Math.round(winRatePrev * 1000) / 10;
const periodCompare: ReportsPeriodCompareRowDto[] = [
{
key: 'sales_amount',
label: 'مبلغ فروش',
format: 'money',
current: salesAmount,
previous: salesAmountPrev,
changePercent: pctChange(salesAmount, salesAmountPrev),
drillKind: 'period_won',
},
{
key: 'won_count',
label: 'معاملات برنده',
format: 'number',
current: wonInPeriod.length,
previous: wonPrev.length,
changePercent: pctChange(wonInPeriod.length, wonPrev.length),
drillKind: 'period_won',
},
{
key: 'lost_count',
label: 'معاملات باخته',
format: 'number',
current: lostInPeriod.length,
previous: lostPrev.length,
changePercent: pctChange(lostInPeriod.length, lostPrev.length),
drillKind: 'period_lost',
},
{
key: 'win_rate',
label: 'نرخ برد',
format: 'percent',
current: winRatePct,
previous: winRatePrevPct,
changePercent: pctChange(winRatePct, winRatePrevPct),
},
{
key: 'new_customers',
label: 'مشتریان جدید',
format: 'number',
current: peopleCreated,
previous: peopleCreatedPrev,
changePercent: pctChange(peopleCreated, peopleCreatedPrev),
},
{
key: 'activities_done',
label: 'فعالیت انجام‌شده',
format: 'number',
current: activityDone,
previous: activityDonePrev,
changePercent: pctChange(activityDone, activityDonePrev),
},
];
const ticketOpen = tickets.filter((t) =>
['open', 'in_progress', 'waiting'].includes(t.status),
).length;
@@ -645,7 +758,7 @@ export class ReportsService {
}
if (wonInPeriod.length + lostInPeriod.length > 0) {
insights.push(
`نرخ برد معاملات در این دوره ${Math.round(winRate * 1000) / 10}٪ بوده است.`,
`نرخ برد معاملات در این دوره ${winRatePct}٪ بوده است.`,
);
}
if (invoiceCount > 0) {
@@ -712,9 +825,10 @@ export class ReportsService {
openAmount: Math.round(sumBase(openDeals)),
wonAmount,
lostAmount: Math.round(sumBase(lostInPeriod)),
winRate: Math.round(winRate * 1000) / 10,
winRate: winRatePct,
},
funnel,
periodCompare,
tickets: {
open: ticketOpen,
resolved: ticketResolved,
@@ -729,6 +843,179 @@ export class ReportsService {
};
}
async drilldown(
actor: Actor,
opts: {
kind: ReportsDrillKind;
id?: string;
from?: string;
to?: string;
},
): Promise<ReportsDrilldownDto> {
const { start, end } = resolveRange(opts.from, opts.to);
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const baseSelect = {
id: true,
title: true,
status: true,
amount: true,
currency: true,
source: true,
lostReason: true,
updatedAt: true,
owner: { select: { fullName: true } },
stage: { select: { name: true } },
} as const;
let where: Prisma.DealWhereInput = {
organizationId: orgId,
...dealScope,
};
let title = 'فهرست معاملات';
switch (opts.kind) {
case 'stage_open': {
if (!opts.id?.trim()) {
throw new BadRequestException('شناسه مرحله الزامی است');
}
const stage = await this.prisma.pipelineStage.findFirst({
where: {
id: opts.id.trim(),
pipeline: { organizationId: orgId },
},
select: { name: true, pipeline: { select: { name: true } } },
});
if (!stage) throw new BadRequestException('مرحله پیدا نشد');
where = {
...where,
status: 'open',
stageId: opts.id.trim(),
};
title = `معاملات باز — ${stage.pipeline.name} / ${stage.name}`;
break;
}
case 'team_won': {
if (!opts.id?.trim()) {
throw new BadRequestException('شناسه کاربر الزامی است');
}
const user = await this.prisma.user.findFirst({
where: {
id: opts.id.trim(),
memberships: { some: { organizationId: orgId } },
},
select: { fullName: true },
});
if (!user) throw new BadRequestException('کاربر پیدا نشد');
where = {
...where,
status: 'won',
ownerId: opts.id.trim(),
updatedAt: { gte: start, lte: end },
};
title = `معاملات برنده — ${user.fullName}`;
break;
}
case 'period_won': {
where = {
...where,
status: 'won',
updatedAt: { gte: start, lte: end },
};
title = 'معاملات برنده این دوره';
break;
}
case 'period_lost': {
where = {
...where,
status: 'lost',
updatedAt: { gte: start, lte: end },
};
title = 'معاملات باخته این دوره';
break;
}
case 'source': {
const raw = (opts.id ?? '').trim();
if (!raw) throw new BadRequestException('منبع الزامی است');
const all = await this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
status: 'won',
updatedAt: { gte: start, lte: end },
},
select: baseSelect,
orderBy: { updatedAt: 'desc' },
take: 500,
});
const matched = all.filter(
(d) => normalizeSource(d.source) === raw,
);
return {
kind: opts.kind,
title: `معاملات برنده — منبع «${raw}»`,
from: start.toISOString(),
to: end.toISOString(),
total: matched.length,
items: matched.slice(0, DRILL_LIMIT).map((d) =>
toDrillItem(d, rates.baseCurrency),
),
};
}
case 'lost_reason': {
const raw = (opts.id ?? '').trim();
if (!raw) throw new BadRequestException('دلیل شکست الزامی است');
const all = await this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
status: 'lost',
updatedAt: { gte: start, lte: end },
},
select: baseSelect,
orderBy: { updatedAt: 'desc' },
take: 500,
});
const matched = all.filter(
(d) => normalizeLostReason(d.lostReason) === raw,
);
return {
kind: opts.kind,
title: `معاملات باخته — «${raw}»`,
from: start.toISOString(),
to: end.toISOString(),
total: matched.length,
items: matched.slice(0, DRILL_LIMIT).map((d) =>
toDrillItem(d, rates.baseCurrency),
),
};
}
default:
throw new BadRequestException('نوع گزارش نامعتبر است');
}
const [total, rows] = await Promise.all([
this.prisma.deal.count({ where }),
this.prisma.deal.findMany({
where,
select: baseSelect,
orderBy: { updatedAt: 'desc' },
take: DRILL_LIMIT,
}),
]);
return {
kind: opts.kind,
title,
from: start.toISOString(),
to: end.toISOString(),
total,
items: rows.map((d) => toDrillItem(d, rates.baseCurrency)),
};
}
async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
@@ -1086,6 +1373,51 @@ function normalizeLostReason(raw: string | null | undefined): string {
return value;
}
function dealStatusLabel(status: string): string {
if (status === 'won') return 'برنده';
if (status === 'lost') return 'باخته';
if (status === 'open') return 'باز';
return status;
}
function toDrillItem(
d: {
id: string;
title: string;
status: string;
amount: Prisma.Decimal | null;
currency: string;
updatedAt: Date;
owner: { fullName: string } | null;
stage: { name: string } | null;
},
_baseCurrency: string,
): {
id: string;
title: string;
status: string;
statusLabel: string;
amount: number;
currency: string;
ownerName: string | null;
stageName: string | null;
updatedAt: string;
link: string;
} {
return {
id: d.id,
title: d.title,
status: d.status,
statusLabel: dealStatusLabel(d.status),
amount: d.amount == null ? 0 : Math.round(Number(d.amount)),
currency: d.currency,
ownerName: d.owner?.fullName ?? null,
stageName: d.stage?.name ?? null,
updatedAt: d.updatedAt.toISOString(),
link: `/deals/${d.id}`,
};
}
function activityTypeLabel(type: string): string {
const map: Record<string, string> = {
call: 'تماس',
+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;
+67 -7
View File
@@ -2077,6 +2077,70 @@ export interface ReportsTeamRowDto {
activitiesDone: number;
}
/** گونه‌های drill-down گزارش عمیق (فاز ۱ BI) */
export type ReportsDrillKind =
| "stage_open"
| "team_won"
| "period_won"
| "period_lost"
| "source"
| "lost_reason";
export const REPORTS_DRILL_KINDS: ReportsDrillKind[] = [
"stage_open",
"team_won",
"period_won",
"period_lost",
"source",
"lost_reason",
];
export interface ReportsFunnelStageDto {
stageId: string;
stageName: string;
pipelineId: string;
pipelineName: string;
count: number;
amount: number;
/** درصد تبدیل نسبت به مرحله قبل همان کاریز؛ null برای اولین مرحله */
conversionFromPrevPercent: number | null;
/** سهم از کل معاملات باز همان کاریز */
shareOfPipelinePercent: number;
}
export interface ReportsPeriodCompareRowDto {
key: string;
label: string;
format: "money" | "number" | "percent";
current: number;
previous: number;
changePercent: number | null;
/** اگر قابل کلیک برای لیست پرونده باشد */
drillKind?: ReportsDrillKind;
}
export interface ReportsDrillItemDto {
id: string;
title: string;
status: string;
statusLabel: string;
amount: number;
currency: string;
ownerName: string | null;
stageName: string | null;
updatedAt: string;
link: string;
}
export interface ReportsDrilldownDto {
kind: ReportsDrillKind;
title: string;
from: string;
to: string;
total: number;
items: ReportsDrillItemDto[];
}
export interface ReportsDashboardDto {
from: string;
to: string;
@@ -2107,13 +2171,9 @@ export interface ReportsDashboardDto {
lostAmount: number;
winRate: number;
};
funnel: Array<{
stageId: string;
stageName: string;
pipelineName: string;
count: number;
amount: number;
}>;
funnel: ReportsFunnelStageDto[];
/** مقایسه صریح بازه فعلی با دوره قبل هم‌طول */
periodCompare: ReportsPeriodCompareRowDto[];
tickets: {
open: number;
resolved: number;