diff --git a/apps/api/src/reports/dto.ts b/apps/api/src/reports/dto.ts index 92ffcf2..7c61140 100644 --- a/apps/api/src/reports/dto.ts +++ b/apps/api/src/reports/dto.ts @@ -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; +} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index 105040a..dbd4c95 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -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); } } diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 640003a..8cb96f6 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -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(); + 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(); - 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 { + 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 { 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 = { call: 'تماس', diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index c2517bc..5b4fc79 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -1,5 +1,6 @@