Redesign reports dashboard with full API-backed widgets.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
ownerWhere,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
@@ -29,24 +29,43 @@ export class ReportsService {
|
||||
const { start, end } = resolveRange(opts.from, opts.to);
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor);
|
||||
const invoiceScope = ownerWhere(actor);
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const ticketScope = ticketAccessWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
|
||||
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 { 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 [deals, stages, activities, tickets, peopleCreated, users] =
|
||||
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: {
|
||||
organizationId: orgId,
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ createdAt: { gte: start, lte: end } },
|
||||
{ status: 'open' },
|
||||
{
|
||||
status: { in: ['won', 'lost'] },
|
||||
updatedAt: { gte: start, lte: end },
|
||||
updatedAt: { gte: dealEarliest },
|
||||
},
|
||||
{ status: 'open' },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
@@ -56,8 +75,18 @@ export class ReportsService {
|
||||
currency: true,
|
||||
stageId: true,
|
||||
ownerId: true,
|
||||
source: 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({
|
||||
@@ -92,19 +121,78 @@ export class ReportsService {
|
||||
csatScore: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.person.count({
|
||||
this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
createdAt: { gte: start, lte: end },
|
||||
...(elevated(actor.role) ? {} : { ownerId: actor.userId }),
|
||||
...personScope,
|
||||
},
|
||||
select: { mood: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: { 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) =>
|
||||
@@ -118,6 +206,12 @@ export class ReportsService {
|
||||
d.updatedAt >= start &&
|
||||
d.updatedAt <= end,
|
||||
);
|
||||
const wonPrev = deals.filter(
|
||||
(d) =>
|
||||
d.status === 'won' &&
|
||||
d.updatedAt >= prevStart &&
|
||||
d.updatedAt <= prevEnd,
|
||||
);
|
||||
|
||||
const sumBase = (
|
||||
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
|
||||
@@ -127,9 +221,302 @@ export class ReportsService {
|
||||
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);
|
||||
|
||||
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 };
|
||||
@@ -149,7 +536,6 @@ export class ReportsService {
|
||||
};
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const activityOpen = activities.filter((a) => a.status === 'open').length;
|
||||
const activityDone = activities.filter((a) => a.status === 'done').length;
|
||||
const activityOverdue = activities.filter(
|
||||
@@ -203,26 +589,74 @@ export class ReportsService {
|
||||
? 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(
|
||||
`نرخ برد معاملات در این دوره ${Math.round(winRate * 1000) / 10}٪ بوده است.`,
|
||||
);
|
||||
}
|
||||
if (invoiceCount > 0) {
|
||||
insights.push(
|
||||
`${invoiceCount} فاکتور صادر/پرداختشده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`,
|
||||
);
|
||||
}
|
||||
if (sources[0] && sources[0].count > 0) {
|
||||
insights.push(
|
||||
`بیشترین منبع معاملات برنده «${sources[0].label}» بوده است.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: start.toISOString(),
|
||||
to: end.toISOString(),
|
||||
currency: rates.baseCurrency,
|
||||
sales: {
|
||||
openCount: openDeals.length,
|
||||
wonCount: wonInPeriod.length,
|
||||
lostCount: lostInPeriod.length,
|
||||
openAmount: Math.round(sumBase(openDeals)),
|
||||
wonAmount: Math.round(sumBase(wonInPeriod)),
|
||||
lostAmount: Math.round(sumBase(lostInPeriod)),
|
||||
winRate: Math.round(winRate * 1000) / 10,
|
||||
},
|
||||
funnel,
|
||||
kpis,
|
||||
salesTrend,
|
||||
monthlyComparison,
|
||||
customerGrowth,
|
||||
sources,
|
||||
salesByCategory,
|
||||
topProducts,
|
||||
customerStatus,
|
||||
activities: {
|
||||
open: activityOpen,
|
||||
done: activityDone,
|
||||
overdue: activityOverdue,
|
||||
byType: [...byTypeMap.entries()].map(([type, count]) => ({
|
||||
type,
|
||||
label: activityTypeLabel(type),
|
||||
count,
|
||||
})),
|
||||
},
|
||||
@@ -242,6 +676,17 @@ export class ReportsService {
|
||||
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: Math.round(winRate * 1000) / 10,
|
||||
},
|
||||
funnel,
|
||||
tickets: {
|
||||
open: ticketOpen,
|
||||
resolved: ticketResolved,
|
||||
@@ -251,6 +696,8 @@ export class ReportsService {
|
||||
csatCount: csatScores.length,
|
||||
},
|
||||
peopleCreated,
|
||||
invoiceCount,
|
||||
invoiceAmount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -604,3 +1051,13 @@ function normalizeSource(raw: string | null | undefined): string {
|
||||
};
|
||||
return map[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
function activityTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
return map[type] ?? type;
|
||||
}
|
||||
|
||||
+903
-285
File diff suppressed because it is too large
Load Diff
@@ -1301,10 +1301,62 @@ export interface PortalTicketDto {
|
||||
|
||||
/* ——— گزارشات و داشبورد ——— */
|
||||
|
||||
export interface ReportsKpiDto {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
format: "money" | "number";
|
||||
changePercent: number | null;
|
||||
changeLabel: string;
|
||||
}
|
||||
|
||||
export interface ReportsSeriesPointDto {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ReportsSliceDto {
|
||||
label: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface ReportsTopProductDto {
|
||||
name: string;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ReportsTeamRowDto {
|
||||
userId: string;
|
||||
fullName: string;
|
||||
wonAmount: number;
|
||||
wonCount: number;
|
||||
activitiesDone: number;
|
||||
}
|
||||
|
||||
export interface ReportsDashboardDto {
|
||||
from: string;
|
||||
to: string;
|
||||
currency: string;
|
||||
kpis: ReportsKpiDto[];
|
||||
salesTrend: ReportsSeriesPointDto[];
|
||||
monthlyComparison: ReportsSeriesPointDto[];
|
||||
customerGrowth: ReportsSeriesPointDto[];
|
||||
sources: ReportsSliceDto[];
|
||||
salesByCategory: ReportsSliceDto[];
|
||||
topProducts: ReportsTopProductDto[];
|
||||
customerStatus: ReportsSliceDto[];
|
||||
activities: {
|
||||
open: number;
|
||||
done: number;
|
||||
overdue: number;
|
||||
byType: Array<{ type: string; label: string; count: number }>;
|
||||
};
|
||||
team: ReportsTeamRowDto[];
|
||||
insights: string[];
|
||||
sales: {
|
||||
openCount: number;
|
||||
wonCount: number;
|
||||
@@ -1321,19 +1373,6 @@ export interface ReportsDashboardDto {
|
||||
count: number;
|
||||
amount: number;
|
||||
}>;
|
||||
activities: {
|
||||
open: number;
|
||||
done: number;
|
||||
overdue: number;
|
||||
byType: Array<{ type: string; count: number }>;
|
||||
};
|
||||
team: Array<{
|
||||
userId: string;
|
||||
fullName: string;
|
||||
wonAmount: number;
|
||||
wonCount: number;
|
||||
activitiesDone: number;
|
||||
}>;
|
||||
tickets: {
|
||||
open: number;
|
||||
resolved: number;
|
||||
@@ -1343,6 +1382,8 @@ export interface ReportsDashboardDto {
|
||||
csatCount: number;
|
||||
};
|
||||
peopleCreated: number;
|
||||
invoiceCount: number;
|
||||
invoiceAmount: number;
|
||||
}
|
||||
|
||||
export interface HomeDashboardKpiDto {
|
||||
|
||||
Reference in New Issue
Block a user