Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ReportsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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 { ReportsService } from './reports.service';
|
||||
|
||||
@Controller('reports')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Query() query: ReportsQueryDto,
|
||||
) {
|
||||
return this.reportsService.dashboard(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
{ from: query.from, to: query.to },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ReportsDashboardDto } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
elevated,
|
||||
ownerWhere,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async dashboard(
|
||||
actor: Actor,
|
||||
opts: { from?: string; to?: string },
|
||||
): Promise<ReportsDashboardDto> {
|
||||
const { start, end } = resolveRange(opts.from, opts.to);
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor);
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const ticketScope = ticketAccessWhere(actor);
|
||||
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
|
||||
const [deals, stages, activities, tickets, peopleCreated, users] =
|
||||
await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ createdAt: { gte: start, lte: end } },
|
||||
{
|
||||
status: { in: ['won', 'lost'] },
|
||||
updatedAt: { gte: start, lte: end },
|
||||
},
|
||||
{ status: 'open' },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
stageId: true,
|
||||
ownerId: true,
|
||||
updatedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.pipelineStage.findMany({
|
||||
where: { pipeline: { organizationId: orgId } },
|
||||
include: { pipeline: { select: { name: true } } },
|
||||
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
createdAt: { gte: start, lte: end },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
dueAt: true,
|
||||
assigneeId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.ticket.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...ticketScope,
|
||||
createdAt: { gte: start, lte: end },
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
slaDueAt: true,
|
||||
firstRespondedAt: true,
|
||||
csatScore: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
createdAt: { gte: start, lte: end },
|
||||
...(elevated(actor.role) ? {} : { ownerId: actor.userId }),
|
||||
},
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, fullName: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const openDeals = deals.filter((d) => d.status === 'open');
|
||||
const wonInPeriod = deals.filter(
|
||||
(d) =>
|
||||
d.status === 'won' &&
|
||||
d.updatedAt >= start &&
|
||||
d.updatedAt <= end,
|
||||
);
|
||||
const lostInPeriod = deals.filter(
|
||||
(d) =>
|
||||
d.status === 'lost' &&
|
||||
d.updatedAt >= start &&
|
||||
d.updatedAt <= end,
|
||||
);
|
||||
|
||||
const sumBase = (
|
||||
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
|
||||
) =>
|
||||
rows.reduce(
|
||||
(acc, row) => acc + decimalToBase(row.amount, row.currency, rates),
|
||||
0,
|
||||
);
|
||||
|
||||
const closedCount = wonInPeriod.length + lostInPeriod.length;
|
||||
const winRate = closedCount ? wonInPeriod.length / closedCount : 0;
|
||||
|
||||
const openByStage = new Map<string, { count: number; amount: number }>();
|
||||
for (const d of openDeals) {
|
||||
const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 };
|
||||
cur.count += 1;
|
||||
cur.amount += decimalToBase(d.amount, d.currency, rates);
|
||||
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 now = new Date();
|
||||
const activityOpen = activities.filter((a) => a.status === 'open').length;
|
||||
const activityDone = activities.filter((a) => a.status === 'done').length;
|
||||
const activityOverdue = activities.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) {
|
||||
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const teamMap = new Map<
|
||||
string,
|
||||
{ wonAmount: number; wonCount: number; activitiesDone: number }
|
||||
>();
|
||||
for (const u of users) {
|
||||
teamMap.set(u.id, { wonAmount: 0, wonCount: 0, activitiesDone: 0 });
|
||||
}
|
||||
for (const d of wonInPeriod) {
|
||||
if (!d.ownerId) continue;
|
||||
const t = teamMap.get(d.ownerId)!;
|
||||
t.wonCount += 1;
|
||||
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
|
||||
}
|
||||
for (const a of activities) {
|
||||
if (a.status !== 'done' || !a.assigneeId) continue;
|
||||
const t = teamMap.get(a.assigneeId);
|
||||
if (t) t.activitiesDone += 1;
|
||||
}
|
||||
|
||||
const ticketOpen = tickets.filter((t) =>
|
||||
['open', 'in_progress', 'waiting'].includes(t.status),
|
||||
).length;
|
||||
const ticketResolved = tickets.filter(
|
||||
(t) => t.status === 'resolved',
|
||||
).length;
|
||||
const ticketClosed = tickets.filter((t) => t.status === 'closed').length;
|
||||
const slaBreached = tickets.filter(
|
||||
(t) =>
|
||||
t.slaDueAt &&
|
||||
!t.firstRespondedAt &&
|
||||
['open', 'in_progress', 'waiting'].includes(t.status) &&
|
||||
t.slaDueAt.getTime() < now.getTime(),
|
||||
).length;
|
||||
const csatScores = tickets
|
||||
.map((t) => t.csatScore)
|
||||
.filter((s): s is number => s != null);
|
||||
const avgCsat = csatScores.length
|
||||
? csatScores.reduce((a, b) => a + b, 0) / csatScores.length
|
||||
: null;
|
||||
|
||||
return {
|
||||
from: start.toISOString(),
|
||||
to: end.toISOString(),
|
||||
currency: rates.baseCurrency,
|
||||
sales: {
|
||||
openCount: openDeals.length,
|
||||
wonCount: wonInPeriod.length,
|
||||
lostCount: lostInPeriod.length,
|
||||
openAmount: Math.round(sumBase(openDeals)),
|
||||
wonAmount: Math.round(sumBase(wonInPeriod)),
|
||||
lostAmount: Math.round(sumBase(lostInPeriod)),
|
||||
winRate: Math.round(winRate * 1000) / 10,
|
||||
},
|
||||
funnel,
|
||||
activities: {
|
||||
open: activityOpen,
|
||||
done: activityDone,
|
||||
overdue: activityOverdue,
|
||||
byType: [...byTypeMap.entries()].map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
})),
|
||||
},
|
||||
team: users
|
||||
.map((u) => {
|
||||
const t = teamMap.get(u.id)!;
|
||||
return {
|
||||
userId: u.id,
|
||||
fullName: u.fullName,
|
||||
wonAmount: Math.round(t.wonAmount),
|
||||
wonCount: t.wonCount,
|
||||
activitiesDone: t.activitiesDone,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(u) =>
|
||||
elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0,
|
||||
)
|
||||
.sort((a, b) => b.wonAmount - a.wonAmount),
|
||||
tickets: {
|
||||
open: ticketOpen,
|
||||
resolved: ticketResolved,
|
||||
closed: ticketClosed,
|
||||
slaBreached,
|
||||
avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10,
|
||||
csatCount: csatScores.length,
|
||||
},
|
||||
peopleCreated,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRange(from?: string, to?: string): { start: Date; end: Date } {
|
||||
const end = to ? endOfDay(parseDay(to)) : endOfDay(new Date());
|
||||
const start = from
|
||||
? startOfDay(parseDay(from))
|
||||
: startOfDay(new Date(end.getFullYear(), end.getMonth(), 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function parseDay(value: string): Date {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return new Date();
|
||||
return d;
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function endOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
Reference in New Issue
Block a user