import { Injectable } from '@nestjs/common'; import type { HomeDashboardDto, ReportsDashboardDto } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { type Actor, assigneeWhere, elevated, ownerWhere, ticketAccessWhere, } from '../common/record-scope'; import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency'; import { addJalaliMonths, dateToJalali, endOfJalaliMonth, jalaliMonthName, startOfJalaliMonth, } from '../common/jalali'; @Injectable() export class ReportsService { constructor(private readonly prisma: PrismaService) {} async dashboard( actor: Actor, opts: { from?: string; to?: string }, ): Promise { 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 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: [ { status: 'open' }, { status: { in: ['won', 'lost'] }, updatedAt: { gte: dealEarliest }, }, ], }, select: { id: true, status: true, amount: true, 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({ where: { pipeline: { organizationId: orgId } }, include: { pipeline: { select: { name: true } } }, orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }], }), this.prisma.activity.findMany({ where: { organizationId: orgId, ...activityScope, createdAt: { gte: start, lte: end }, }, select: { id: true, type: true, status: true, dueAt: true, assigneeId: 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: { 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 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(); 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(); 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(); 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 = stages.map((s) => { const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 }; return { stageId: s.id, stageName: s.name, pipelineName: s.pipeline.name, count: agg.count, amount: Math.round(agg.amount), }; }); const activityOpen = activities.filter((a) => a.status === 'open').length; const activityDone = activities.filter((a) => a.status === 'done').length; const activityOverdue = activities.filter( (a) => a.status === 'open' && a.dueAt != null && a.dueAt.getTime() < now.getTime(), ).length; const byTypeMap = new Map(); for (const a of activities) { 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 activities) { if (a.status !== 'done' || !a.assigneeId) continue; const t = teamMap.get(a.assigneeId); if (t) t.activitiesDone += 1; } 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( `نرخ برد معاملات در این دوره ${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, 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, })), }, 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: Math.round(winRate * 1000) / 10, }, funnel, tickets: { open: ticketOpen, resolved: ticketResolved, closed: ticketClosed, slaBreached, avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10, csatCount: csatScores.length, }, peopleCreated, invoiceCount, invoiceAmount, }; } async home(actor: Actor): Promise { 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(); 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 = { referral: 'معرفی دوستان', website: 'وب‌سایت', social: 'شبکه‌های اجتماعی', ads: 'تبلیغات', instagram: 'شبکه‌های اجتماعی', google: 'تبلیغات', }; return map[value.toLowerCase()] ?? value; } function activityTypeLabel(type: string): string { const map: Record = { call: 'تماس', meeting: 'جلسه', task: 'وظیفه', note: 'یادداشت', }; return map[type] ?? type; }