Redesign home dashboard and app shell to match Mento mockup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 15:21:54 +03:30
co-authored by Cursor
parent d152d1c19f
commit 523e641d8f
7 changed files with 1709 additions and 560 deletions
@@ -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,
});
}
}
+336 -1
View File
@@ -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;
}