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
+114
View File
@@ -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,
});
}
}
+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;
}
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -1,14 +1,14 @@
:root {
--bg: #eef2f0;
--bg: #f4f7f8;
--surface: #ffffff;
--ink: #17211c;
--muted: #5b6b63;
--line: #d5ddd8;
--accent: #0f6b5c;
--accent-soft: #d8efe9;
--ink: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--accent: #0d9488;
--accent-soft: #ccfbf1;
--danger: #b42318;
--shadow: 0 10px 30px rgba(23, 33, 28, 0.06);
--radius: 14px;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--radius: 18px;
--font: "Vazirmatn", Tahoma, sans-serif;
}
@@ -31,9 +31,7 @@ body {
margin: 0;
font-family: var(--font);
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 107, 92, 0.08), transparent 28%),
linear-gradient(180deg, #f7faf8 0%, var(--bg) 100%);
background: var(--bg);
overflow-x: clip;
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -1163,6 +1163,49 @@ export interface ReportsDashboardDto {
peopleCreated: number;
}
export interface HomeDashboardKpiDto {
key: string;
label: string;
value: number;
changePercent: number | null;
changeLabel: string;
}
export interface HomeDashboardAlertDto {
key: string;
tone: 'urgent' | 'warning' | 'success' | 'info';
text: string;
link?: string;
}
export interface HomeDashboardTrendPointDto {
monthKey: string;
monthLabel: string;
amount: number;
}
export interface HomeDashboardSourceDto {
label: string;
count: number;
percent: number;
}
export interface HomeDashboardTopCustomerDto {
id: string;
name: string;
dealCount: number;
rank: number;
}
export interface HomeDashboardDto {
currency: string;
kpis: HomeDashboardKpiDto[];
alerts: HomeDashboardAlertDto[];
salesTrend: HomeDashboardTrendPointDto[];
sources: HomeDashboardSourceDto[];
topCustomers: HomeDashboardTopCustomerDto[];
}
/** برد پروژه / کاریز کارت */
export interface ProjectBoardStageDto {
id: string;