feat(leads): edit lead-board columns from the board

New-lead counts follow the first open column instead of its name, and cards no longer show an initials mark.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-29 17:23:40 +03:30
co-authored by Cursor
parent 6f42440b3b
commit 0d5e1034e5
4 changed files with 486 additions and 59 deletions
+42 -17
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { LeadStageDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{
@@ -74,20 +75,24 @@ export class LeadStagesService {
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.leadStage.create({
data: {
organizationId,
name: dto.name.trim(),
color: dto.color?.trim() || 'teal',
showOnBoard: dto.showOnBoard !== false,
isConverted: Boolean(dto.isConverted),
isLost: Boolean(dto.isLost),
isUnqualified: Boolean(dto.isUnqualified),
position: (max._max.position ?? -1) + 1,
},
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
try {
const row = await this.prisma.leadStage.create({
data: {
organizationId,
name: this.cleanName(dto.name),
color: dto.color?.trim() || 'teal',
showOnBoard: dto.showOnBoard !== false,
isConverted: Boolean(dto.isConverted),
isLost: Boolean(dto.isLost),
isUnqualified: Boolean(dto.isUnqualified),
position: (max._max.position ?? -1) + 1,
},
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
}
async update(
@@ -103,10 +108,11 @@ export class LeadStagesService {
position?: number;
},
): Promise<LeadStageDto> {
try {
await this.prisma.leadStage.updateMany({
where: { id, organizationId },
data: {
...(dto.name != null ? { name: dto.name.trim() } : {}),
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: dto.color } : {}),
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
@@ -122,6 +128,9 @@ export class LeadStagesService {
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
}
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
@@ -143,11 +152,27 @@ export class LeadStagesService {
});
if (!stage) return;
if (stage._count.leads > 0) {
throw new Error('مرحله‌ای که لید دارد قابل حذف نیست');
throw new BadRequestException(
'این ستون هنوز لید دارد و حذف نمی‌شود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.',
);
}
await this.prisma.leadStage.delete({ where: { id } });
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام ستون لازم است');
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
return name;
}
private rethrow(e: unknown): never {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('ستونی با این نام وجود دارد');
}
throw e;
}
private toDto(row: {
id: string;
name: string;
+1
View File
@@ -107,6 +107,7 @@ export class LeadsController {
await this.stages.remove(user.organizationId, id);
return { ok: true };
} catch (e) {
if (e instanceof BadRequestException) throw e;
throw new BadRequestException(
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
);
+5 -9
View File
@@ -979,8 +979,10 @@ export class LeadsService {
const stages = await this.stages.list(actor.organizationId);
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
const interested = stages.find((s) => s.name.includes('علاقه‌مند'));
const newStage = stages.find((s) => s.name === 'جدید');
// همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید»
const newStage = stages.find(
(s) => !s.isConverted && !s.isLost && !s.isUnqualified,
);
const [
total,
@@ -988,7 +990,6 @@ export class LeadsService {
unassigned,
converted,
lost,
interestedCount,
withFollowUp,
callsLogged,
byAssigneeRaw,
@@ -1008,11 +1009,6 @@ export class LeadsService {
where: { ...base, stageId: { in: lostIds } },
})
: Promise.resolve(0),
interested
? this.prisma.lead.count({
where: { ...base, stageId: interested.id },
})
: Promise.resolve(0),
this.prisma.lead.count({
where: { ...base, nextFollowUpAt: { not: null } },
}),
@@ -1055,7 +1051,7 @@ export class LeadsService {
.sort((a, b) => b.count - a.count),
callsLogged,
withFollowUp,
interested: interestedCount,
interested: 0,
converted,
lost,
conversionRate: Math.round((converted / denom) * 1000) / 10,