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:
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user