Add simple report builder with metrics, dimensions, and user/team filters.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { REPORTS_DRILL_KINDS } from '@mento/shared';
|
||||
import {
|
||||
REPORTS_BUILDER_DIMENSIONS,
|
||||
REPORTS_BUILDER_METRICS,
|
||||
REPORTS_DRILL_KINDS,
|
||||
} from '@mento/shared';
|
||||
|
||||
export class ReportsQueryDto {
|
||||
@IsOptional()
|
||||
@@ -27,3 +31,27 @@ export class ReportsDrilldownQueryDto {
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class ReportsBuilderQueryDto {
|
||||
@IsIn([...REPORTS_BUILDER_METRICS])
|
||||
metric!: (typeof REPORTS_BUILDER_METRICS)[number];
|
||||
|
||||
@IsIn([...REPORTS_BUILDER_DIMENSIONS])
|
||||
dimension!: (typeof REPORTS_BUILDER_DIMENSIONS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamId?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { SavedViewsService } from '../saved-views/saved-views.service';
|
||||
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
|
||||
import { ReportsBuilderQueryDto, ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
class CreateReportViewDto {
|
||||
@@ -80,6 +80,21 @@ export class ReportsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('builder')
|
||||
builder(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: ReportsBuilderQueryDto,
|
||||
) {
|
||||
return this.reportsService.builder(user, {
|
||||
metric: dto.metric,
|
||||
dimension: dto.dimension,
|
||||
from: dto.from,
|
||||
to: dto.to,
|
||||
userId: dto.userId,
|
||||
teamId: dto.teamId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('home')
|
||||
home(@CurrentUser() user: Actor) {
|
||||
return this.reportsService.home(user);
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import type {
|
||||
HomeDashboardDto,
|
||||
ReportsBuilderDimension,
|
||||
ReportsBuilderMetric,
|
||||
ReportsBuilderQuery,
|
||||
ReportsBuilderResultDto,
|
||||
ReportsBuilderRowDto,
|
||||
ReportsDashboardDto,
|
||||
ReportsDrillKind,
|
||||
ReportsDrilldownDto,
|
||||
ReportsFunnelStageDto,
|
||||
ReportsPeriodCompareRowDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
REPORTS_BUILDER_DIMENSION_LABELS,
|
||||
REPORTS_BUILDER_METRIC_LABELS,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -1016,6 +1025,332 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async builder(
|
||||
actor: Actor,
|
||||
query: ReportsBuilderQuery,
|
||||
): Promise<ReportsBuilderResultDto> {
|
||||
const { start, end } = resolveRange(query.from, query.to);
|
||||
const orgId = actor.organizationId;
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
const metric = query.metric;
|
||||
let dimension = query.dimension;
|
||||
|
||||
const dealMetrics = new Set<ReportsBuilderMetric>([
|
||||
'won_amount',
|
||||
'won_count',
|
||||
'lost_count',
|
||||
'open_count',
|
||||
'open_amount',
|
||||
]);
|
||||
const invoiceMetrics = new Set<ReportsBuilderMetric>([
|
||||
'invoice_amount',
|
||||
'invoice_count',
|
||||
]);
|
||||
|
||||
if (
|
||||
!dealMetrics.has(metric) &&
|
||||
(dimension === 'source' ||
|
||||
dimension === 'stage' ||
|
||||
dimension === 'status')
|
||||
) {
|
||||
dimension = 'none';
|
||||
}
|
||||
|
||||
const format: 'money' | 'number' =
|
||||
metric === 'won_amount' ||
|
||||
metric === 'open_amount' ||
|
||||
metric === 'invoice_amount'
|
||||
? 'money'
|
||||
: 'number';
|
||||
|
||||
const ownerFilterIds = await this.resolveBuilderOwnerIds(actor, {
|
||||
userId: query.userId,
|
||||
teamId: query.teamId,
|
||||
});
|
||||
if (ownerFilterIds && ownerFilterIds.length === 0) {
|
||||
return emptyBuilderResult(metric, dimension, start, end, rates.baseCurrency, format);
|
||||
}
|
||||
|
||||
const dealOwnerWhere =
|
||||
ownerFilterIds == null
|
||||
? ownerWhere(actor)
|
||||
: { ownerId: { in: ownerFilterIds } };
|
||||
const activityAssigneeWhere =
|
||||
ownerFilterIds == null
|
||||
? assigneeWhere(actor)
|
||||
: { assigneeId: { in: ownerFilterIds } };
|
||||
const personOwnerWhere =
|
||||
ownerFilterIds == null
|
||||
? elevated(actor.role)
|
||||
? {}
|
||||
: ownerWhere(actor)
|
||||
: { ownerId: { in: ownerFilterIds } };
|
||||
|
||||
const buckets = new Map<string, { label: string; value: number }>();
|
||||
const add = (key: string, label: string, value: number) => {
|
||||
const cur = buckets.get(key) ?? { label, value: 0 };
|
||||
cur.value += value;
|
||||
buckets.set(key, cur);
|
||||
};
|
||||
|
||||
const userName = async (userId: string | null | undefined) => {
|
||||
if (!userId) return 'بدون مسئول';
|
||||
const u = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
return u?.fullName?.trim() || 'بدون مسئول';
|
||||
};
|
||||
|
||||
let teamByUser = new Map<string, { id: string; name: string }>();
|
||||
if (dimension === 'team' || query.teamId) {
|
||||
teamByUser = await this.loadUserTeams(orgId);
|
||||
}
|
||||
|
||||
const dimKey = async (opts: {
|
||||
ownerId?: string | null;
|
||||
source?: string | null;
|
||||
stageId?: string | null;
|
||||
stageName?: string | null;
|
||||
status?: string | null;
|
||||
at: Date;
|
||||
}): Promise<{ key: string; label: string }> => {
|
||||
if (dimension === 'none') return { key: 'total', label: 'جمع کل' };
|
||||
if (dimension === 'owner') {
|
||||
const id = opts.ownerId || 'none';
|
||||
return { key: id, label: await userName(opts.ownerId) };
|
||||
}
|
||||
if (dimension === 'team') {
|
||||
const t = opts.ownerId ? teamByUser.get(opts.ownerId) : undefined;
|
||||
return t
|
||||
? { key: t.id, label: t.name }
|
||||
: { key: 'none', label: 'بدون تیم' };
|
||||
}
|
||||
if (dimension === 'source') {
|
||||
const label = normalizeSource(opts.source);
|
||||
return { key: label, label };
|
||||
}
|
||||
if (dimension === 'stage') {
|
||||
const id = opts.stageId || 'none';
|
||||
return { key: id, label: opts.stageName || 'بدون مرحله' };
|
||||
}
|
||||
if (dimension === 'status') {
|
||||
const st = opts.status || 'open';
|
||||
return { key: st, label: dealStatusLabel(st) };
|
||||
}
|
||||
// month
|
||||
const j = dateToJalali(opts.at);
|
||||
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
|
||||
return { key, label: `${jalaliMonthName(j.jm)} ${j.jy}` };
|
||||
};
|
||||
|
||||
if (dealMetrics.has(metric)) {
|
||||
let statusFilter: 'won' | 'lost' | 'open';
|
||||
if (metric === 'won_amount' || metric === 'won_count') statusFilter = 'won';
|
||||
else if (metric === 'lost_count') statusFilter = 'lost';
|
||||
else statusFilter = 'open';
|
||||
|
||||
const deals = await this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...dealOwnerWhere,
|
||||
status: statusFilter,
|
||||
...(statusFilter === 'open'
|
||||
? { createdAt: { lte: end } }
|
||||
: { updatedAt: { gte: start, lte: end } }),
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
ownerId: true,
|
||||
source: true,
|
||||
status: true,
|
||||
stageId: true,
|
||||
stage: { select: { name: true } },
|
||||
updatedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const d of deals) {
|
||||
const at = statusFilter === 'open' ? d.createdAt : d.updatedAt;
|
||||
const dim = await dimKey({
|
||||
ownerId: d.ownerId,
|
||||
source: d.source,
|
||||
stageId: d.stageId,
|
||||
stageName: d.stage?.name,
|
||||
status: d.status,
|
||||
at,
|
||||
});
|
||||
const amount = decimalToBase(d.amount, d.currency, rates);
|
||||
const value =
|
||||
metric === 'won_amount' || metric === 'open_amount' ? amount : 1;
|
||||
add(dim.key, dim.label, value);
|
||||
}
|
||||
} else if (metric === 'activities_done') {
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityAssigneeWhere,
|
||||
status: 'done',
|
||||
createdAt: { gte: start, lte: end },
|
||||
},
|
||||
select: { assigneeId: true, createdAt: true },
|
||||
});
|
||||
for (const a of rows) {
|
||||
const dim = await dimKey({
|
||||
ownerId: a.assigneeId,
|
||||
at: a.createdAt,
|
||||
});
|
||||
add(dim.key, dim.label, 1);
|
||||
}
|
||||
} else if (metric === 'people_created') {
|
||||
const rows = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personOwnerWhere,
|
||||
createdAt: { gte: start, lte: end },
|
||||
},
|
||||
select: { ownerId: true, createdAt: true },
|
||||
});
|
||||
for (const p of rows) {
|
||||
const dim = await dimKey({
|
||||
ownerId: p.ownerId,
|
||||
at: p.createdAt,
|
||||
});
|
||||
add(dim.key, dim.label, 1);
|
||||
}
|
||||
} else if (invoiceMetrics.has(metric)) {
|
||||
const invoices = await this.prisma.invoice.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...dealOwnerWhere,
|
||||
status: { in: ['issued', 'partially_paid', 'paid'] },
|
||||
OR: [
|
||||
{ issuedAt: { gte: start, lte: end } },
|
||||
{ issuedAt: null, createdAt: { gte: start, lte: end } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
ownerId: true,
|
||||
issuedAt: true,
|
||||
createdAt: true,
|
||||
lineItems: {
|
||||
select: { quantity: true, unitPrice: true, currency: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const inv of invoices) {
|
||||
const at = inv.issuedAt ?? inv.createdAt;
|
||||
const dim = await dimKey({ ownerId: inv.ownerId, at });
|
||||
if (metric === 'invoice_count') {
|
||||
add(dim.key, dim.label, 1);
|
||||
} else {
|
||||
let amount = 0;
|
||||
for (const line of inv.lineItems) {
|
||||
amount += toBaseAmount(
|
||||
Number(line.quantity) * Number(line.unitPrice),
|
||||
line.currency,
|
||||
rates,
|
||||
);
|
||||
}
|
||||
add(dim.key, dim.label, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ReportsBuilderRowDto[] = [...buckets.entries()]
|
||||
.map(([key, row]) => ({
|
||||
key,
|
||||
label: row.label,
|
||||
value: Math.round(row.value * (format === 'money' ? 1 : 1)),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (format === 'money') {
|
||||
for (const r of rows) r.value = Math.round(r.value);
|
||||
}
|
||||
|
||||
const total = rows.reduce((a, b) => a + b.value, 0);
|
||||
|
||||
return {
|
||||
metric,
|
||||
metricLabel: REPORTS_BUILDER_METRIC_LABELS[metric],
|
||||
dimension,
|
||||
dimensionLabel: REPORTS_BUILDER_DIMENSION_LABELS[dimension],
|
||||
from: start.toISOString(),
|
||||
to: end.toISOString(),
|
||||
currency: rates.baseCurrency,
|
||||
format,
|
||||
total: Math.round(total),
|
||||
rows: dimension === 'none' && rows.length === 0
|
||||
? [{ key: 'total', label: 'جمع کل', value: 0 }]
|
||||
: dimension === 'none'
|
||||
? [{ key: 'total', label: 'جمع کل', value: Math.round(total) }]
|
||||
: rows,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveBuilderOwnerIds(
|
||||
actor: Actor,
|
||||
opts: { userId?: string; teamId?: string },
|
||||
): Promise<string[] | null> {
|
||||
const scope = ownerWhere(actor);
|
||||
let allowed: string[] | null = null;
|
||||
if ('ownerId' in scope && typeof scope.ownerId === 'string') {
|
||||
allowed = [scope.ownerId];
|
||||
} else if (
|
||||
'ownerId' in scope &&
|
||||
scope.ownerId &&
|
||||
typeof scope.ownerId === 'object' &&
|
||||
'in' in scope.ownerId
|
||||
) {
|
||||
allowed = [...scope.ownerId.in];
|
||||
}
|
||||
|
||||
let ids: string[] | null = null;
|
||||
|
||||
if (opts.teamId?.trim()) {
|
||||
const members = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: opts.teamId.trim(),
|
||||
team: { organizationId: actor.organizationId },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
ids = members.map((m) => m.userId);
|
||||
}
|
||||
|
||||
if (opts.userId?.trim()) {
|
||||
const uid = opts.userId.trim();
|
||||
ids = ids == null ? [uid] : ids.filter((id) => id === uid);
|
||||
}
|
||||
|
||||
if (ids == null) return allowed;
|
||||
if (allowed == null) return [...new Set(ids)];
|
||||
return ids.filter((id) => allowed.includes(id));
|
||||
}
|
||||
|
||||
private async loadUserTeams(
|
||||
organizationId: string,
|
||||
): Promise<Map<string, { id: string; name: string }>> {
|
||||
const members = await this.prisma.teamMember.findMany({
|
||||
where: { team: { organizationId } },
|
||||
select: {
|
||||
userId: true,
|
||||
team: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { team: { name: 'asc' } },
|
||||
});
|
||||
const map = new Map<string, { id: string; name: string }>();
|
||||
for (const m of members) {
|
||||
if (!map.has(m.userId)) {
|
||||
map.set(m.userId, { id: m.team.id, name: m.team.name });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async home(actor: Actor): Promise<HomeDashboardDto> {
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor);
|
||||
@@ -1418,6 +1753,31 @@ function toDrillItem(
|
||||
};
|
||||
}
|
||||
|
||||
function emptyBuilderResult(
|
||||
metric: ReportsBuilderMetric,
|
||||
dimension: ReportsBuilderDimension,
|
||||
start: Date,
|
||||
end: Date,
|
||||
currency: string,
|
||||
format: 'money' | 'number',
|
||||
): ReportsBuilderResultDto {
|
||||
return {
|
||||
metric,
|
||||
metricLabel: REPORTS_BUILDER_METRIC_LABELS[metric],
|
||||
dimension,
|
||||
dimensionLabel: REPORTS_BUILDER_DIMENSION_LABELS[dimension],
|
||||
from: start.toISOString(),
|
||||
to: end.toISOString(),
|
||||
currency,
|
||||
format,
|
||||
total: 0,
|
||||
rows:
|
||||
dimension === 'none'
|
||||
? [{ key: 'total', label: 'جمع کل', value: 0 }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function activityTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
|
||||
Reference in New Issue
Block a user