Redesign sales goals and forecast with typed targets and live progress.

Match desktop/mobile mockups and compute current amounts, status, and KPIs from real CRM data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 18:38:11 +03:30
co-authored by Cursor
parent 1c1b77e74a
commit 32af147237
8 changed files with 1287 additions and 385 deletions
@@ -0,0 +1,6 @@
-- AlterTable SalesGoal: typed goals + forecast fields
ALTER TABLE "SalesGoal" ADD COLUMN IF NOT EXISTS "type" TEXT NOT NULL DEFAULT 'sales';
ALTER TABLE "SalesGoal" ADD COLUMN IF NOT EXISTS "forecastAmount" DECIMAL(18,2);
ALTER TABLE "SalesGoal" ADD COLUMN IF NOT EXISTS "probability" DECIMAL(5,2);
CREATE INDEX IF NOT EXISTS "SalesGoal_organizationId_type_idx" ON "SalesGoal"("organizationId", "type");
+6
View File
@@ -758,9 +758,14 @@ model SalesGoal {
userId String?
user User? @relation("SalesGoalUser", fields: [userId], references: [id], onDelete: Cascade)
title String
/// sales|customer|revenue|deal|activity|quality
type String @default("sales")
periodStart DateTime
periodEnd DateTime
targetAmount Decimal @db.Decimal(18, 2)
forecastAmount Decimal? @db.Decimal(18, 2)
/// 0-100
probability Decimal? @db.Decimal(5, 2)
currency String @default("IRR")
notes String?
createdAt DateTime @default(now())
@@ -768,6 +773,7 @@ model SalesGoal {
@@index([organizationId, periodStart, periodEnd])
@@index([userId])
@@index([organizationId, type])
}
model ExchangeRate {
+2
View File
@@ -1174,6 +1174,7 @@ async function main() {
data: {
organizationId: org.id,
userId: null,
type: 'sales',
title: 'هدف فروش سازمانی ماه جاری',
periodStart,
periodEnd,
@@ -1190,6 +1191,7 @@ async function main() {
data: {
organizationId: org.id,
userId: salesUser.id,
type: 'sales',
title: 'هدف شخصی کارشناس فروش',
periodStart,
periodEnd,
+67
View File
@@ -1,13 +1,30 @@
import {
IsIn,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
const SALES_GOAL_TYPES = [
'sales',
'customer',
'revenue',
'deal',
'activity',
'quality',
] as const;
const SALES_GOAL_STATUSES = [
'in_progress',
'completed',
'needs_attention',
] as const;
export class CreateSalesGoalDto {
@IsString()
@MinLength(2)
@@ -25,6 +42,24 @@ export class CreateSalesGoalDto {
@Min(0)
targetAmount!: number;
@IsOptional()
@IsString()
@IsIn(SALES_GOAL_TYPES)
type?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
forecastAmount?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(100)
probability?: number | null;
@IsOptional()
@IsString()
@MaxLength(8)
@@ -61,6 +96,24 @@ export class UpdateSalesGoalDto {
@Min(0)
targetAmount?: number;
@IsOptional()
@IsString()
@IsIn(SALES_GOAL_TYPES)
type?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
forecastAmount?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(100)
probability?: number | null;
@IsOptional()
@IsString()
@MaxLength(8)
@@ -102,4 +155,18 @@ export class ListGoalsQueryDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
@IsIn(SALES_GOAL_STATUSES)
status?: string;
@IsOptional()
@IsString()
@IsIn(SALES_GOAL_TYPES)
type?: string;
}
+8 -1
View File
@@ -52,7 +52,14 @@ export class SalesController {
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to, userId: query.userId },
{
from: query.from,
to: query.to,
userId: query.userId,
q: query.q,
status: query.status,
type: query.type,
},
);
}
+366 -17
View File
@@ -8,7 +8,10 @@ import type {
CreateSalesGoalRequest,
PaginatedResponse,
SalesForecastDto,
SalesForecastKpiDto,
SalesGoalDto,
SalesGoalStatus,
SalesGoalType,
UpdateSalesGoalRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -18,7 +21,11 @@ import {
elevated,
ownerWhere,
} from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
import {
decimalToBase,
loadOrgRates,
type RateMap,
} from '../common/currency';
const include = {
user: { select: { id: true, fullName: true } },
@@ -26,7 +33,16 @@ const include = {
type GoalRow = Prisma.SalesGoalGetPayload<{ include: typeof include }>;
const OPEN_WEIGHT = 0.4;
const GOAL_TYPES: SalesGoalType[] = [
'sales',
'customer',
'revenue',
'deal',
'activity',
'quality',
];
const DEFAULT_OPEN_WEIGHT = 0.4;
@Injectable()
export class SalesService {
@@ -34,7 +50,14 @@ export class SalesService {
async listGoals(
actor: Actor,
opts: { from?: string; to?: string; userId?: string } = {},
opts: {
from?: string;
to?: string;
userId?: string;
q?: string;
status?: string;
type?: string;
} = {},
): Promise<PaginatedResponse<SalesGoalDto>> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const where: Prisma.SalesGoalWhereInput = {
@@ -43,6 +66,13 @@ export class SalesService {
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
if (opts.type && isGoalType(opts.type)) {
where.type = opts.type;
}
const q = opts.q?.trim();
if (q) {
where.title = { contains: q, mode: 'insensitive' };
}
const rows = await this.prisma.salesGoal.findMany({
where,
@@ -51,9 +81,17 @@ export class SalesService {
take: 100,
});
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
let items = await Promise.all(
rows.map((row) => this.toGoalDto(row, rateMap)),
);
if (opts.status && isGoalStatus(opts.status)) {
items = items.filter((g) => g.status === opts.status);
}
return {
items: rows.map((row) => this.toGoalDto(row)),
total: rows.length,
items,
total: items.length,
page: 1,
limit: 100,
};
@@ -69,7 +107,8 @@ export class SalesService {
include,
});
if (!row) throw new NotFoundException('هدف پیدا نشد');
return this.toGoalDto(row);
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
return this.toGoalDto(row, rateMap);
}
async createGoal(
@@ -83,21 +122,30 @@ export class SalesService {
}
const userId = await this.resolveGoalUserId(actor, dto.userId);
const type = normalizeGoalType(dto.type);
const row = await this.prisma.salesGoal.create({
data: {
organizationId: actor.organizationId,
userId,
title: dto.title.trim(),
type,
periodStart,
periodEnd,
targetAmount: new Prisma.Decimal(dto.targetAmount),
forecastAmount:
dto.forecastAmount != null
? new Prisma.Decimal(dto.forecastAmount)
: null,
probability:
dto.probability != null ? new Prisma.Decimal(dto.probability) : null,
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
notes: emptyToNull(dto.notes ?? null),
},
include,
});
return this.toGoalDto(row);
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
return this.toGoalDto(row, rateMap);
}
async updateGoal(
@@ -128,6 +176,17 @@ export class SalesService {
if (dto.targetAmount !== undefined) {
data.targetAmount = new Prisma.Decimal(dto.targetAmount);
}
if (dto.type !== undefined) data.type = normalizeGoalType(dto.type);
if ('forecastAmount' in dto) {
data.forecastAmount =
dto.forecastAmount != null
? new Prisma.Decimal(dto.forecastAmount)
: null;
}
if ('probability' in dto) {
data.probability =
dto.probability != null ? new Prisma.Decimal(dto.probability) : null;
}
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
@@ -151,7 +210,8 @@ export class SalesService {
data,
include,
});
return this.toGoalDto(row);
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
return this.toGoalDto(row, rateMap);
}
async removeGoal(actor: Actor, id: string): Promise<void> {
@@ -186,7 +246,19 @@ export class SalesService {
...this.goalVisibility(actor, opts.userId),
};
const [goals, wonDeals, openDeals, rateMap] = await Promise.all([
const personOwner =
elevated(actor.role) && opts.userId
? { ownerId: opts.userId }
: ownerWhere(actor);
const [
goals,
wonDeals,
openDeals,
rateMap,
newCustomersCount,
newDealsCount,
] = await Promise.all([
this.prisma.salesGoal.findMany({
where: goalsWhere,
include,
@@ -210,13 +282,57 @@ export class SalesService {
select: { amount: true, currency: true },
}),
loadOrgRates(this.prisma, actor.organizationId),
this.prisma.person.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: start, lte: end },
...personOwner,
},
}),
this.prisma.deal.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: start, lte: end },
...dealOwner,
},
}),
]);
const targetAmount = goals.reduce(
(sum, g) =>
sum + decimalToBase(g.targetAmount, g.currency, rateMap),
const goalDtos = await Promise.all(
goals.map((g) => this.toGoalDto(g, rateMap)),
);
const salesGoals = goals.filter((g) => normalizeGoalType(g.type) === 'sales');
const customerGoals = goals.filter(
(g) => normalizeGoalType(g.type) === 'customer',
);
const dealGoals = goals.filter((g) => normalizeGoalType(g.type) === 'deal');
const targetAmount = salesGoals.reduce(
(sum, g) => sum + decimalToBase(g.targetAmount, g.currency, rateMap),
0,
);
const customerTarget = customerGoals.reduce(
(sum, g) => sum + Number(g.targetAmount),
0,
);
const dealTarget = dealGoals.reduce(
(sum, g) => sum + Number(g.targetAmount),
0,
);
const forecastTargets = goals
.map((g) =>
g.forecastAmount != null
? decimalToBase(g.forecastAmount, g.currency, rateMap)
: null,
)
.filter((v): v is number => v != null);
const forecastTarget =
forecastTargets.length > 0
? forecastTargets.reduce((a, b) => a + b, 0)
: targetAmount;
const wonAmount = wonDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
@@ -225,12 +341,46 @@ export class SalesService {
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const forecastAmount = wonAmount + openPipelineAmount * OPEN_WEIGHT;
const probs = goals
.map((g) => (g.probability != null ? Number(g.probability) / 100 : null))
.filter((v): v is number => v != null && !Number.isNaN(v));
const avgProbability =
probs.length > 0
? probs.reduce((a, b) => a + b, 0) / probs.length
: DEFAULT_OPEN_WEIGHT;
const forecastAmount = wonAmount + openPipelineAmount * avgProbability;
const progressPercent =
targetAmount > 0
? Math.round((wonAmount / targetAmount) * 1000) / 10
? Math.min(999, Math.round((wonAmount / targetAmount) * 1000) / 10)
: null;
const kpis: SalesForecastKpiDto[] = [
kpi(
'sales',
'هدف فروش ماه جاری',
wonAmount,
targetAmount,
'money',
),
kpi(
'customer',
'تعداد مشتریان جدید',
newCustomersCount,
customerTarget,
'count',
),
kpi('deal', 'تعداد معاملات جدید', newDealsCount, dealTarget, 'count'),
kpi(
'forecast',
'میزان پیش‌بینی درآمد',
forecastAmount,
forecastTarget,
'money',
),
];
return {
periodStart: start.toISOString(),
periodEnd: end.toISOString(),
@@ -242,7 +392,10 @@ export class SalesService {
progressPercent,
openDealsCount: openDeals.length,
wonDealsCount: wonDeals.length,
goals: goals.map((g) => this.toGoalDto(g)),
goals: goalDtos,
kpis,
newCustomersCount,
newDealsCount,
};
}
@@ -279,20 +432,216 @@ export class SalesService {
return user.id;
}
private toGoalDto(row: GoalRow): SalesGoalDto {
private async toGoalDto(
row: GoalRow,
rateMap: RateMap,
): Promise<SalesGoalDto> {
const currentAmount = await this.computeGoalCurrent(row, rateMap);
const target = Number(row.targetAmount);
const progressPercent = computeProgressPercent(currentAmount, target);
const status = computeGoalStatus(
progressPercent,
row.periodStart,
row.periodEnd,
);
return {
id: row.id,
title: row.title,
type: normalizeGoalType(row.type),
periodStart: row.periodStart.toISOString(),
periodEnd: row.periodEnd.toISOString(),
targetAmount: Number(row.targetAmount),
targetAmount: target,
forecastAmount:
row.forecastAmount != null ? Number(row.forecastAmount) : null,
probability: row.probability != null ? Number(row.probability) : null,
currency: row.currency,
notes: row.notes,
user: row.user,
currentAmount,
progressPercent,
status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async computeGoalCurrent(
goal: GoalRow,
rateMap: RateMap,
): Promise<number> {
const type = normalizeGoalType(goal.type);
const orgId = goal.organizationId;
const start = goal.periodStart;
const end = goal.periodEnd;
const scopeUserId = goal.userId;
switch (type) {
case 'sales': {
const deals = await this.prisma.deal.findMany({
where: {
organizationId: orgId,
status: 'won',
updatedAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
select: { amount: true, currency: true },
});
const base = deals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
return fromBaseToCurrency(base, goal.currency, rateMap);
}
case 'revenue': {
const payments = await this.prisma.payment.findMany({
where: {
paidAt: { gte: start, lte: end },
invoice: {
organizationId: orgId,
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
},
select: { amount: true, currency: true },
});
const base = payments.reduce(
(sum, p) => sum + decimalToBase(p.amount, p.currency, rateMap),
0,
);
return fromBaseToCurrency(base, goal.currency, rateMap);
}
case 'customer': {
return this.prisma.person.count({
where: {
organizationId: orgId,
createdAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
});
}
case 'deal': {
return this.prisma.deal.count({
where: {
organizationId: orgId,
createdAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
});
}
case 'activity': {
return this.prisma.activity.count({
where: {
organizationId: orgId,
status: 'done',
OR: [
{ completedAt: { gte: start, lte: end } },
{
AND: [
{ completedAt: null },
{ updatedAt: { gte: start, lte: end } },
],
},
],
...(scopeUserId ? { assigneeId: scopeUserId } : {}),
},
});
}
case 'quality': {
const whereBase: Prisma.DealWhereInput = {
organizationId: orgId,
status: { in: ['won', 'lost'] },
updatedAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
};
const [won, lost] = await Promise.all([
this.prisma.deal.count({
where: { ...whereBase, status: 'won' },
}),
this.prisma.deal.count({
where: { ...whereBase, status: 'lost' },
}),
]);
const closed = won + lost;
if (closed === 0) return 0;
return Math.round((won / closed) * 1000) / 10;
}
default:
return 0;
}
}
}
function kpi(
key: SalesForecastKpiDto['key'],
label: string,
current: number,
target: number,
unit: SalesForecastKpiDto['unit'],
): SalesForecastKpiDto {
return {
key,
label,
current,
target,
percent: computeProgressPercent(current, target),
unit,
};
}
function computeProgressPercent(current: number, target: number): number {
if (!(target > 0)) return 0;
return Math.min(999, Math.round((current / target) * 1000) / 10);
}
function computeGoalStatus(
progressPercent: number,
periodStart: Date,
periodEnd: Date,
now = new Date(),
): SalesGoalStatus {
if (progressPercent >= 100) return 'completed';
if (now > periodEnd && progressPercent < 100) return 'needs_attention';
const totalMs = periodEnd.getTime() - periodStart.getTime();
if (totalMs > 0) {
const elapsed = Math.min(
1,
Math.max(0, (now.getTime() - periodStart.getTime()) / totalMs),
);
if (elapsed > 0.66 && progressPercent < elapsed * 100 * 0.5) {
return 'needs_attention';
}
}
return 'in_progress';
}
function fromBaseToCurrency(
baseAmount: number,
currency: string,
rateMap: RateMap,
): number {
const code = (currency || rateMap.baseCurrency).trim() || rateMap.baseCurrency;
if (code === rateMap.baseCurrency) return baseAmount;
const rate = rateMap.rates.get(code);
if (rate == null || rate <= 0) return baseAmount;
return baseAmount / rate;
}
function normalizeGoalType(value?: string | null): SalesGoalType {
if (value && isGoalType(value)) return value;
return 'sales';
}
function isGoalType(value: string): value is SalesGoalType {
return (GOAL_TYPES as string[]).includes(value);
}
function isGoalStatus(value: string): value is SalesGoalStatus {
return (
value === 'in_progress' ||
value === 'completed' ||
value === 'needs_attention'
);
}
function resolvePeriod(from?: string, to?: string): { start: Date; end: Date } {
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -1097,15 +1097,31 @@ export interface CreatePaymentRequest {
installmentId?: string | null;
}
export type SalesGoalType =
| "sales"
| "customer"
| "revenue"
| "deal"
| "activity"
| "quality";
export type SalesGoalStatus = "in_progress" | "completed" | "needs_attention";
export interface SalesGoalDto {
id: string;
title: string;
type: SalesGoalType | string;
periodStart: string;
periodEnd: string;
targetAmount: number;
forecastAmount: number | null;
probability: number | null;
currency: string;
notes: string | null;
user: UserSummaryDto | null;
currentAmount: number;
progressPercent: number;
status: SalesGoalStatus;
createdAt: string;
updatedAt: string;
}
@@ -1115,6 +1131,10 @@ export interface CreateSalesGoalRequest {
periodStart: string;
periodEnd: string;
targetAmount: number;
type?: SalesGoalType | string;
forecastAmount?: number | null;
/** 0-100 */
probability?: number | null;
currency?: string;
notes?: string;
/** فقط مالک/مدیر؛ در غیر این صورت هدف برای خود کاربر */
@@ -1126,11 +1146,24 @@ export interface UpdateSalesGoalRequest {
periodStart?: string;
periodEnd?: string;
targetAmount?: number;
type?: SalesGoalType | string;
forecastAmount?: number | null;
/** 0-100 */
probability?: number | null;
currency?: string;
notes?: string | null;
userId?: string | null;
}
export interface SalesForecastKpiDto {
key: "sales" | "customer" | "deal" | "forecast";
label: string;
current: number;
target: number;
percent: number;
unit: "money" | "count";
}
export interface SalesForecastDto {
periodStart: string;
periodEnd: string;
@@ -1141,12 +1174,16 @@ export interface SalesForecastDto {
wonAmount: number;
/** معاملات باز فعلی */
openPipelineAmount: number;
/** پیش‌بینی ساده: تحقق + ۴۰٪ کاریز باز */
/** پیش‌بینی: تحقق + کاریز باز × میانگین احتمال */
forecastAmount: number;
progressPercent: number | null;
openDealsCount: number;
wonDealsCount: number;
goals: SalesGoalDto[];
kpis: SalesForecastKpiDto[];
newCustomersCount: number;
/** معاملات ایجادشده در بازه */
newDealsCount: number;
}
export type TicketStatus =