Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization. Co-authored-by: Cursor <cursoragent@cursor.com>
2008 lines
61 KiB
TypeScript
2008 lines
61 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
|
import type {
|
|
HomeDashboardDto,
|
|
ReportsBuilderDimension,
|
|
ReportsBuilderMetric,
|
|
ReportsBuilderQuery,
|
|
ReportsBuilderResultDto,
|
|
ReportsBuilderRowDto,
|
|
ReportsDashboardDto,
|
|
ReportsDrillKind,
|
|
ReportsDrilldownDto,
|
|
ReportsFunnelStageDto,
|
|
ReportsPeriodCompareRowDto,
|
|
} from '@mento/shared';
|
|
import {
|
|
REPORTS_BUILDER_DIMENSION_LABELS,
|
|
REPORTS_BUILDER_METRIC_LABELS,
|
|
} from '@mento/shared';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { customersPersonWhere } from '../common/data-access';
|
|
import {
|
|
type Actor,
|
|
assigneeWhere,
|
|
elevated,
|
|
ownerWhere,
|
|
ticketAccessWhere,
|
|
} from '../common/record-scope';
|
|
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
|
|
import {
|
|
activityPriorWhere,
|
|
dealPriorWhere,
|
|
} from '../common/prior-archive';
|
|
import {
|
|
addJalaliMonths,
|
|
dateToJalali,
|
|
endOfJalaliMonth,
|
|
jalaliMonthName,
|
|
startOfJalaliMonth,
|
|
} from '../common/jalali';
|
|
|
|
const DRILL_LIMIT = 50;
|
|
|
|
@Injectable()
|
|
export class ReportsService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async dashboard(
|
|
actor: Actor,
|
|
opts: { from?: string; to?: string },
|
|
): Promise<ReportsDashboardDto> {
|
|
const { start, end } = resolveRange(opts.from, opts.to);
|
|
const orgId = actor.organizationId;
|
|
const dealScope = ownerWhere(actor, 'deals');
|
|
const invoiceScope = ownerWhere(actor, 'finance');
|
|
const activityScope = assigneeWhere(actor);
|
|
const ticketScope = ticketAccessWhere(actor);
|
|
const personScope = customersPersonWhere(actor);
|
|
|
|
const durationMs = end.getTime() - start.getTime();
|
|
const prevEnd = new Date(start.getTime() - 1);
|
|
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
|
|
|
const now = new Date();
|
|
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
|
|
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
|
const { jy, jm } = dateToJalali(now);
|
|
const sixAgo = addJalaliMonths(jy, jm, -5);
|
|
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
|
|
const dealEarliest = new Date(
|
|
Math.min(sixMonthStart.getTime(), prevStart.getTime(), start.getTime()),
|
|
);
|
|
|
|
const rates = await loadOrgRates(this.prisma, orgId);
|
|
|
|
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
|
|
'issued',
|
|
'partially_paid',
|
|
'paid',
|
|
];
|
|
|
|
const [deals, stages, activities, tickets, people, users, invoices] =
|
|
await Promise.all([
|
|
this.prisma.deal.findMany({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
...dealScope,
|
|
OR: [
|
|
{ status: 'open' },
|
|
{
|
|
status: { in: ['won', 'lost'] },
|
|
updatedAt: { gte: dealEarliest },
|
|
},
|
|
{ createdAt: { gte: dealEarliest } },
|
|
],
|
|
},
|
|
dealsPrior,
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
amount: true,
|
|
currency: true,
|
|
stageId: true,
|
|
ownerId: true,
|
|
source: true,
|
|
lostReason: true,
|
|
updatedAt: true,
|
|
createdAt: true,
|
|
lineItems: {
|
|
select: {
|
|
name: true,
|
|
quantity: true,
|
|
unitPrice: true,
|
|
currency: true,
|
|
product: { select: { name: true, category: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.pipelineStage.findMany({
|
|
where: { pipeline: { organizationId: orgId } },
|
|
include: { pipeline: { select: { id: true, name: true } } },
|
|
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
|
|
}),
|
|
this.prisma.activity.findMany({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
createdAt: { gte: prevStart, lte: end },
|
|
},
|
|
activityScope,
|
|
activitiesPrior,
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
status: true,
|
|
dueAt: true,
|
|
assigneeId: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
this.prisma.ticket.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...ticketScope,
|
|
createdAt: { gte: start, lte: end },
|
|
},
|
|
select: {
|
|
status: true,
|
|
slaDueAt: true,
|
|
firstRespondedAt: true,
|
|
csatScore: true,
|
|
},
|
|
}),
|
|
this.prisma.person.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...personScope,
|
|
},
|
|
select: { mood: true, createdAt: true },
|
|
}),
|
|
this.prisma.user.findMany({
|
|
where: { memberships: { some: { organizationId: orgId } } },
|
|
select: { id: true, fullName: true },
|
|
}),
|
|
this.prisma.invoice.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...invoiceScope,
|
|
status: { in: invoiceStatuses },
|
|
OR: [
|
|
{ issuedAt: { gte: prevStart, lte: end } },
|
|
{ createdAt: { gte: prevStart, lte: end } },
|
|
],
|
|
},
|
|
select: {
|
|
issuedAt: true,
|
|
createdAt: true,
|
|
lineItems: {
|
|
select: {
|
|
name: true,
|
|
quantity: true,
|
|
unitPrice: true,
|
|
currency: true,
|
|
product: { select: { name: true, category: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const invoiceInRange = (inv: {
|
|
issuedAt: Date | null;
|
|
createdAt: Date;
|
|
}, from: Date, to: Date) => {
|
|
const anchor = inv.issuedAt ?? inv.createdAt;
|
|
return anchor >= from && anchor <= to;
|
|
};
|
|
|
|
const invoicesCur = invoices.filter((inv) =>
|
|
invoiceInRange(inv, start, end),
|
|
);
|
|
const invoicesPrev = invoices.filter((inv) =>
|
|
invoiceInRange(inv, prevStart, prevEnd),
|
|
);
|
|
|
|
const sumInvoiceLines = (
|
|
rows: typeof invoices,
|
|
): { count: number; amount: number } => {
|
|
let amount = 0;
|
|
for (const inv of rows) {
|
|
for (const line of inv.lineItems) {
|
|
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
|
|
amount += toBaseAmount(lineTotal, line.currency, rates);
|
|
}
|
|
}
|
|
return { count: rows.length, amount };
|
|
};
|
|
|
|
const invCur = sumInvoiceLines(invoicesCur);
|
|
const invPrev = sumInvoiceLines(invoicesPrev);
|
|
const invoiceCount = invCur.count;
|
|
const invoiceAmount = Math.round(invCur.amount);
|
|
const invoiceCountPrev = invPrev.count;
|
|
const invoiceAmountPrev = Math.round(invPrev.amount);
|
|
|
|
const openDeals = deals.filter((d) => d.status === 'open');
|
|
const wonInPeriod = deals.filter(
|
|
(d) =>
|
|
d.status === 'won' &&
|
|
d.updatedAt >= start &&
|
|
d.updatedAt <= end,
|
|
);
|
|
const lostInPeriod = deals.filter(
|
|
(d) =>
|
|
d.status === 'lost' &&
|
|
d.updatedAt >= start &&
|
|
d.updatedAt <= end,
|
|
);
|
|
const wonPrev = deals.filter(
|
|
(d) =>
|
|
d.status === 'won' &&
|
|
d.updatedAt >= prevStart &&
|
|
d.updatedAt <= prevEnd,
|
|
);
|
|
const lostPrev = deals.filter(
|
|
(d) =>
|
|
d.status === 'lost' &&
|
|
d.updatedAt >= prevStart &&
|
|
d.updatedAt <= prevEnd,
|
|
);
|
|
|
|
const activitiesCur = activities.filter(
|
|
(a) => a.createdAt >= start && a.createdAt <= end,
|
|
);
|
|
const activitiesPrev = activities.filter(
|
|
(a) => a.createdAt >= prevStart && a.createdAt <= prevEnd,
|
|
);
|
|
|
|
const sumBase = (
|
|
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
|
|
) =>
|
|
rows.reduce(
|
|
(acc, row) => acc + decimalToBase(row.amount, row.currency, rates),
|
|
0,
|
|
);
|
|
|
|
const wonAmount = Math.round(sumBase(wonInPeriod));
|
|
const wonAmountPrev = Math.round(sumBase(wonPrev));
|
|
const closedCount = wonInPeriod.length + lostInPeriod.length;
|
|
const winRate = closedCount ? wonInPeriod.length / closedCount : 0;
|
|
|
|
const peopleCreated = people.filter(
|
|
(p) => p.createdAt >= start && p.createdAt <= end,
|
|
).length;
|
|
const peopleCreatedPrev = people.filter(
|
|
(p) => p.createdAt >= prevStart && p.createdAt <= prevEnd,
|
|
).length;
|
|
|
|
const salesAmount = invoiceAmount > 0 ? invoiceAmount : wonAmount;
|
|
const salesAmountPrev =
|
|
invoiceAmountPrev > 0 ? invoiceAmountPrev : wonAmountPrev;
|
|
const avgInvoice =
|
|
invoiceCount > 0 ? Math.round(salesAmount / invoiceCount) : 0;
|
|
const avgInvoicePrev =
|
|
invoiceCountPrev > 0
|
|
? Math.round(salesAmountPrev / invoiceCountPrev)
|
|
: 0;
|
|
const avgSaleDenom = Math.max(invoiceCount || wonInPeriod.length, 1);
|
|
const avgSale = Math.round(salesAmount / avgSaleDenom);
|
|
const avgSalePrev = Math.round(
|
|
salesAmountPrev /
|
|
Math.max(invoiceCountPrev || wonPrev.length, 1),
|
|
);
|
|
|
|
const changeLabel = 'نسبت به دوره قبل';
|
|
const kpis: ReportsDashboardDto['kpis'] = [
|
|
{
|
|
key: 'new_customers',
|
|
label: 'مشتریان جدید',
|
|
value: peopleCreated,
|
|
format: 'number',
|
|
changePercent: pctChange(peopleCreated, peopleCreatedPrev),
|
|
changeLabel,
|
|
},
|
|
{
|
|
key: 'invoice_count',
|
|
label: 'تعداد فاکتور',
|
|
value: invoiceCount,
|
|
format: 'number',
|
|
changePercent: pctChange(invoiceCount, invoiceCountPrev),
|
|
changeLabel,
|
|
},
|
|
{
|
|
key: 'sales_amount',
|
|
label: 'مبلغ فروش',
|
|
value: salesAmount,
|
|
format: 'money',
|
|
changePercent: pctChange(salesAmount, salesAmountPrev),
|
|
changeLabel,
|
|
},
|
|
{
|
|
key: 'avg_invoice',
|
|
label: 'میانگین فاکتور',
|
|
value: avgInvoice,
|
|
format: 'money',
|
|
changePercent: pctChange(avgInvoice, avgInvoicePrev),
|
|
changeLabel,
|
|
},
|
|
{
|
|
key: 'avg_sale',
|
|
label: 'میانگین فروش',
|
|
value: avgSale,
|
|
format: 'money',
|
|
changePercent: pctChange(avgSale, avgSalePrev),
|
|
changeLabel,
|
|
},
|
|
];
|
|
|
|
const spanDays =
|
|
Math.ceil((end.getTime() - start.getTime()) / 86_400_000) || 1;
|
|
const salesTrend: ReportsDashboardDto['salesTrend'] = [];
|
|
if (spanDays <= 45) {
|
|
for (let i = 0; i < spanDays; i += 1) {
|
|
const dayStart = startOfDay(
|
|
new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
|
|
);
|
|
const dayEnd = endOfDay(dayStart);
|
|
if (dayStart > end) break;
|
|
const value = Math.round(
|
|
sumBase(
|
|
wonInPeriod.filter(
|
|
(d) => d.updatedAt >= dayStart && d.updatedAt <= dayEnd,
|
|
),
|
|
),
|
|
);
|
|
const key = `${dayStart.getFullYear()}-${String(dayStart.getMonth() + 1).padStart(2, '0')}-${String(dayStart.getDate()).padStart(2, '0')}`;
|
|
const j = dateToJalali(dayStart);
|
|
salesTrend.push({
|
|
key,
|
|
label: `${j.jd} ${jalaliMonthName(j.jm)}`,
|
|
value,
|
|
});
|
|
}
|
|
} else {
|
|
const buckets = new Map<
|
|
string,
|
|
{ label: string; start: Date; end: Date; value: number }
|
|
>();
|
|
for (const d of wonInPeriod) {
|
|
const j = dateToJalali(d.updatedAt);
|
|
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
|
|
let bucket = buckets.get(key);
|
|
if (!bucket) {
|
|
bucket = {
|
|
label: `${jalaliMonthName(j.jm)} ${j.jy}`,
|
|
start: startOfJalaliMonth(j.jy, j.jm),
|
|
end: endOfJalaliMonth(j.jy, j.jm),
|
|
value: 0,
|
|
};
|
|
buckets.set(key, bucket);
|
|
}
|
|
bucket.value += decimalToBase(d.amount, d.currency, rates);
|
|
}
|
|
const cursor = dateToJalali(start);
|
|
let cy = cursor.jy;
|
|
let cm = cursor.jm;
|
|
const endJ = dateToJalali(end);
|
|
while (cy < endJ.jy || (cy === endJ.jy && cm <= endJ.jm)) {
|
|
const key = `${cy}-${String(cm).padStart(2, '0')}`;
|
|
const existing = buckets.get(key);
|
|
salesTrend.push({
|
|
key,
|
|
label: `${jalaliMonthName(cm)} ${cy}`,
|
|
value: Math.round(existing?.value ?? 0),
|
|
});
|
|
const next = addJalaliMonths(cy, cm, 1);
|
|
cy = next.jy;
|
|
cm = next.jm;
|
|
}
|
|
}
|
|
|
|
const monthlyComparison: ReportsDashboardDto['monthlyComparison'] = [];
|
|
const customerGrowth: ReportsDashboardDto['customerGrowth'] = [];
|
|
for (let i = 5; i >= 0; i -= 1) {
|
|
const m = addJalaliMonths(jy, jm, -i);
|
|
const mStart = startOfJalaliMonth(m.jy, m.jm);
|
|
const mEnd = endOfJalaliMonth(m.jy, m.jm);
|
|
const key = `${m.jy}-${String(m.jm).padStart(2, '0')}`;
|
|
const label = jalaliMonthName(m.jm);
|
|
monthlyComparison.push({
|
|
key,
|
|
label,
|
|
value: Math.round(
|
|
sumBase(
|
|
deals.filter(
|
|
(d) =>
|
|
d.status === 'won' &&
|
|
d.updatedAt >= mStart &&
|
|
d.updatedAt <= mEnd,
|
|
),
|
|
),
|
|
),
|
|
});
|
|
customerGrowth.push({
|
|
key,
|
|
label,
|
|
value: people.filter(
|
|
(p) => p.createdAt >= mStart && p.createdAt <= mEnd,
|
|
).length,
|
|
});
|
|
}
|
|
|
|
const sourceMap = new Map<string, number>();
|
|
for (const d of wonInPeriod) {
|
|
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: ReportsDashboardDto['sources'] = [...sourceMap.entries()]
|
|
.map(([label, count]) => ({
|
|
label,
|
|
count,
|
|
percent: Math.round((count / sourceTotal) * 1000) / 10,
|
|
}))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
const lostReasonMap = new Map<string, { count: number; amount: number }>();
|
|
for (const d of lostInPeriod) {
|
|
const label = normalizeLostReason(d.lostReason);
|
|
const prev = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
|
|
prev.count += 1;
|
|
prev.amount += decimalToBase(d.amount, d.currency, rates);
|
|
lostReasonMap.set(label, prev);
|
|
}
|
|
const lostReasonTotal =
|
|
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
|
|
const lostReasons: ReportsDashboardDto['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);
|
|
|
|
type LineAgg = {
|
|
name: string;
|
|
category: string;
|
|
quantity: number;
|
|
amount: number;
|
|
};
|
|
const collectLines = (
|
|
lines: Array<{
|
|
name: string;
|
|
quantity: Prisma.Decimal;
|
|
unitPrice: Prisma.Decimal;
|
|
currency: string;
|
|
product: { name: string; category: string | null } | null;
|
|
}>,
|
|
): LineAgg[] =>
|
|
lines.map((line) => {
|
|
const qty = Number(line.quantity);
|
|
const amount = toBaseAmount(
|
|
qty * Number(line.unitPrice),
|
|
line.currency,
|
|
rates,
|
|
);
|
|
return {
|
|
name: line.product?.name?.trim() || line.name,
|
|
category: line.product?.category?.trim() || 'سایر',
|
|
quantity: qty,
|
|
amount,
|
|
};
|
|
});
|
|
|
|
const invoiceProductLines: LineAgg[] = [];
|
|
for (const inv of invoicesCur) {
|
|
invoiceProductLines.push(...collectLines(inv.lineItems));
|
|
}
|
|
|
|
let productLines = invoiceProductLines;
|
|
if (!productLines.length) {
|
|
productLines = [];
|
|
for (const d of wonInPeriod) {
|
|
productLines.push(...collectLines(d.lineItems));
|
|
}
|
|
}
|
|
|
|
const categoryMap = new Map<string, { count: number; amount: number }>();
|
|
for (const line of invoiceProductLines) {
|
|
const cur = categoryMap.get(line.category) ?? { count: 0, amount: 0 };
|
|
cur.count += 1;
|
|
cur.amount += line.amount;
|
|
categoryMap.set(line.category, cur);
|
|
}
|
|
const categoryTotal =
|
|
[...categoryMap.values()].reduce((a, b) => a + b.amount, 0) || 1;
|
|
const salesByCategory: ReportsDashboardDto['salesByCategory'] = [
|
|
...categoryMap.entries(),
|
|
]
|
|
.map(([label, row]) => ({
|
|
label,
|
|
count: row.count,
|
|
amount: Math.round(row.amount),
|
|
percent: Math.round((row.amount / categoryTotal) * 1000) / 10,
|
|
}))
|
|
.sort((a, b) => (b.amount ?? 0) - (a.amount ?? 0));
|
|
|
|
const productMap = new Map<
|
|
string,
|
|
{ name: string; quantity: number; amount: number }
|
|
>();
|
|
for (const line of productLines) {
|
|
const cur = productMap.get(line.name) ?? {
|
|
name: line.name,
|
|
quantity: 0,
|
|
amount: 0,
|
|
};
|
|
cur.quantity += line.quantity;
|
|
cur.amount += line.amount;
|
|
productMap.set(line.name, cur);
|
|
}
|
|
const topProducts: ReportsDashboardDto['topProducts'] = [
|
|
...productMap.values(),
|
|
]
|
|
.map((row) => ({
|
|
name: row.name,
|
|
quantity: Math.round(row.quantity * 100) / 100,
|
|
amount: Math.round(row.amount),
|
|
}))
|
|
.sort((a, b) => b.amount - a.amount)
|
|
.slice(0, 5);
|
|
|
|
let statusNew = 0;
|
|
let statusActive = 0;
|
|
let statusPotential = 0;
|
|
let statusInactive = 0;
|
|
for (const p of people) {
|
|
const isNew = p.createdAt >= start && p.createdAt <= end;
|
|
if (isNew) {
|
|
statusNew += 1;
|
|
} else if (p.mood === 'happy' || p.mood === 'special') {
|
|
statusActive += 1;
|
|
} else if (p.mood === 'newcomer') {
|
|
statusPotential += 1;
|
|
} else {
|
|
statusInactive += 1;
|
|
}
|
|
}
|
|
const statusTotal = people.length || 1;
|
|
const customerStatus: ReportsDashboardDto['customerStatus'] = [
|
|
{ label: 'جدید', count: statusNew },
|
|
{ label: 'فعال', count: statusActive },
|
|
{ label: 'بالقوه', count: statusPotential },
|
|
{ label: 'غیرفعال', count: statusInactive },
|
|
].map((row) => ({
|
|
...row,
|
|
percent: Math.round((row.count / statusTotal) * 1000) / 10,
|
|
}));
|
|
|
|
const openByStage = new Map<string, { count: number; amount: number }>();
|
|
for (const d of openDeals) {
|
|
const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 };
|
|
cur.count += 1;
|
|
cur.amount += decimalToBase(d.amount, d.currency, rates);
|
|
openByStage.set(d.stageId, cur);
|
|
}
|
|
|
|
const funnel: ReportsFunnelStageDto[] = [];
|
|
const stagesByPipeline = new Map<string, typeof stages>();
|
|
for (const s of stages) {
|
|
const list = stagesByPipeline.get(s.pipelineId) ?? [];
|
|
list.push(s);
|
|
stagesByPipeline.set(s.pipelineId, list);
|
|
}
|
|
for (const pipelineStages of stagesByPipeline.values()) {
|
|
const pipelineTotal =
|
|
pipelineStages.reduce(
|
|
(acc, s) => acc + (openByStage.get(s.id)?.count ?? 0),
|
|
0,
|
|
) || 1;
|
|
let prevCount: number | null = null;
|
|
for (const s of pipelineStages) {
|
|
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
|
|
const conversionFromPrevPercent =
|
|
prevCount == null
|
|
? null
|
|
: prevCount === 0
|
|
? agg.count > 0
|
|
? 100
|
|
: 0
|
|
: Math.round((agg.count / prevCount) * 1000) / 10;
|
|
funnel.push({
|
|
stageId: s.id,
|
|
stageName: s.name,
|
|
pipelineId: s.pipelineId,
|
|
pipelineName: s.pipeline.name,
|
|
count: agg.count,
|
|
amount: Math.round(agg.amount),
|
|
conversionFromPrevPercent,
|
|
shareOfPipelinePercent:
|
|
Math.round((agg.count / pipelineTotal) * 1000) / 10,
|
|
});
|
|
prevCount = agg.count;
|
|
}
|
|
}
|
|
|
|
const activityOpen = activitiesCur.filter((a) => a.status === 'open').length;
|
|
const activityDone = activitiesCur.filter((a) => a.status === 'done').length;
|
|
const activityDonePrev = activitiesPrev.filter(
|
|
(a) => a.status === 'done',
|
|
).length;
|
|
const activityOverdue = activitiesCur.filter(
|
|
(a) =>
|
|
a.status === 'open' &&
|
|
a.dueAt != null &&
|
|
a.dueAt.getTime() < now.getTime(),
|
|
).length;
|
|
const byTypeMap = new Map<string, number>();
|
|
for (const a of activitiesCur) {
|
|
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
|
|
}
|
|
|
|
const teamMap = new Map<
|
|
string,
|
|
{ wonAmount: number; wonCount: number; activitiesDone: number }
|
|
>();
|
|
for (const u of users) {
|
|
teamMap.set(u.id, { wonAmount: 0, wonCount: 0, activitiesDone: 0 });
|
|
}
|
|
for (const d of wonInPeriod) {
|
|
if (!d.ownerId) continue;
|
|
const t = teamMap.get(d.ownerId)!;
|
|
t.wonCount += 1;
|
|
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
|
|
}
|
|
for (const a of activitiesCur) {
|
|
if (a.status !== 'done' || !a.assigneeId) continue;
|
|
const t = teamMap.get(a.assigneeId);
|
|
if (t) t.activitiesDone += 1;
|
|
}
|
|
|
|
const closedPrev = wonPrev.length + lostPrev.length;
|
|
const winRatePrev = closedPrev ? wonPrev.length / closedPrev : 0;
|
|
const winRatePct = Math.round(winRate * 1000) / 10;
|
|
const winRatePrevPct = Math.round(winRatePrev * 1000) / 10;
|
|
|
|
const incomingCur = deals.filter(
|
|
(d) => d.createdAt >= start && d.createdAt <= end,
|
|
);
|
|
const incomingPrev = deals.filter(
|
|
(d) => d.createdAt >= prevStart && d.createdAt <= prevEnd,
|
|
);
|
|
const convWonCur = incomingCur.filter((d) => d.status === 'won').length;
|
|
const convWonPrev = incomingPrev.filter((d) => d.status === 'won').length;
|
|
const conversionPercent = incomingCur.length
|
|
? Math.round((convWonCur / incomingCur.length) * 1000) / 10
|
|
: 0;
|
|
const previousConversionPercent = incomingPrev.length
|
|
? Math.round((convWonPrev / incomingPrev.length) * 1000) / 10
|
|
: 0;
|
|
|
|
const conversionByTime: ReportsDashboardDto['conversionRate']['byTime'] =
|
|
[];
|
|
if (spanDays <= 45) {
|
|
for (let i = 0; i < spanDays; i += 1) {
|
|
const dayStart = startOfDay(
|
|
new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
|
|
);
|
|
const dayEnd = endOfDay(dayStart);
|
|
if (dayStart > end) break;
|
|
const dayIncoming = incomingCur.filter(
|
|
(d) => d.createdAt >= dayStart && d.createdAt <= dayEnd,
|
|
);
|
|
const dayWon = dayIncoming.filter((d) => d.status === 'won').length;
|
|
const j = dateToJalali(dayStart);
|
|
const key = `${dayStart.getFullYear()}-${String(dayStart.getMonth() + 1).padStart(2, '0')}-${String(dayStart.getDate()).padStart(2, '0')}`;
|
|
conversionByTime.push({
|
|
key,
|
|
label: `${j.jd} ${jalaliMonthName(j.jm)}`,
|
|
incomingCount: dayIncoming.length,
|
|
wonCount: dayWon,
|
|
conversionPercent: dayIncoming.length
|
|
? Math.round((dayWon / dayIncoming.length) * 1000) / 10
|
|
: 0,
|
|
});
|
|
}
|
|
} else {
|
|
const buckets = new Map<
|
|
string,
|
|
{ label: string; incoming: number; won: number }
|
|
>();
|
|
for (const d of incomingCur) {
|
|
const j = dateToJalali(d.createdAt);
|
|
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
|
|
let bucket = buckets.get(key);
|
|
if (!bucket) {
|
|
bucket = {
|
|
label: `${jalaliMonthName(j.jm)} ${j.jy}`,
|
|
incoming: 0,
|
|
won: 0,
|
|
};
|
|
buckets.set(key, bucket);
|
|
}
|
|
bucket.incoming += 1;
|
|
if (d.status === 'won') bucket.won += 1;
|
|
}
|
|
for (const [key, b] of [...buckets.entries()].sort((a, b) =>
|
|
a[0].localeCompare(b[0]),
|
|
)) {
|
|
conversionByTime.push({
|
|
key,
|
|
label: b.label,
|
|
incomingCount: b.incoming,
|
|
wonCount: b.won,
|
|
conversionPercent: b.incoming
|
|
? Math.round((b.won / b.incoming) * 1000) / 10
|
|
: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
const conversionByUserMap = new Map<
|
|
string,
|
|
{ label: string; incoming: number; won: number }
|
|
>();
|
|
for (const u of users) {
|
|
conversionByUserMap.set(u.id, {
|
|
label: u.fullName,
|
|
incoming: 0,
|
|
won: 0,
|
|
});
|
|
}
|
|
for (const d of incomingCur) {
|
|
if (!d.ownerId) continue;
|
|
const row = conversionByUserMap.get(d.ownerId);
|
|
if (!row) continue;
|
|
row.incoming += 1;
|
|
if (d.status === 'won') row.won += 1;
|
|
}
|
|
const conversionByUser: ReportsDashboardDto['conversionRate']['byUser'] = [
|
|
...conversionByUserMap.entries(),
|
|
]
|
|
.map(([key, row]) => ({
|
|
key,
|
|
label: row.label,
|
|
incomingCount: row.incoming,
|
|
wonCount: row.won,
|
|
conversionPercent: row.incoming
|
|
? Math.round((row.won / row.incoming) * 1000) / 10
|
|
: 0,
|
|
}))
|
|
.filter((r) => r.incomingCount > 0)
|
|
.sort((a, b) => b.conversionPercent - a.conversionPercent);
|
|
|
|
const conversionBySourceMap = new Map<
|
|
string,
|
|
{ incoming: number; won: number }
|
|
>();
|
|
for (const d of incomingCur) {
|
|
const label = normalizeSource(d.source);
|
|
const row = conversionBySourceMap.get(label) ?? {
|
|
incoming: 0,
|
|
won: 0,
|
|
};
|
|
row.incoming += 1;
|
|
if (d.status === 'won') row.won += 1;
|
|
conversionBySourceMap.set(label, row);
|
|
}
|
|
const conversionBySource: ReportsDashboardDto['conversionRate']['bySource'] =
|
|
[...conversionBySourceMap.entries()]
|
|
.map(([label, row]) => ({
|
|
key: label,
|
|
label,
|
|
incomingCount: row.incoming,
|
|
wonCount: row.won,
|
|
conversionPercent: row.incoming
|
|
? Math.round((row.won / row.incoming) * 1000) / 10
|
|
: 0,
|
|
}))
|
|
.sort((a, b) => b.incomingCount - a.incomingCount);
|
|
|
|
const conversionRate: ReportsDashboardDto['conversionRate'] = {
|
|
incomingCount: incomingCur.length,
|
|
wonCount: convWonCur,
|
|
conversionPercent,
|
|
previousIncomingCount: incomingPrev.length,
|
|
previousWonCount: convWonPrev,
|
|
previousConversionPercent,
|
|
changePercent: pctChange(conversionPercent, previousConversionPercent),
|
|
byTime: conversionByTime,
|
|
byUser: conversionByUser,
|
|
bySource: conversionBySource,
|
|
};
|
|
|
|
const periodCompare: ReportsPeriodCompareRowDto[] = [
|
|
{
|
|
key: 'sales_amount',
|
|
label: 'مبلغ فروش',
|
|
format: 'money',
|
|
current: salesAmount,
|
|
previous: salesAmountPrev,
|
|
changePercent: pctChange(salesAmount, salesAmountPrev),
|
|
drillKind: 'period_won',
|
|
},
|
|
{
|
|
key: 'won_count',
|
|
label: 'معاملات برنده',
|
|
format: 'number',
|
|
current: wonInPeriod.length,
|
|
previous: wonPrev.length,
|
|
changePercent: pctChange(wonInPeriod.length, wonPrev.length),
|
|
drillKind: 'period_won',
|
|
},
|
|
{
|
|
key: 'lost_count',
|
|
label: 'معاملات باخته',
|
|
format: 'number',
|
|
current: lostInPeriod.length,
|
|
previous: lostPrev.length,
|
|
changePercent: pctChange(lostInPeriod.length, lostPrev.length),
|
|
drillKind: 'period_lost',
|
|
},
|
|
{
|
|
key: 'win_rate',
|
|
label: 'نرخ برد',
|
|
format: 'percent',
|
|
current: winRatePct,
|
|
previous: winRatePrevPct,
|
|
changePercent: pctChange(winRatePct, winRatePrevPct),
|
|
},
|
|
{
|
|
key: 'conversion_rate',
|
|
label: 'نرخ تبدیل',
|
|
format: 'percent',
|
|
current: conversionPercent,
|
|
previous: previousConversionPercent,
|
|
changePercent: pctChange(conversionPercent, previousConversionPercent),
|
|
},
|
|
{
|
|
key: 'new_customers',
|
|
label: 'مشتریان جدید',
|
|
format: 'number',
|
|
current: peopleCreated,
|
|
previous: peopleCreatedPrev,
|
|
changePercent: pctChange(peopleCreated, peopleCreatedPrev),
|
|
},
|
|
{
|
|
key: 'activities_done',
|
|
label: 'فعالیت انجامشده',
|
|
format: 'number',
|
|
current: activityDone,
|
|
previous: activityDonePrev,
|
|
changePercent: pctChange(activityDone, activityDonePrev),
|
|
},
|
|
];
|
|
|
|
const ticketOpen = tickets.filter((t) =>
|
|
['open', 'in_progress', 'waiting'].includes(t.status),
|
|
).length;
|
|
const ticketResolved = tickets.filter(
|
|
(t) => t.status === 'resolved',
|
|
).length;
|
|
const ticketClosed = tickets.filter((t) => t.status === 'closed').length;
|
|
const slaBreached = tickets.filter(
|
|
(t) =>
|
|
t.slaDueAt &&
|
|
!t.firstRespondedAt &&
|
|
['open', 'in_progress', 'waiting'].includes(t.status) &&
|
|
t.slaDueAt.getTime() < now.getTime(),
|
|
).length;
|
|
const csatScores = tickets
|
|
.map((t) => t.csatScore)
|
|
.filter((s): s is number => s != null);
|
|
const avgCsat = csatScores.length
|
|
? csatScores.reduce((a, b) => a + b, 0) / csatScores.length
|
|
: null;
|
|
|
|
const insights: string[] = [];
|
|
const hasData =
|
|
peopleCreated > 0 ||
|
|
wonAmount > 0 ||
|
|
invoiceAmount > 0 ||
|
|
invoiceCount > 0 ||
|
|
wonInPeriod.length > 0;
|
|
if (hasData) {
|
|
const salesChange = pctChange(salesAmount, salesAmountPrev);
|
|
if (salesChange != null) {
|
|
if (salesChange > 0) {
|
|
insights.push(
|
|
`مبلغ فروش نسبت به دوره قبل ${salesChange}٪ افزایش داشته است.`,
|
|
);
|
|
} else if (salesChange < 0) {
|
|
insights.push(
|
|
`مبلغ فروش نسبت به دوره قبل ${Math.abs(salesChange)}٪ کاهش داشته است.`,
|
|
);
|
|
} else {
|
|
insights.push('مبلغ فروش نسبت به دوره قبل تغییری نداشته است.');
|
|
}
|
|
}
|
|
if (peopleCreated > 0) {
|
|
insights.push(
|
|
`${peopleCreated} مشتری جدید در این بازه ثبت شده است.`,
|
|
);
|
|
}
|
|
if (topProducts[0]) {
|
|
insights.push(
|
|
`پرفروشترین محصول «${topProducts[0].name}» با مبلغ ${topProducts[0].amount.toLocaleString('fa-IR')} است.`,
|
|
);
|
|
}
|
|
if (wonInPeriod.length + lostInPeriod.length > 0) {
|
|
insights.push(
|
|
`نرخ برد معاملات در این دوره ${winRatePct}٪ بوده است.`,
|
|
);
|
|
}
|
|
if (incomingCur.length > 0) {
|
|
insights.push(
|
|
`نرخ تبدیل ورودیها ${conversionPercent}٪ است (${convWonCur} از ${incomingCur.length} معامله).`,
|
|
);
|
|
}
|
|
if (invoiceCount > 0) {
|
|
insights.push(
|
|
`${invoiceCount} فاکتور صادر/پرداختشده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`,
|
|
);
|
|
}
|
|
if (sources[0] && sources[0].count > 0) {
|
|
insights.push(
|
|
`بیشترین منبع معاملات برنده «${sources[0].label}» بوده است.`,
|
|
);
|
|
}
|
|
if (lostReasons[0] && lostReasons[0].count > 0) {
|
|
insights.push(
|
|
`بیشترین دلیل شکست معاملات «${lostReasons[0].label}» بوده است (${lostReasons[0].count} مورد).`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
from: start.toISOString(),
|
|
to: end.toISOString(),
|
|
currency: rates.baseCurrency,
|
|
kpis,
|
|
salesTrend,
|
|
monthlyComparison,
|
|
customerGrowth,
|
|
sources,
|
|
lostReasons,
|
|
salesByCategory,
|
|
topProducts,
|
|
customerStatus,
|
|
activities: {
|
|
open: activityOpen,
|
|
done: activityDone,
|
|
overdue: activityOverdue,
|
|
byType: [...byTypeMap.entries()].map(([type, count]) => ({
|
|
type,
|
|
label: activityTypeLabel(type),
|
|
count,
|
|
})),
|
|
},
|
|
team: users
|
|
.map((u) => {
|
|
const t = teamMap.get(u.id)!;
|
|
return {
|
|
userId: u.id,
|
|
fullName: u.fullName,
|
|
wonAmount: Math.round(t.wonAmount),
|
|
wonCount: t.wonCount,
|
|
activitiesDone: t.activitiesDone,
|
|
};
|
|
})
|
|
.filter(
|
|
(u) =>
|
|
elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0,
|
|
)
|
|
.sort((a, b) => b.wonAmount - a.wonAmount),
|
|
insights: insights.slice(0, 5),
|
|
sales: {
|
|
openCount: openDeals.length,
|
|
wonCount: wonInPeriod.length,
|
|
lostCount: lostInPeriod.length,
|
|
openAmount: Math.round(sumBase(openDeals)),
|
|
wonAmount,
|
|
lostAmount: Math.round(sumBase(lostInPeriod)),
|
|
winRate: winRatePct,
|
|
},
|
|
funnel,
|
|
conversionRate,
|
|
periodCompare,
|
|
tickets: {
|
|
open: ticketOpen,
|
|
resolved: ticketResolved,
|
|
closed: ticketClosed,
|
|
slaBreached,
|
|
avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10,
|
|
csatCount: csatScores.length,
|
|
},
|
|
peopleCreated,
|
|
invoiceCount,
|
|
invoiceAmount,
|
|
};
|
|
}
|
|
|
|
async drilldown(
|
|
actor: Actor,
|
|
opts: {
|
|
kind: ReportsDrillKind;
|
|
id?: string;
|
|
from?: string;
|
|
to?: string;
|
|
},
|
|
): Promise<ReportsDrilldownDto> {
|
|
const { start, end } = resolveRange(opts.from, opts.to);
|
|
const orgId = actor.organizationId;
|
|
const dealScope = ownerWhere(actor, 'deals');
|
|
const rates = await loadOrgRates(this.prisma, orgId);
|
|
|
|
const baseSelect = {
|
|
id: true,
|
|
title: true,
|
|
status: true,
|
|
amount: true,
|
|
currency: true,
|
|
source: true,
|
|
lostReason: true,
|
|
updatedAt: true,
|
|
owner: { select: { fullName: true } },
|
|
stage: { select: { name: true } },
|
|
} as const;
|
|
|
|
let where: Prisma.DealWhereInput = {
|
|
organizationId: orgId,
|
|
...dealScope,
|
|
};
|
|
let title = 'فهرست معاملات';
|
|
|
|
switch (opts.kind) {
|
|
case 'stage_open': {
|
|
if (!opts.id?.trim()) {
|
|
throw new BadRequestException('شناسه مرحله الزامی است');
|
|
}
|
|
const stage = await this.prisma.pipelineStage.findFirst({
|
|
where: {
|
|
id: opts.id.trim(),
|
|
pipeline: { organizationId: orgId },
|
|
},
|
|
select: { name: true, pipeline: { select: { name: true } } },
|
|
});
|
|
if (!stage) throw new BadRequestException('مرحله پیدا نشد');
|
|
where = {
|
|
...where,
|
|
status: 'open',
|
|
stageId: opts.id.trim(),
|
|
};
|
|
title = `معاملات باز — ${stage.pipeline.name} / ${stage.name}`;
|
|
break;
|
|
}
|
|
case 'team_won': {
|
|
if (!opts.id?.trim()) {
|
|
throw new BadRequestException('شناسه کاربر الزامی است');
|
|
}
|
|
const user = await this.prisma.user.findFirst({
|
|
where: {
|
|
id: opts.id.trim(),
|
|
memberships: { some: { organizationId: orgId } },
|
|
},
|
|
select: { fullName: true },
|
|
});
|
|
if (!user) throw new BadRequestException('کاربر پیدا نشد');
|
|
where = {
|
|
...where,
|
|
status: 'won',
|
|
ownerId: opts.id.trim(),
|
|
updatedAt: { gte: start, lte: end },
|
|
};
|
|
title = `معاملات برنده — ${user.fullName}`;
|
|
break;
|
|
}
|
|
case 'period_won': {
|
|
where = {
|
|
...where,
|
|
status: 'won',
|
|
updatedAt: { gte: start, lte: end },
|
|
};
|
|
title = 'معاملات برنده این دوره';
|
|
break;
|
|
}
|
|
case 'period_lost': {
|
|
where = {
|
|
...where,
|
|
status: 'lost',
|
|
updatedAt: { gte: start, lte: end },
|
|
};
|
|
title = 'معاملات باخته این دوره';
|
|
break;
|
|
}
|
|
case 'source': {
|
|
const raw = (opts.id ?? '').trim();
|
|
if (!raw) throw new BadRequestException('منبع الزامی است');
|
|
const all = await this.prisma.deal.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...dealScope,
|
|
status: 'won',
|
|
updatedAt: { gte: start, lte: end },
|
|
},
|
|
select: baseSelect,
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 500,
|
|
});
|
|
const matched = all.filter(
|
|
(d) => normalizeSource(d.source) === raw,
|
|
);
|
|
return {
|
|
kind: opts.kind,
|
|
title: `معاملات برنده — منبع «${raw}»`,
|
|
from: start.toISOString(),
|
|
to: end.toISOString(),
|
|
total: matched.length,
|
|
items: matched.slice(0, DRILL_LIMIT).map((d) =>
|
|
toDrillItem(d, rates.baseCurrency),
|
|
),
|
|
};
|
|
}
|
|
case 'lost_reason': {
|
|
const raw = (opts.id ?? '').trim();
|
|
if (!raw) throw new BadRequestException('دلیل شکست الزامی است');
|
|
const all = await this.prisma.deal.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...dealScope,
|
|
status: 'lost',
|
|
updatedAt: { gte: start, lte: end },
|
|
},
|
|
select: baseSelect,
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 500,
|
|
});
|
|
const matched = all.filter(
|
|
(d) => normalizeLostReason(d.lostReason) === raw,
|
|
);
|
|
return {
|
|
kind: opts.kind,
|
|
title: `معاملات باخته — «${raw}»`,
|
|
from: start.toISOString(),
|
|
to: end.toISOString(),
|
|
total: matched.length,
|
|
items: matched.slice(0, DRILL_LIMIT).map((d) =>
|
|
toDrillItem(d, rates.baseCurrency),
|
|
),
|
|
};
|
|
}
|
|
default:
|
|
throw new BadRequestException('نوع گزارش نامعتبر است');
|
|
}
|
|
|
|
const [total, rows] = await Promise.all([
|
|
this.prisma.deal.count({ where }),
|
|
this.prisma.deal.findMany({
|
|
where,
|
|
select: baseSelect,
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: DRILL_LIMIT,
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
kind: opts.kind,
|
|
title,
|
|
from: start.toISOString(),
|
|
to: end.toISOString(),
|
|
total,
|
|
items: rows.map((d) => toDrillItem(d, rates.baseCurrency)),
|
|
};
|
|
}
|
|
|
|
async builder(
|
|
actor: Actor,
|
|
query: ReportsBuilderQuery,
|
|
): Promise<ReportsBuilderResultDto> {
|
|
const { start, end } = resolveRange(query.from, query.to);
|
|
const orgId = actor.organizationId;
|
|
const rates = await loadOrgRates(this.prisma, orgId);
|
|
const metric = query.metric;
|
|
let dimension = query.dimension;
|
|
|
|
const dealMetrics = new Set<ReportsBuilderMetric>([
|
|
'won_amount',
|
|
'won_count',
|
|
'lost_count',
|
|
'open_count',
|
|
'open_amount',
|
|
]);
|
|
const invoiceMetrics = new Set<ReportsBuilderMetric>([
|
|
'invoice_amount',
|
|
'invoice_count',
|
|
]);
|
|
|
|
if (
|
|
!dealMetrics.has(metric) &&
|
|
(dimension === 'source' ||
|
|
dimension === 'stage' ||
|
|
dimension === 'status')
|
|
) {
|
|
dimension = 'none';
|
|
}
|
|
|
|
const format: 'money' | 'number' =
|
|
metric === 'won_amount' ||
|
|
metric === 'open_amount' ||
|
|
metric === 'invoice_amount'
|
|
? 'money'
|
|
: 'number';
|
|
|
|
const ownerFilterIds = await this.resolveBuilderOwnerIds(actor, {
|
|
userId: query.userId,
|
|
teamId: query.teamId,
|
|
});
|
|
if (ownerFilterIds && ownerFilterIds.length === 0) {
|
|
return emptyBuilderResult(metric, dimension, start, end, rates.baseCurrency, format);
|
|
}
|
|
|
|
const dealOwnerWhere =
|
|
ownerFilterIds == null
|
|
? ownerWhere(actor, 'deals')
|
|
: { ownerId: { in: ownerFilterIds } };
|
|
const activityAssigneeWhere =
|
|
ownerFilterIds == null
|
|
? assigneeWhere(actor)
|
|
: { assigneeId: { in: ownerFilterIds } };
|
|
const personOwnerWhere =
|
|
ownerFilterIds == null
|
|
? elevated(actor.role)
|
|
? {}
|
|
: ownerWhere(actor)
|
|
: { ownerId: { in: ownerFilterIds } };
|
|
|
|
const buckets = new Map<string, { label: string; value: number }>();
|
|
const add = (key: string, label: string, value: number) => {
|
|
const cur = buckets.get(key) ?? { label, value: 0 };
|
|
cur.value += value;
|
|
buckets.set(key, cur);
|
|
};
|
|
|
|
const userName = async (userId: string | null | undefined) => {
|
|
if (!userId) return 'بدون مسئول';
|
|
const u = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { fullName: true },
|
|
});
|
|
return u?.fullName?.trim() || 'بدون مسئول';
|
|
};
|
|
|
|
let teamByUser = new Map<string, { id: string; name: string }>();
|
|
if (dimension === 'team' || query.teamId) {
|
|
teamByUser = await this.loadUserTeams(orgId);
|
|
}
|
|
|
|
const dimKey = async (opts: {
|
|
ownerId?: string | null;
|
|
source?: string | null;
|
|
stageId?: string | null;
|
|
stageName?: string | null;
|
|
status?: string | null;
|
|
at: Date;
|
|
}): Promise<{ key: string; label: string }> => {
|
|
if (dimension === 'none') return { key: 'total', label: 'جمع کل' };
|
|
if (dimension === 'owner') {
|
|
const id = opts.ownerId || 'none';
|
|
return { key: id, label: await userName(opts.ownerId) };
|
|
}
|
|
if (dimension === 'team') {
|
|
const t = opts.ownerId ? teamByUser.get(opts.ownerId) : undefined;
|
|
return t
|
|
? { key: t.id, label: t.name }
|
|
: { key: 'none', label: 'بدون تیم' };
|
|
}
|
|
if (dimension === 'source') {
|
|
const label = normalizeSource(opts.source);
|
|
return { key: label, label };
|
|
}
|
|
if (dimension === 'stage') {
|
|
const id = opts.stageId || 'none';
|
|
return { key: id, label: opts.stageName || 'بدون مرحله' };
|
|
}
|
|
if (dimension === 'status') {
|
|
const st = opts.status || 'open';
|
|
return { key: st, label: dealStatusLabel(st) };
|
|
}
|
|
// month
|
|
const j = dateToJalali(opts.at);
|
|
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
|
|
return { key, label: `${jalaliMonthName(j.jm)} ${j.jy}` };
|
|
};
|
|
|
|
if (dealMetrics.has(metric)) {
|
|
let statusFilter: 'won' | 'lost' | 'open';
|
|
if (metric === 'won_amount' || metric === 'won_count') statusFilter = 'won';
|
|
else if (metric === 'lost_count') statusFilter = 'lost';
|
|
else statusFilter = 'open';
|
|
|
|
const deals = await this.prisma.deal.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...dealOwnerWhere,
|
|
status: statusFilter,
|
|
...(statusFilter === 'open'
|
|
? { createdAt: { lte: end } }
|
|
: { updatedAt: { gte: start, lte: end } }),
|
|
},
|
|
select: {
|
|
amount: true,
|
|
currency: true,
|
|
ownerId: true,
|
|
source: true,
|
|
status: true,
|
|
stageId: true,
|
|
stage: { select: { name: true } },
|
|
updatedAt: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
for (const d of deals) {
|
|
const at = statusFilter === 'open' ? d.createdAt : d.updatedAt;
|
|
const dim = await dimKey({
|
|
ownerId: d.ownerId,
|
|
source: d.source,
|
|
stageId: d.stageId,
|
|
stageName: d.stage?.name,
|
|
status: d.status,
|
|
at,
|
|
});
|
|
const amount = decimalToBase(d.amount, d.currency, rates);
|
|
const value =
|
|
metric === 'won_amount' || metric === 'open_amount' ? amount : 1;
|
|
add(dim.key, dim.label, value);
|
|
}
|
|
} else if (metric === 'activities_done') {
|
|
const rows = await this.prisma.activity.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...activityAssigneeWhere,
|
|
status: 'done',
|
|
createdAt: { gte: start, lte: end },
|
|
},
|
|
select: { assigneeId: true, createdAt: true },
|
|
});
|
|
for (const a of rows) {
|
|
const dim = await dimKey({
|
|
ownerId: a.assigneeId,
|
|
at: a.createdAt,
|
|
});
|
|
add(dim.key, dim.label, 1);
|
|
}
|
|
} else if (metric === 'people_created') {
|
|
const rows = await this.prisma.person.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...personOwnerWhere,
|
|
createdAt: { gte: start, lte: end },
|
|
},
|
|
select: { ownerId: true, createdAt: true },
|
|
});
|
|
for (const p of rows) {
|
|
const dim = await dimKey({
|
|
ownerId: p.ownerId,
|
|
at: p.createdAt,
|
|
});
|
|
add(dim.key, dim.label, 1);
|
|
}
|
|
} else if (invoiceMetrics.has(metric)) {
|
|
const invoices = await this.prisma.invoice.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
...dealOwnerWhere,
|
|
status: { in: ['issued', 'partially_paid', 'paid'] },
|
|
OR: [
|
|
{ issuedAt: { gte: start, lte: end } },
|
|
{ issuedAt: null, createdAt: { gte: start, lte: end } },
|
|
],
|
|
},
|
|
select: {
|
|
ownerId: true,
|
|
issuedAt: true,
|
|
createdAt: true,
|
|
lineItems: {
|
|
select: { quantity: true, unitPrice: true, currency: true },
|
|
},
|
|
},
|
|
});
|
|
for (const inv of invoices) {
|
|
const at = inv.issuedAt ?? inv.createdAt;
|
|
const dim = await dimKey({ ownerId: inv.ownerId, at });
|
|
if (metric === 'invoice_count') {
|
|
add(dim.key, dim.label, 1);
|
|
} else {
|
|
let amount = 0;
|
|
for (const line of inv.lineItems) {
|
|
amount += toBaseAmount(
|
|
Number(line.quantity) * Number(line.unitPrice),
|
|
line.currency,
|
|
rates,
|
|
);
|
|
}
|
|
add(dim.key, dim.label, amount);
|
|
}
|
|
}
|
|
}
|
|
|
|
const rows: ReportsBuilderRowDto[] = [...buckets.entries()]
|
|
.map(([key, row]) => ({
|
|
key,
|
|
label: row.label,
|
|
value: Math.round(row.value * (format === 'money' ? 1 : 1)),
|
|
}))
|
|
.sort((a, b) => b.value - a.value);
|
|
|
|
if (format === 'money') {
|
|
for (const r of rows) r.value = Math.round(r.value);
|
|
}
|
|
|
|
const total = rows.reduce((a, b) => a + b.value, 0);
|
|
|
|
return {
|
|
metric,
|
|
metricLabel: REPORTS_BUILDER_METRIC_LABELS[metric],
|
|
dimension,
|
|
dimensionLabel: REPORTS_BUILDER_DIMENSION_LABELS[dimension],
|
|
from: start.toISOString(),
|
|
to: end.toISOString(),
|
|
currency: rates.baseCurrency,
|
|
format,
|
|
total: Math.round(total),
|
|
rows: dimension === 'none' && rows.length === 0
|
|
? [{ key: 'total', label: 'جمع کل', value: 0 }]
|
|
: dimension === 'none'
|
|
? [{ key: 'total', label: 'جمع کل', value: Math.round(total) }]
|
|
: rows,
|
|
};
|
|
}
|
|
|
|
private async resolveBuilderOwnerIds(
|
|
actor: Actor,
|
|
opts: { userId?: string; teamId?: string },
|
|
): Promise<string[] | null> {
|
|
const scope = ownerWhere(actor, 'deals');
|
|
let allowed: string[] | null = null;
|
|
if ('ownerId' in scope && typeof scope.ownerId === 'string') {
|
|
allowed = [scope.ownerId];
|
|
} else if (
|
|
'ownerId' in scope &&
|
|
scope.ownerId &&
|
|
typeof scope.ownerId === 'object' &&
|
|
'in' in scope.ownerId
|
|
) {
|
|
allowed = [...scope.ownerId.in];
|
|
}
|
|
|
|
let ids: string[] | null = null;
|
|
|
|
if (opts.teamId?.trim()) {
|
|
const members = await this.prisma.teamMember.findMany({
|
|
where: {
|
|
teamId: opts.teamId.trim(),
|
|
team: { organizationId: actor.organizationId },
|
|
},
|
|
select: { userId: true },
|
|
});
|
|
ids = members.map((m) => m.userId);
|
|
}
|
|
|
|
if (opts.userId?.trim()) {
|
|
const uid = opts.userId.trim();
|
|
ids = ids == null ? [uid] : ids.filter((id) => id === uid);
|
|
}
|
|
|
|
if (ids == null) return allowed;
|
|
if (allowed == null) return [...new Set(ids)];
|
|
return ids.filter((id) => allowed.includes(id));
|
|
}
|
|
|
|
private async loadUserTeams(
|
|
organizationId: string,
|
|
): Promise<Map<string, { id: string; name: string }>> {
|
|
const members = await this.prisma.teamMember.findMany({
|
|
where: { team: { organizationId } },
|
|
select: {
|
|
userId: true,
|
|
team: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { team: { name: 'asc' } },
|
|
});
|
|
const map = new Map<string, { id: string; name: string }>();
|
|
for (const m of members) {
|
|
if (!map.has(m.userId)) {
|
|
map.set(m.userId, { id: m.team.id, name: m.team.name });
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async home(actor: Actor): Promise<HomeDashboardDto> {
|
|
const orgId = actor.organizationId;
|
|
const dealScope = ownerWhere(actor, 'deals');
|
|
const activityScope = assigneeWhere(actor);
|
|
const personScope = customersPersonWhere(actor);
|
|
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 dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
|
|
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
|
|
|
const [
|
|
wonDeals,
|
|
openDeals,
|
|
staleDeals,
|
|
peopleCur,
|
|
peoplePrev,
|
|
openActivitiesToday,
|
|
openActivitiesYesterday,
|
|
openDealsWeekAgo,
|
|
goals,
|
|
overdueCount,
|
|
] = await Promise.all([
|
|
this.prisma.deal.findMany({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
status: 'won',
|
|
updatedAt: { gte: addMonthsStart(now, -5) },
|
|
},
|
|
dealScope,
|
|
dealsPrior,
|
|
],
|
|
},
|
|
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: {
|
|
AND: [
|
|
{ organizationId: orgId, status: 'open' },
|
|
dealScope,
|
|
dealsPrior,
|
|
],
|
|
},
|
|
}),
|
|
this.prisma.deal.findMany({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
status: 'open',
|
|
...dealScope,
|
|
OR: [
|
|
{ isStale: true },
|
|
{ isRotten: true },
|
|
{ updatedAt: { lt: daysAgo(5) } },
|
|
],
|
|
},
|
|
dealsPrior,
|
|
],
|
|
},
|
|
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: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
status: 'open',
|
|
dueAt: { gte: todayStart, lte: todayEnd },
|
|
},
|
|
activityScope,
|
|
activitiesPrior,
|
|
],
|
|
},
|
|
}),
|
|
this.prisma.activity.count({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
|
},
|
|
activityScope,
|
|
activitiesPrior,
|
|
],
|
|
},
|
|
}),
|
|
this.prisma.deal.count({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
status: 'open',
|
|
updatedAt: { lte: weekAgo },
|
|
},
|
|
dealScope,
|
|
dealsPrior,
|
|
],
|
|
},
|
|
}),
|
|
this.prisma.salesGoal.findMany({
|
|
where: {
|
|
organizationId: orgId,
|
|
periodStart: { lte: curMonthEnd },
|
|
periodEnd: { gte: curMonthStart },
|
|
},
|
|
select: { targetAmount: true },
|
|
}),
|
|
this.prisma.activity.count({
|
|
where: {
|
|
AND: [
|
|
{
|
|
organizationId: orgId,
|
|
status: 'open',
|
|
dueAt: { lt: now },
|
|
},
|
|
activityScope,
|
|
activitiesPrior,
|
|
],
|
|
},
|
|
}),
|
|
]);
|
|
|
|
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 } {
|
|
const end = to ? endOfDay(parseDay(to)) : endOfDay(new Date());
|
|
const start = from
|
|
? startOfDay(parseDay(from))
|
|
: startOfDay(new Date(end.getFullYear(), end.getMonth(), 1));
|
|
return { start, end };
|
|
}
|
|
|
|
function parseDay(value: string): Date {
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return new Date();
|
|
return d;
|
|
}
|
|
|
|
function startOfDay(d: Date): Date {
|
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function normalizeLostReason(raw: string | null | undefined): string {
|
|
const value = raw?.trim();
|
|
if (!value) return 'بدون دلیل ثبتشده';
|
|
return value;
|
|
}
|
|
|
|
function dealStatusLabel(status: string): string {
|
|
if (status === 'won') return 'برنده';
|
|
if (status === 'lost') return 'باخته';
|
|
if (status === 'open') return 'باز';
|
|
return status;
|
|
}
|
|
|
|
function toDrillItem(
|
|
d: {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
amount: Prisma.Decimal | null;
|
|
currency: string;
|
|
updatedAt: Date;
|
|
owner: { fullName: string } | null;
|
|
stage: { name: string } | null;
|
|
},
|
|
_baseCurrency: string,
|
|
): {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
statusLabel: string;
|
|
amount: number;
|
|
currency: string;
|
|
ownerName: string | null;
|
|
stageName: string | null;
|
|
updatedAt: string;
|
|
link: string;
|
|
} {
|
|
return {
|
|
id: d.id,
|
|
title: d.title,
|
|
status: d.status,
|
|
statusLabel: dealStatusLabel(d.status),
|
|
amount: d.amount == null ? 0 : Math.round(Number(d.amount)),
|
|
currency: d.currency,
|
|
ownerName: d.owner?.fullName ?? null,
|
|
stageName: d.stage?.name ?? null,
|
|
updatedAt: d.updatedAt.toISOString(),
|
|
link: `/deals/${d.id}`,
|
|
};
|
|
}
|
|
|
|
function emptyBuilderResult(
|
|
metric: ReportsBuilderMetric,
|
|
dimension: ReportsBuilderDimension,
|
|
start: Date,
|
|
end: Date,
|
|
currency: string,
|
|
format: 'money' | 'number',
|
|
): ReportsBuilderResultDto {
|
|
return {
|
|
metric,
|
|
metricLabel: REPORTS_BUILDER_METRIC_LABELS[metric],
|
|
dimension,
|
|
dimensionLabel: REPORTS_BUILDER_DIMENSION_LABELS[dimension],
|
|
from: start.toISOString(),
|
|
to: end.toISOString(),
|
|
currency,
|
|
format,
|
|
total: 0,
|
|
rows:
|
|
dimension === 'none'
|
|
? [{ key: 'total', label: 'جمع کل', value: 0 }]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function activityTypeLabel(type: string): string {
|
|
const map: Record<string, string> = {
|
|
call: 'تماس',
|
|
meeting: 'جلسه',
|
|
task: 'وظیفه',
|
|
note: 'یادداشت',
|
|
};
|
|
return map[type] ?? type;
|
|
}
|