From 32af147237d102bc9673c63381fa665c4b12bee8 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 18:38:11 +0330 Subject: [PATCH] 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 --- .../migration.sql | 6 + apps/api/prisma/schema.prisma | 6 + apps/api/prisma/seed.ts | 2 + apps/api/src/sales/dto.ts | 67 + apps/api/src/sales/sales.controller.ts | 9 +- apps/api/src/sales/sales.service.ts | 383 +++++- apps/web/src/views/SalesForecastView.vue | 1160 +++++++++++------ packages/shared/src/index.ts | 39 +- 8 files changed, 1287 insertions(+), 385 deletions(-) create mode 100644 apps/api/prisma/migrations/20260905180000_sales_goal_types/migration.sql diff --git a/apps/api/prisma/migrations/20260905180000_sales_goal_types/migration.sql b/apps/api/prisma/migrations/20260905180000_sales_goal_types/migration.sql new file mode 100644 index 0000000..427b4e2 --- /dev/null +++ b/apps/api/prisma/migrations/20260905180000_sales_goal_types/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1779ba0..9912463 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 { diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index da9b0cc..57d8618 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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, diff --git a/apps/api/src/sales/dto.ts b/apps/api/src/sales/dto.ts index 691138f..41441d1 100644 --- a/apps/api/src/sales/dto.ts +++ b/apps/api/src/sales/dto.ts @@ -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; } diff --git a/apps/api/src/sales/sales.controller.ts b/apps/api/src/sales/sales.controller.ts index 5710c07..99eb699 100644 --- a/apps/api/src/sales/sales.controller.ts +++ b/apps/api/src/sales/sales.controller.ts @@ -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, + }, ); } diff --git a/apps/api/src/sales/sales.service.ts b/apps/api/src/sales/sales.service.ts index 4522bc8..28078b3 100644 --- a/apps/api/src/sales/sales.service.ts +++ b/apps/api/src/sales/sales.service.ts @@ -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> { 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 { @@ -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 { + 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 { + 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 } { diff --git a/apps/web/src/views/SalesForecastView.vue b/apps/web/src/views/SalesForecastView.vue index 60268bf..dfba043 100644 --- a/apps/web/src/views/SalesForecastView.vue +++ b/apps/web/src/views/SalesForecastView.vue @@ -1,21 +1,44 @@