Redesign home dashboard and app shell to match Mento mockup.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,4 +24,16 @@ export class ReportsController {
|
||||
{ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ReportsDashboardDto } from '@mento/shared';
|
||||
import type { HomeDashboardDto, ReportsDashboardDto } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
endOfJalaliMonth,
|
||||
jalaliMonthName,
|
||||
startOfJalaliMonth,
|
||||
} from '../common/jalali';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
@@ -246,6 +253,303 @@ export class ReportsService {
|
||||
peopleCreated,
|
||||
};
|
||||
}
|
||||
|
||||
async home(actor: Actor): Promise<HomeDashboardDto> {
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor);
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
const now = new Date();
|
||||
const { jy, jm } = dateToJalali(now);
|
||||
const curMonthStart = startOfJalaliMonth(jy, jm);
|
||||
const curMonthEnd = endOfJalaliMonth(jy, jm);
|
||||
const prev = addJalaliMonths(jy, jm, -1);
|
||||
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
|
||||
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
|
||||
|
||||
const todayStart = startOfDay(now);
|
||||
const todayEnd = endOfDay(now);
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStart = startOfDay(yesterday);
|
||||
const yesterdayEnd = endOfDay(yesterday);
|
||||
const weekAgo = new Date(now);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const [
|
||||
wonDeals,
|
||||
openDeals,
|
||||
staleDeals,
|
||||
peopleCur,
|
||||
peoplePrev,
|
||||
openActivitiesToday,
|
||||
openActivitiesYesterday,
|
||||
openDealsWeekAgo,
|
||||
goals,
|
||||
overdueCount,
|
||||
] = await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
status: 'won',
|
||||
...dealScope,
|
||||
updatedAt: { gte: addMonthsStart(now, -5) },
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
updatedAt: true,
|
||||
source: true,
|
||||
companyId: true,
|
||||
personId: true,
|
||||
company: { select: { id: true, name: true } },
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({
|
||||
where: { organizationId: orgId, status: 'open', ...dealScope },
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ isStale: true },
|
||||
{ isRotten: true },
|
||||
{ updatedAt: { lt: daysAgo(5) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
company: { select: { name: true } },
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 5,
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
createdAt: { gte: curMonthStart, lte: curMonthEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
status: 'open',
|
||||
dueAt: { gte: todayStart, lte: todayEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
updatedAt: { lte: weekAgo },
|
||||
},
|
||||
}),
|
||||
this.prisma.salesGoal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
periodStart: { lte: curMonthEnd },
|
||||
periodEnd: { gte: curMonthStart },
|
||||
},
|
||||
select: { targetAmount: true },
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
status: 'open',
|
||||
dueAt: { lt: now },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const sumWonInRange = (start: Date, end: Date) =>
|
||||
wonDeals
|
||||
.filter((d) => d.updatedAt >= start && d.updatedAt <= end)
|
||||
.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
);
|
||||
|
||||
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
|
||||
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
|
||||
const targetAmount = goals.reduce(
|
||||
(acc, g) => acc + Number(g.targetAmount),
|
||||
0,
|
||||
);
|
||||
|
||||
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
|
||||
[];
|
||||
for (let i = 4; i >= 0; i -= 1) {
|
||||
const m = addJalaliMonths(jy, jm, -i);
|
||||
trendMonths.push({
|
||||
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
|
||||
label: jalaliMonthName(m.jm),
|
||||
start: startOfJalaliMonth(m.jy, m.jm),
|
||||
end: endOfJalaliMonth(m.jy, m.jm),
|
||||
});
|
||||
}
|
||||
|
||||
const salesTrend = trendMonths.map((m) => ({
|
||||
monthKey: m.key,
|
||||
monthLabel: m.label,
|
||||
amount: Math.round(sumWonInRange(m.start, m.end)),
|
||||
}));
|
||||
|
||||
const sourceMap = new Map<string, number>();
|
||||
for (const d of wonDeals) {
|
||||
const label = normalizeSource(d.source);
|
||||
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
|
||||
}
|
||||
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
|
||||
const sources = [...sourceMap.entries()]
|
||||
.map(([label, count]) => ({
|
||||
label,
|
||||
count,
|
||||
percent: Math.round((count / sourceTotal) * 1000) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
const customerMap = new Map<
|
||||
string,
|
||||
{ name: string; dealCount: number }
|
||||
>();
|
||||
for (const d of wonDeals) {
|
||||
if (d.company) {
|
||||
const cur = customerMap.get(`c:${d.company.id}`) ?? {
|
||||
name: d.company.name,
|
||||
dealCount: 0,
|
||||
};
|
||||
cur.dealCount += 1;
|
||||
customerMap.set(`c:${d.company.id}`, cur);
|
||||
} else if (d.person) {
|
||||
const name = `${d.person.firstName} ${d.person.lastName}`.trim();
|
||||
const cur = customerMap.get(`p:${d.person.id}`) ?? {
|
||||
name,
|
||||
dealCount: 0,
|
||||
};
|
||||
cur.dealCount += 1;
|
||||
customerMap.set(`p:${d.person.id}`, cur);
|
||||
}
|
||||
}
|
||||
const topCustomers = [...customerMap.entries()]
|
||||
.map(([id, row]) => ({
|
||||
id: id.slice(2),
|
||||
name: row.name,
|
||||
dealCount: row.dealCount,
|
||||
rank: 0,
|
||||
}))
|
||||
.sort((a, b) => b.dealCount - a.dealCount)
|
||||
.slice(0, 5)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }));
|
||||
|
||||
const alerts: HomeDashboardDto['alerts'] = [];
|
||||
if (staleDeals.length) {
|
||||
const first = staleDeals[0]!;
|
||||
const who =
|
||||
first.company?.name ??
|
||||
(first.person
|
||||
? `${first.person.firstName} ${first.person.lastName}`.trim()
|
||||
: first.title);
|
||||
alerts.push({
|
||||
key: 'stale-deal',
|
||||
tone: 'urgent',
|
||||
text: `${who} در مرحله تصمیمگیری است — امروز تماس بگیرید`,
|
||||
link: '/pipeline',
|
||||
});
|
||||
}
|
||||
if (staleDeals.length > 1) {
|
||||
alerts.push({
|
||||
key: 'stale-count',
|
||||
tone: 'warning',
|
||||
text: `${staleDeals.length} معامله بیش از ۵ روز پیگیری نشده`,
|
||||
link: '/pipeline',
|
||||
});
|
||||
} else if (overdueCount > 0) {
|
||||
alerts.push({
|
||||
key: 'overdue',
|
||||
tone: 'warning',
|
||||
text: `${overdueCount} پیگیری عقبافتاده دارید`,
|
||||
link: '/activities',
|
||||
});
|
||||
}
|
||||
if (targetAmount > 0 && salesCur >= targetAmount) {
|
||||
alerts.push({
|
||||
key: 'target-ahead',
|
||||
tone: 'success',
|
||||
text: 'این ماه از تارگت جلوتر هستید',
|
||||
link: '/sales/forecast',
|
||||
});
|
||||
} else if (targetAmount > 0 && salesCur >= targetAmount * 0.8) {
|
||||
alerts.push({
|
||||
key: 'target-close',
|
||||
tone: 'info',
|
||||
text: 'به تارگت ماه نزدیک شدهاید',
|
||||
link: '/sales/forecast',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currency: rates.baseCurrency,
|
||||
kpis: [
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'فروش این ماه',
|
||||
value: Math.round(salesCur),
|
||||
changePercent: pctChange(salesCur, salesPrev),
|
||||
changeLabel: 'نسبت به ماه قبل',
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: 'مشتریان جدید',
|
||||
value: peopleCur,
|
||||
changePercent: pctChange(peopleCur, peoplePrev),
|
||||
changeLabel: 'نسبت به ماه قبل',
|
||||
},
|
||||
{
|
||||
key: 'open-deals',
|
||||
label: 'معاملات باز',
|
||||
value: openDeals,
|
||||
changePercent: pctChange(openDeals, openDealsWeekAgo || openDeals),
|
||||
changeLabel: 'نسبت به هفته قبل',
|
||||
},
|
||||
{
|
||||
key: 'followups',
|
||||
label: 'پیگیری امروز',
|
||||
value: openActivitiesToday,
|
||||
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
|
||||
changeLabel: 'نسبت به دیروز',
|
||||
},
|
||||
],
|
||||
alerts,
|
||||
salesTrend,
|
||||
sources,
|
||||
topCustomers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRange(from?: string, to?: string): { start: Date; end: Date } {
|
||||
@@ -269,3 +573,34 @@ function startOfDay(d: Date): Date {
|
||||
function endOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function daysAgo(n: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addMonthsStart(from: Date, deltaMonths: number): Date {
|
||||
const { jy, jm } = dateToJalali(from);
|
||||
const m = addJalaliMonths(jy, jm, deltaMonths);
|
||||
return startOfJalaliMonth(m.jy, m.jm);
|
||||
}
|
||||
|
||||
function pctChange(current: number, previous: number): number | null {
|
||||
if (previous <= 0) return current > 0 ? 100 : null;
|
||||
return Math.round(((current - previous) / previous) * 1000) / 10;
|
||||
}
|
||||
|
||||
function normalizeSource(raw: string | null | undefined): string {
|
||||
const value = raw?.trim();
|
||||
if (!value) return 'سایر';
|
||||
const map: Record<string, string> = {
|
||||
referral: 'معرفی دوستان',
|
||||
website: 'وبسایت',
|
||||
social: 'شبکههای اجتماعی',
|
||||
ads: 'تبلیغات',
|
||||
instagram: 'شبکههای اجتماعی',
|
||||
google: 'تبلیغات',
|
||||
};
|
||||
return map[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user