Redesign home dashboard and app shell to match Mento mockup.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
const MONTH_NAMES = [
|
||||
'فروردین',
|
||||
'اردیبهشت',
|
||||
'خرداد',
|
||||
'تیر',
|
||||
'مرداد',
|
||||
'شهریور',
|
||||
'مهر',
|
||||
'آبان',
|
||||
'آذر',
|
||||
'دی',
|
||||
'بهمن',
|
||||
'اسفند',
|
||||
] as const;
|
||||
|
||||
function div(a: number, b: number) {
|
||||
return Math.trunc(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number) {
|
||||
return a - Math.trunc(a / b) * b;
|
||||
}
|
||||
|
||||
export function toJalali(gy: number, gm: number, gd: number) {
|
||||
const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||
const gy2 = gm > 2 ? gy + 1 : gy;
|
||||
let days =
|
||||
355666 +
|
||||
365 * gy +
|
||||
div(gy2 + 3, 4) -
|
||||
div(gy2 + 99, 100) +
|
||||
div(gy2 + 399, 400) +
|
||||
gd +
|
||||
gdm[gm - 1];
|
||||
let jy = -1595 + 33 * div(days, 12053);
|
||||
days %= 12053;
|
||||
jy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
jy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
|
||||
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
|
||||
return { jy, jm, jd };
|
||||
}
|
||||
|
||||
export function toGregorian(jy: number, jm: number, jd: number) {
|
||||
const jy2 = jy + 1595;
|
||||
let days =
|
||||
-355668 +
|
||||
365 * jy2 +
|
||||
div(jy2, 33) * 8 +
|
||||
div(mod(jy2, 33) + 3, 4) +
|
||||
jd +
|
||||
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
|
||||
let gy = 400 * div(days, 146097);
|
||||
days %= 146097;
|
||||
if (days > 36524) {
|
||||
gy += 100 * div(--days, 36524);
|
||||
days %= 36524;
|
||||
if (days >= 365) days += 1;
|
||||
}
|
||||
gy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
gy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
const gd = days + 1;
|
||||
const salA = [
|
||||
0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
|
||||
31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||||
];
|
||||
let gm = 0;
|
||||
let dayAcc = gd;
|
||||
for (gm = 1; gm <= 12 && dayAcc > salA[gm]; gm += 1) {
|
||||
dayAcc -= salA[gm];
|
||||
}
|
||||
return { gy, gm, gd: dayAcc };
|
||||
}
|
||||
|
||||
export function dateToJalali(d: Date) {
|
||||
return toJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
}
|
||||
|
||||
export function jalaliToDate(jy: number, jm: number, jd: number): Date {
|
||||
const { gy, gm, gd } = toGregorian(jy, jm, jd);
|
||||
return new Date(gy, gm - 1, gd);
|
||||
}
|
||||
|
||||
export function addJalaliMonths(jy: number, jm: number, delta: number) {
|
||||
const idx = jy * 12 + (jm - 1) + delta;
|
||||
return { jy: Math.floor(idx / 12), jm: (idx % 12) + 1 };
|
||||
}
|
||||
|
||||
export function jalaliMonthName(jm: number): string {
|
||||
return MONTH_NAMES[jm - 1] ?? '';
|
||||
}
|
||||
|
||||
export function jalaliMonthKey(d: Date): string {
|
||||
const { jy, jm } = dateToJalali(d);
|
||||
return `${jy}-${String(jm).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function startOfJalaliMonth(jy: number, jm: number): Date {
|
||||
return jalaliToDate(jy, jm, 1);
|
||||
}
|
||||
|
||||
export function endOfJalaliMonth(jy: number, jm: number): Date {
|
||||
const next = addJalaliMonths(jy, jm, 1);
|
||||
const startNext = jalaliToDate(next.jy, next.jm, 1);
|
||||
return new Date(startNext.getTime() - 1);
|
||||
}
|
||||
@@ -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