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