Add simple report builder with metrics, dimensions, and user/team filters.

This commit is contained in:
2026-09-27 11:54:23 +03:30
parent a16c2f0a9b
commit 55a287563d
5 changed files with 935 additions and 11 deletions
+29 -1
View File
@@ -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;
}
+16 -1
View File
@@ -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);
+360
View File
@@ -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: 'تماس',