feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
@@ -1533,6 +1533,7 @@ export class ReportsService {
|
||||
async home(actor: Actor): Promise<HomeDashboardDto> {
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor, 'deals');
|
||||
const invoiceScope = ownerWhere(actor, 'finance');
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
@@ -1543,6 +1544,7 @@ export class ReportsService {
|
||||
const prev = addJalaliMonths(jy, jm, -1);
|
||||
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
|
||||
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
|
||||
const trendWindowStart = addMonthsStart(now, -12);
|
||||
|
||||
const todayStart = startOfDay(now);
|
||||
const todayEnd = endOfDay(now);
|
||||
@@ -1555,17 +1557,25 @@ export class ReportsService {
|
||||
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
|
||||
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
|
||||
'issued',
|
||||
'partially_paid',
|
||||
'paid',
|
||||
];
|
||||
|
||||
const [
|
||||
wonDeals,
|
||||
openDeals,
|
||||
openDealRows,
|
||||
lostDeals,
|
||||
staleDeals,
|
||||
peopleCur,
|
||||
peoplePrev,
|
||||
peopleRecent,
|
||||
openActivitiesToday,
|
||||
openActivitiesYesterday,
|
||||
openDealsWeekAgo,
|
||||
goals,
|
||||
overdueCount,
|
||||
invoices,
|
||||
activityTypeGroups,
|
||||
] = await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
@@ -1573,7 +1583,7 @@ export class ReportsService {
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'won',
|
||||
updatedAt: { gte: addMonthsStart(now, -5) },
|
||||
updatedAt: { gte: trendWindowStart },
|
||||
},
|
||||
dealScope,
|
||||
dealsPrior,
|
||||
@@ -1590,7 +1600,7 @@ export class ReportsService {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ organizationId: orgId, status: 'open' },
|
||||
@@ -1598,6 +1608,30 @@ export class ReportsService {
|
||||
dealsPrior,
|
||||
],
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, position: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'lost',
|
||||
updatedAt: { gte: trendWindowStart },
|
||||
},
|
||||
dealScope,
|
||||
dealsPrior,
|
||||
],
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
lostReason: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
@@ -1624,19 +1658,13 @@ export class ReportsService {
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 5,
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
createdAt: { gte: curMonthStart, lte: curMonthEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
|
||||
createdAt: { gte: trendWindowStart, lte: curMonthEnd },
|
||||
},
|
||||
select: { createdAt: true, importSource: true },
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
@@ -1656,6 +1684,7 @@ export class ReportsService {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
||||
},
|
||||
activityScope,
|
||||
@@ -1697,8 +1726,67 @@ export class ReportsService {
|
||||
],
|
||||
},
|
||||
}),
|
||||
this.prisma.invoice.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...invoiceScope,
|
||||
status: { in: invoiceStatuses },
|
||||
OR: [
|
||||
{ issuedAt: { gte: trendWindowStart, lte: curMonthEnd } },
|
||||
{ createdAt: { gte: trendWindowStart, lte: curMonthEnd } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
issuedAt: true,
|
||||
createdAt: true,
|
||||
lineItems: {
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
currency: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.groupBy({
|
||||
by: ['type'],
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
},
|
||||
activityScope,
|
||||
activitiesPrior,
|
||||
],
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const openDeals = openDealRows.length;
|
||||
|
||||
const invoiceInRange = (
|
||||
inv: { issuedAt: Date | null; createdAt: Date },
|
||||
from: Date,
|
||||
to: Date,
|
||||
) => {
|
||||
const anchor = inv.issuedAt ?? inv.createdAt;
|
||||
return anchor >= from && anchor <= to;
|
||||
};
|
||||
|
||||
const sumInvoiceInRange = (from: Date, to: Date) => {
|
||||
let amount = 0;
|
||||
for (const inv of invoices) {
|
||||
if (!invoiceInRange(inv, from, to)) continue;
|
||||
for (const line of inv.lineItems) {
|
||||
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
|
||||
amount += toBaseAmount(lineTotal, line.currency, rates);
|
||||
}
|
||||
}
|
||||
return amount;
|
||||
};
|
||||
|
||||
const sumWonInRange = (start: Date, end: Date) =>
|
||||
wonDeals
|
||||
.filter((d) => d.updatedAt >= start && d.updatedAt <= end)
|
||||
@@ -1707,8 +1795,21 @@ export class ReportsService {
|
||||
0,
|
||||
);
|
||||
|
||||
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
|
||||
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
|
||||
const salesInRange = (start: Date, end: Date) => {
|
||||
const inv = sumInvoiceInRange(start, end);
|
||||
const won = sumWonInRange(start, end);
|
||||
return Math.round(inv > 0 ? inv : won);
|
||||
};
|
||||
|
||||
const peopleCur = peopleRecent.filter(
|
||||
(p) => p.createdAt >= curMonthStart && p.createdAt <= curMonthEnd,
|
||||
).length;
|
||||
const peoplePrev = peopleRecent.filter(
|
||||
(p) => p.createdAt >= prevMonthStart && p.createdAt <= prevMonthEnd,
|
||||
).length;
|
||||
|
||||
const salesCur = salesInRange(curMonthStart, curMonthEnd);
|
||||
const salesPrev = salesInRange(prevMonthStart, prevMonthEnd);
|
||||
const targetAmount = goals.reduce(
|
||||
(acc, g) => acc + Number(g.targetAmount),
|
||||
0,
|
||||
@@ -1716,7 +1817,7 @@ export class ReportsService {
|
||||
|
||||
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
|
||||
[];
|
||||
for (let i = 4; i >= 0; i -= 1) {
|
||||
for (let i = 11; i >= 0; i -= 1) {
|
||||
const m = addJalaliMonths(jy, jm, -i);
|
||||
trendMonths.push({
|
||||
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
|
||||
@@ -1729,14 +1830,28 @@ export class ReportsService {
|
||||
const salesTrend = trendMonths.map((m) => ({
|
||||
monthKey: m.key,
|
||||
monthLabel: m.label,
|
||||
amount: Math.round(sumWonInRange(m.start, m.end)),
|
||||
amount: salesInRange(m.start, m.end),
|
||||
}));
|
||||
|
||||
const customerGrowth = trendMonths.map((m) => ({
|
||||
monthKey: m.key,
|
||||
monthLabel: m.label,
|
||||
value: peopleRecent.filter(
|
||||
(p) => p.createdAt >= m.start && p.createdAt <= m.end,
|
||||
).length,
|
||||
}));
|
||||
|
||||
const sourceMap = new Map<string, number>();
|
||||
for (const d of wonDeals) {
|
||||
const label = normalizeSource(d.source);
|
||||
for (const p of peopleRecent) {
|
||||
const label = normalizeImportSource(p.importSource);
|
||||
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
|
||||
}
|
||||
if (sourceMap.size === 0) {
|
||||
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]) => ({
|
||||
@@ -1780,6 +1895,101 @@ export class ReportsService {
|
||||
.slice(0, 5)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }));
|
||||
|
||||
const wonAmountAll = Math.round(
|
||||
wonDeals.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const lostAmountAll = Math.round(
|
||||
lostDeals.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const openAmountAll = Math.round(
|
||||
openDealRows.reduce(
|
||||
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const closedCount = wonDeals.length + lostDeals.length;
|
||||
const winRate = closedCount
|
||||
? Math.round((wonDeals.length / closedCount) * 1000) / 10
|
||||
: 0;
|
||||
const salesSummary: HomeDashboardDto['salesSummary'] = {
|
||||
openCount: openDeals,
|
||||
wonCount: wonDeals.length,
|
||||
lostCount: lostDeals.length,
|
||||
openAmount: openAmountAll,
|
||||
wonAmount: wonAmountAll,
|
||||
lostAmount: lostAmountAll,
|
||||
winRate,
|
||||
};
|
||||
|
||||
const funnelMap = new Map<
|
||||
string,
|
||||
{ stageId: string; stageName: string; position: number; count: number; amount: number }
|
||||
>();
|
||||
for (const d of openDealRows) {
|
||||
const stageId = d.stage?.id ?? d.stageId ?? 'unknown';
|
||||
const stageName = d.stage?.name ?? 'بدون مرحله';
|
||||
const position = d.stage?.position ?? 999;
|
||||
const cur = funnelMap.get(stageId) ?? {
|
||||
stageId,
|
||||
stageName,
|
||||
position,
|
||||
count: 0,
|
||||
amount: 0,
|
||||
};
|
||||
cur.count += 1;
|
||||
cur.amount += decimalToBase(d.amount, d.currency, rates);
|
||||
funnelMap.set(stageId, cur);
|
||||
}
|
||||
const funnel = [...funnelMap.values()]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.slice(0, 8)
|
||||
.map((row) => ({
|
||||
stageId: row.stageId,
|
||||
stageName: row.stageName,
|
||||
count: row.count,
|
||||
amount: Math.round(row.amount),
|
||||
}));
|
||||
|
||||
const lostReasonMap = new Map<string, { count: number; amount: number }>();
|
||||
for (const d of lostDeals) {
|
||||
const label = normalizeLostReason(d.lostReason);
|
||||
const prevRow = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
|
||||
prevRow.count += 1;
|
||||
prevRow.amount += decimalToBase(d.amount, d.currency, rates);
|
||||
lostReasonMap.set(label, prevRow);
|
||||
}
|
||||
const lostReasonTotal =
|
||||
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
|
||||
const lostReasons = [...lostReasonMap.entries()]
|
||||
.map(([label, row]) => ({
|
||||
label,
|
||||
count: row.count,
|
||||
amount: Math.round(row.amount),
|
||||
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
const activityTypeLabel: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'کار',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
const activityByType = activityTypeGroups
|
||||
.map((row) => ({
|
||||
type: row.type,
|
||||
label: activityTypeLabel[row.type] ?? row.type,
|
||||
count: row._count._all,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const alerts: HomeDashboardDto['alerts'] = [];
|
||||
if (staleDeals.length) {
|
||||
const first = staleDeals[0]!;
|
||||
@@ -1857,11 +2067,30 @@ export class ReportsService {
|
||||
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
|
||||
changeLabel: 'نسبت به دیروز',
|
||||
},
|
||||
{
|
||||
key: 'win-rate',
|
||||
label: 'نرخ برد',
|
||||
value: winRate,
|
||||
changePercent: null,
|
||||
changeLabel: '۱۲ ماه اخیر',
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'عقبافتاده',
|
||||
value: overdueCount,
|
||||
changePercent: null,
|
||||
changeLabel: 'فعالیت باز',
|
||||
},
|
||||
],
|
||||
alerts,
|
||||
salesTrend,
|
||||
customerGrowth,
|
||||
sources,
|
||||
topCustomers,
|
||||
salesSummary,
|
||||
funnel,
|
||||
lostReasons,
|
||||
activityByType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1919,6 +2148,21 @@ function normalizeSource(raw: string | null | undefined): string {
|
||||
return map[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
function normalizeImportSource(raw: string | null | undefined): string {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return 'سایر';
|
||||
const map: Record<string, string> = {
|
||||
manual: 'دستی',
|
||||
tahesab: 'تهحساب',
|
||||
didar: 'دیدار',
|
||||
excel: 'اکسل',
|
||||
phone: 'تلفن',
|
||||
lead_form: 'فرم لید',
|
||||
other: 'سایر',
|
||||
};
|
||||
return map[value] ?? normalizeSource(raw);
|
||||
}
|
||||
|
||||
function normalizeLostReason(raw: string | null | undefined): string {
|
||||
const value = raw?.trim();
|
||||
if (!value) return 'بدون دلیل ثبتشده';
|
||||
|
||||
Reference in New Issue
Block a user