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 },
|
{ 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 { Injectable } from '@nestjs/common';
|
||||||
import type { ReportsDashboardDto } from '@mento/shared';
|
import type { HomeDashboardDto, ReportsDashboardDto } from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,13 @@ import {
|
|||||||
ticketAccessWhere,
|
ticketAccessWhere,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||||
|
import {
|
||||||
|
addJalaliMonths,
|
||||||
|
dateToJalali,
|
||||||
|
endOfJalaliMonth,
|
||||||
|
jalaliMonthName,
|
||||||
|
startOfJalaliMonth,
|
||||||
|
} from '../common/jalali';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
@@ -246,6 +253,303 @@ export class ReportsService {
|
|||||||
peopleCreated,
|
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 } {
|
function resolveRange(from?: string, to?: string): { start: Date; end: Date } {
|
||||||
@@ -269,3 +573,34 @@ function startOfDay(d: Date): Date {
|
|||||||
function endOfDay(d: Date): Date {
|
function endOfDay(d: Date): Date {
|
||||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+378
-311
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #eef2f0;
|
--bg: #f4f7f8;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--ink: #17211c;
|
--ink: #0f172a;
|
||||||
--muted: #5b6b63;
|
--muted: #64748b;
|
||||||
--line: #d5ddd8;
|
--line: #e2e8f0;
|
||||||
--accent: #0f6b5c;
|
--accent: #0d9488;
|
||||||
--accent-soft: #d8efe9;
|
--accent-soft: #ccfbf1;
|
||||||
--danger: #b42318;
|
--danger: #b42318;
|
||||||
--shadow: 0 10px 30px rgba(23, 33, 28, 0.06);
|
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
--radius: 14px;
|
--radius: 18px;
|
||||||
--font: "Vazirmatn", Tahoma, sans-serif;
|
--font: "Vazirmatn", Tahoma, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +31,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(circle at top left, rgba(15, 107, 92, 0.08), transparent 28%),
|
|
||||||
linear-gradient(180deg, #f7faf8 0%, var(--bg) 100%);
|
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+817
-237
File diff suppressed because it is too large
Load Diff
@@ -1163,6 +1163,49 @@ export interface ReportsDashboardDto {
|
|||||||
peopleCreated: number;
|
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 {
|
export interface ProjectBoardStageDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user