Add standalone Leads module for prospect outreach before customers.

Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 17:05:11 +03:30
co-authored by Cursor
parent 22980e5152
commit c079295ff4
24 changed files with 4357 additions and 9 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ SMS_PASSWORD=
SMS_SENDER=
# SMS_BASE_URL=http://smspanel.Trez.ir
# SMS_SEND_PATH=/SendMessageWithCode.ashx
# SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
# SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
# SMS_TIMEOUT_MS=20000
# رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت به‌صورت hex (۶۴ کاراکتر)
@@ -0,0 +1,113 @@
-- CreateTable
CREATE TABLE "LeadStage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'teal',
"showOnBoard" BOOLEAN NOT NULL DEFAULT true,
"isConverted" BOOLEAN NOT NULL DEFAULT false,
"isLost" BOOLEAN NOT NULL DEFAULT false,
"isUnqualified" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadStage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"contactName" TEXT,
"mobile" TEXT NOT NULL,
"phone" TEXT,
"city" TEXT,
"region" TEXT,
"trade" TEXT,
"source" TEXT,
"notes" TEXT,
"estimatedValue" DECIMAL(18,2),
"stageId" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"assigneeId" TEXT,
"createdById" TEXT,
"lastContactAt" TIMESTAMP(3),
"nextFollowUpAt" TIMESTAMP(3),
"convertedPersonId" TEXT,
"convertedAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadEvent" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT,
"meta" JSONB,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeadEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadImportJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"createdById" TEXT,
"fileName" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"totalCount" INTEGER NOT NULL DEFAULT 0,
"processedCount" INTEGER NOT NULL DEFAULT 0,
"successCount" INTEGER NOT NULL DEFAULT 0,
"duplicateCount" INTEGER NOT NULL DEFAULT 0,
"errorCount" INTEGER NOT NULL DEFAULT 0,
"rows" JSONB NOT NULL DEFAULT '[]',
"errorSamples" JSONB NOT NULL DEFAULT '[]',
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadImportJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LeadStage_organizationId_position_idx" ON "LeadStage"("organizationId", "position");
CREATE UNIQUE INDEX "LeadStage_organizationId_name_key" ON "LeadStage"("organizationId", "name");
CREATE UNIQUE INDEX "Lead_organizationId_mobile_key" ON "Lead"("organizationId", "mobile");
CREATE INDEX "Lead_organizationId_stageId_position_idx" ON "Lead"("organizationId", "stageId", "position");
CREATE INDEX "Lead_organizationId_assigneeId_idx" ON "Lead"("organizationId", "assigneeId");
CREATE INDEX "Lead_organizationId_nextFollowUpAt_idx" ON "Lead"("organizationId", "nextFollowUpAt");
CREATE INDEX "Lead_organizationId_lastContactAt_idx" ON "Lead"("organizationId", "lastContactAt");
CREATE INDEX "Lead_organizationId_archivedAt_idx" ON "Lead"("organizationId", "archivedAt");
CREATE INDEX "Lead_organizationId_createdAt_idx" ON "Lead"("organizationId", "createdAt");
CREATE INDEX "Lead_organizationId_city_idx" ON "Lead"("organizationId", "city");
CREATE INDEX "Lead_organizationId_trade_idx" ON "Lead"("organizationId", "trade");
CREATE INDEX "Lead_organizationId_source_idx" ON "Lead"("organizationId", "source");
CREATE INDEX "LeadEvent_leadId_createdAt_idx" ON "LeadEvent"("leadId", "createdAt");
CREATE INDEX "LeadEvent_organizationId_createdAt_idx" ON "LeadEvent"("organizationId", "createdAt");
CREATE INDEX "LeadImportJob_organizationId_status_createdAt_idx" ON "LeadImportJob"("organizationId", "status", "createdAt");
CREATE INDEX "LeadImportJob_status_createdAt_idx" ON "LeadImportJob"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "LeadStage" ADD CONSTRAINT "LeadStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "LeadStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "LeadImportJob" ADD CONSTRAINT "LeadImportJob_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+119
View File
@@ -203,6 +203,10 @@ model Organization {
voipConnections VoipConnection[]
reservationServices ReservationService[]
reservationAppointments ReservationAppointment[]
leads Lead[]
leadStages LeadStage[]
leadEvents LeadEvent[]
leadImportJobs LeadImportJob[]
}
model User {
@@ -251,6 +255,8 @@ model User {
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[]
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
assignedLeads Lead[] @relation("LeadAssignee")
createdLeads Lead[] @relation("LeadCreator")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1922,3 +1928,116 @@ model ReservationAppointment {
@@index([personId])
@@index([assigneeId, startsAt])
}
/// مرحله/وضعیت لید — هم لیست وضعیت و هم ستون Kanban
model LeadStage {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
position Int @default(0)
color String @default("teal")
/// نمایش در تخته Kanban
showOnBoard Boolean @default(true)
isConverted Boolean @default(false)
isLost Boolean @default(false)
isUnqualified Boolean @default(false)
leads Lead[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, position])
@@unique([organizationId, name])
}
/// مخاطب بالقوه — جدا از Person/مشتری
model Lead {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// نام فروشگاه / شخص / عنوان نمایشی
title String
/// نام مالک یا مسئول
contactName String?
mobile String
phone String?
city String?
region String?
/// صنف
trade String?
source String?
notes String?
estimatedValue Decimal? @db.Decimal(18, 2)
stageId String
stage LeadStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
position Int @default(0)
assigneeId String?
assignee User? @relation("LeadAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
createdById String?
createdBy User? @relation("LeadCreator", fields: [createdById], references: [id], onDelete: SetNull)
lastContactAt DateTime?
nextFollowUpAt DateTime?
/// شخص مشتری پس از تبدیل
convertedPersonId String?
convertedAt DateTime?
archivedAt DateTime?
events LeadEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, mobile])
@@index([organizationId, stageId, position])
@@index([organizationId, assigneeId])
@@index([organizationId, nextFollowUpAt])
@@index([organizationId, lastContactAt])
@@index([organizationId, archivedAt])
@@index([organizationId, createdAt])
@@index([organizationId, city])
@@index([organizationId, trade])
@@index([organizationId, source])
}
model LeadEvent {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
/// created | assigned | stage_changed | call | note | task | follow_up | converted | archived | import
type String
title String
body String?
meta Json?
actorId String?
createdAt DateTime @default(now())
@@index([leadId, createdAt])
@@index([organizationId, createdAt])
}
/// ایمپورت پس‌زمینه فایل اکسل لید
model LeadImportJob {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdById String?
fileName String?
/// pending | processing | done | failed
status String @default("pending")
totalCount Int @default(0)
processedCount Int @default(0)
successCount Int @default(0)
duplicateCount Int @default(0)
errorCount Int @default(0)
/// ردیف‌های خام [{...}]
rows Json @default("[]")
/// خلاصه خطاها
errorSamples Json @default("[]")
startedAt DateTime?
finishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, status, createdAt])
@@index([status, createdAt])
}
+2
View File
@@ -42,6 +42,7 @@ import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
@Module({
imports: [
@@ -61,6 +62,7 @@ import { ReservationsModule } from './reservations/reservations.module';
OrgRolesModule,
OrganizationsModule,
ContactsModule,
LeadsModule,
PipelinesModule,
ActivitiesModule,
NotesModule,
+2 -1
View File
@@ -77,7 +77,8 @@ function maskEmail(email: string): string {
function maskMobile(mobile: string): string {
if (mobile.length < 4) return '****';
return `${mobile.slice(0, 4)}***${mobile.slice(-2)}`;
// نمایش RTL: دو رقم آخر سپس *** سپس پیش‌شماره (مثلاً 69***0913)
return `${mobile.slice(-2)}***${mobile.slice(0, 4)}`;
}
function generateOtp(): string {
+2 -3
View File
@@ -1,6 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { brand } from '@mento/shared';
/** پنل ترِز / رایگان — همان الگوی montime */
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
@@ -44,8 +43,8 @@ export class SmsService {
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ??
`کد ورود پرتال ${brand.nameFa}: {code}`;
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
`کد ورود منتکت: {code}`;
const text = template.replace('{code}', code);
const result = await this.sendSmsDetailed(mobile, text);
return result.ok;
+119
View File
@@ -0,0 +1,119 @@
import {
IsArray,
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class CreateLeadDto {
@IsString()
@MinLength(1)
title!: string;
@IsString()
@MinLength(5)
mobile!: string;
@IsOptional() @IsString() contactName?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() city?: string;
@IsOptional() @IsString() region?: string;
@IsOptional() @IsString() trade?: string;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsNumber() estimatedValue?: number;
@IsOptional() @IsString() stageId?: string;
@IsOptional() @IsString() assigneeId?: string | null;
@IsOptional() @IsString() nextFollowUpAt?: string | null;
}
export class UpdateLeadDto {
@IsOptional() @IsString() @MinLength(1) title?: string;
@IsOptional() @IsString() @MinLength(5) mobile?: string;
@IsOptional() @IsString() contactName?: string | null;
@IsOptional() @IsString() phone?: string | null;
@IsOptional() @IsString() city?: string | null;
@IsOptional() @IsString() region?: string | null;
@IsOptional() @IsString() trade?: string | null;
@IsOptional() @IsString() source?: string | null;
@IsOptional() @IsString() notes?: string | null;
@IsOptional() @IsNumber() estimatedValue?: number | null;
@IsOptional() @IsString() stageId?: string;
@IsOptional() @IsString() assigneeId?: string | null;
@IsOptional() @IsString() nextFollowUpAt?: string | null;
@IsOptional() @IsString() lastContactAt?: string | null;
}
export class MoveLeadDto {
@IsString() stageId!: string;
@IsOptional() @IsNumber() position?: number;
}
export class AssignLeadsDto {
@IsArray() @IsString({ each: true }) leadIds!: string[];
@IsString() assigneeId!: string;
}
export class BulkLeadStageDto {
@IsArray() @IsString({ each: true }) leadIds!: string[];
@IsString() stageId!: string;
}
export class AutoAssignLeadsDto {
@IsOptional() @IsIn(['round_robin', 'equal_split']) rule?: string;
@IsOptional() @IsArray() @IsString({ each: true }) leadIds?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) assigneeIds?: string[];
@IsOptional() @IsBoolean() onlyUnassigned?: boolean;
}
export class LeadCallLogDto {
@IsOptional() @IsString() outcome?: string;
@IsOptional() @IsString() body?: string;
@IsOptional() @IsString() nextFollowUpAt?: string | null;
@IsOptional() @IsString() stageId?: string;
}
export class LeadNoteDto {
@IsString() @MinLength(1) body!: string;
}
export class LeadFollowUpDto {
@IsString() nextFollowUpAt!: string;
@IsOptional() @IsString() body?: string;
}
export class ConvertLeadDto {
@IsOptional() @IsString() firstName?: string;
@IsOptional() @IsString() lastName?: string;
}
export class CreateLeadStageDto {
@IsString() @MinLength(1) name!: string;
@IsOptional() @IsString() color?: string;
@IsOptional() @IsBoolean() showOnBoard?: boolean;
@IsOptional() @IsBoolean() isConverted?: boolean;
@IsOptional() @IsBoolean() isLost?: boolean;
@IsOptional() @IsBoolean() isUnqualified?: boolean;
}
export class UpdateLeadStageDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() color?: string;
@IsOptional() @IsBoolean() showOnBoard?: boolean;
@IsOptional() @IsBoolean() isConverted?: boolean;
@IsOptional() @IsBoolean() isLost?: boolean;
@IsOptional() @IsBoolean() isUnqualified?: boolean;
@IsOptional() @IsNumber() position?: number;
}
export class ReorderLeadStagesDto {
@IsArray() @IsString({ each: true }) stageIds!: string[];
}
export class ImportLeadsDto {
@IsArray() rows!: Array<Record<string, unknown>>;
@IsOptional() @IsString() fileName?: string;
}
+172
View File
@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import type { LeadStageDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{
name: string;
color: string;
isConverted?: boolean;
isLost?: boolean;
isUnqualified?: boolean;
}> = [
{ name: 'جدید', color: 'sky' },
{ name: 'تماس اولیه', color: 'teal' },
{ name: 'عدم پاسخ', color: 'amber' },
{ name: 'پیگیری مجدد', color: 'amber' },
{ name: 'تماس موفق', color: 'mint' },
{ name: 'علاقه‌مند', color: 'violet' },
{ name: 'واجد شرایط', color: 'violet' },
{ name: 'مذاکره', color: 'peach' },
{ name: 'آماده خرید', color: 'rose' },
{ name: 'تبدیل‌شده', color: 'teal', isConverted: true },
{ name: 'از دست رفته', color: 'rose', isLost: true },
{ name: 'نامرتبط', color: 'amber', isUnqualified: true },
];
@Injectable()
export class LeadStagesService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> {
const count = await this.prisma.leadStage.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.leadStage.createMany({
data: DEFAULT_STAGES.map((s, i) => ({
organizationId,
name: s.name,
position: i,
color: s.color,
showOnBoard: true,
isConverted: Boolean(s.isConverted),
isLost: Boolean(s.isLost),
isUnqualified: Boolean(s.isUnqualified),
})),
});
}
async list(organizationId: string): Promise<LeadStageDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.leadStage.findMany({
where: { organizationId },
orderBy: { position: 'asc' },
include: { _count: { select: { leads: true } } },
});
return rows.map((r) => this.toDto(r));
}
async create(
organizationId: string,
dto: {
name: string;
color?: string;
showOnBoard?: boolean;
isConverted?: boolean;
isLost?: boolean;
isUnqualified?: boolean;
},
): Promise<LeadStageDto> {
await this.ensureDefaults(organizationId);
const max = await this.prisma.leadStage.aggregate({
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);
}
async update(
organizationId: string,
id: string,
dto: {
name?: string;
color?: string;
showOnBoard?: boolean;
isConverted?: boolean;
isLost?: boolean;
isUnqualified?: boolean;
position?: number;
},
): Promise<LeadStageDto> {
await this.prisma.leadStage.updateMany({
where: { id, organizationId },
data: {
...(dto.name != null ? { name: dto.name.trim() } : {}),
...(dto.color != null ? { color: dto.color } : {}),
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
...(dto.isLost != null ? { isLost: dto.isLost } : {}),
...(dto.isUnqualified != null
? { isUnqualified: dto.isUnqualified }
: {}),
...(dto.position != null ? { position: dto.position } : {}),
},
});
const row = await this.prisma.leadStage.findFirstOrThrow({
where: { id, organizationId },
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
}
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
await this.prisma.$transaction(
stageIds.map((id, position) =>
this.prisma.leadStage.updateMany({
where: { id, organizationId },
data: { position },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const stage = await this.prisma.leadStage.findFirst({
where: { id, organizationId },
include: { _count: { select: { leads: true } } },
});
if (!stage) return;
if (stage._count.leads > 0) {
throw new Error('مرحله‌ای که لید دارد قابل حذف نیست');
}
await this.prisma.leadStage.delete({ where: { id } });
}
private toDto(row: {
id: string;
name: string;
position: number;
color: string;
showOnBoard: boolean;
isConverted: boolean;
isLost: boolean;
isUnqualified: boolean;
_count?: { leads: number };
}): LeadStageDto {
return {
id: row.id,
name: row.name,
position: row.position,
color: row.color,
showOnBoard: row.showOnBoard,
isConverted: row.isConverted,
isLost: row.isLost,
isUnqualified: row.isUnqualified,
leadCount: row._count?.leads,
};
}
}
+212
View File
@@ -0,0 +1,212 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import type { LeadImportJobDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LeadStagesService } from './lead-stages.service';
import { LeadsService } from './leads.service';
@Injectable()
export class LeadsImportService {
private readonly logger = new Logger(LeadsImportService.name);
private running = false;
constructor(
private readonly prisma: PrismaService,
private readonly stages: LeadStagesService,
private readonly leads: LeadsService,
) {}
async enqueue(
organizationId: string,
createdById: string,
rows: Array<Record<string, unknown>>,
fileName?: string,
): Promise<LeadImportJobDto> {
await this.stages.ensureDefaults(organizationId);
const normalized = rows.map((r) => this.normalizeRow(r));
const job = await this.prisma.leadImportJob.create({
data: {
organizationId,
createdById,
fileName: fileName ?? null,
status: 'pending',
totalCount: normalized.length,
rows: normalized as unknown as Prisma.InputJsonValue,
},
});
return this.toDto(job);
}
async getJob(
organizationId: string,
id: string,
): Promise<LeadImportJobDto | null> {
const job = await this.prisma.leadImportJob.findFirst({
where: { id, organizationId },
});
return job ? this.toDto(job) : null;
}
@Cron('*/1 * * * *')
async processDue(): Promise<number> {
if (this.running) return 0;
this.running = true;
try {
const jobs = await this.prisma.leadImportJob.findMany({
where: { status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 2,
});
for (const job of jobs) {
await this.processJob(job.id);
}
return jobs.length;
} finally {
this.running = false;
}
}
private async processJob(id: string): Promise<void> {
const claimed = await this.prisma.leadImportJob.updateMany({
where: { id, status: 'pending' },
data: { status: 'processing', startedAt: new Date() },
});
if (!claimed.count) return;
const job = await this.prisma.leadImportJob.findUnique({ where: { id } });
if (!job) return;
const stages = await this.stages.list(job.organizationId);
const defaultStageId = stages[0]?.id;
if (!defaultStageId) {
await this.prisma.leadImportJob.update({
where: { id },
data: {
status: 'failed',
finishedAt: new Date(),
errorSamples: ['مرحله پیش‌فرض لید وجود ندارد'],
},
});
return;
}
const rows = (job.rows as Array<Record<string, string>>) ?? [];
const errorSamples: string[] = [];
let success = 0;
let duplicate = 0;
let error = 0;
let processed = 0;
const BATCH = 50;
for (let i = 0; i < rows.length; i += BATCH) {
const chunk = rows.slice(i, i + BATCH);
for (const row of chunk) {
const result = await this.leads.createFromImportRow(
job.organizationId,
job.createdById,
row,
defaultStageId,
);
processed += 1;
if (result === 'ok') success += 1;
else if (result === 'duplicate') duplicate += 1;
else {
error += 1;
if (errorSamples.length < 20) {
errorSamples.push(
`ردیف ${processed}: ${row.title || row.mobile || 'نامعتبر'}`,
);
}
}
}
await this.prisma.leadImportJob.update({
where: { id },
data: {
processedCount: processed,
successCount: success,
duplicateCount: duplicate,
errorCount: error,
errorSamples,
},
});
}
await this.prisma.leadImportJob.update({
where: { id },
data: {
status: 'done',
processedCount: processed,
successCount: success,
duplicateCount: duplicate,
errorCount: error,
errorSamples,
finishedAt: new Date(),
rows: [],
},
});
this.logger.log(
`Lead import ${id}: ok=${success} dup=${duplicate} err=${error}`,
);
}
private normalizeRow(raw: Record<string, unknown>): Record<string, string> {
const get = (...keys: string[]) => {
for (const k of keys) {
const direct = raw[k];
if (direct != null && String(direct).trim()) return String(direct).trim();
const found = Object.entries(raw).find(
([key]) => key.trim().toLowerCase() === k.toLowerCase(),
);
if (found?.[1] != null && String(found[1]).trim()) {
return String(found[1]).trim();
}
}
return '';
};
return {
title: get('title', 'نام', 'نام فروشگاه', 'فروشگاه', 'shop', 'name'),
contactName: get('contactName', 'مالک', 'مسئول', 'نام مالک', 'owner'),
mobile: get('mobile', 'موبایل', 'شماره', 'شماره تماس', 'phone', 'tel'),
phone: get('phone', 'تلفن'),
city: get('city', 'شهر'),
region: get('region', 'منطقه', 'محله'),
trade: get('trade', 'صنف', 'رسته'),
source: get('source', 'منبع', 'منبع لید'),
notes: get('notes', 'توضیحات', 'یادداشت'),
};
}
private toDto(job: {
id: string;
fileName: string | null;
status: string;
totalCount: number;
processedCount: number;
successCount: number;
duplicateCount: number;
errorCount: number;
errorSamples: unknown;
startedAt: Date | null;
finishedAt: Date | null;
createdAt: Date;
}): LeadImportJobDto {
const samples = Array.isArray(job.errorSamples)
? job.errorSamples.map((s) => String(s))
: [];
return {
id: job.id,
fileName: job.fileName,
status: job.status,
totalCount: job.totalCount,
processedCount: job.processedCount,
successCount: job.successCount,
duplicateCount: job.duplicateCount,
errorCount: job.errorCount,
errorSamples: samples,
startedAt: job.startedAt?.toISOString() ?? null,
finishedAt: job.finishedAt?.toISOString() ?? null,
createdAt: job.createdAt.toISOString(),
};
}
}
+351
View File
@@ -0,0 +1,351 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import ExcelJS from 'exceljs';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { cellStr } from '../common/excel';
import { brand } from '@mento/shared';
import {
AssignLeadsDto,
AutoAssignLeadsDto,
BulkLeadStageDto,
ConvertLeadDto,
CreateLeadDto,
CreateLeadStageDto,
ImportLeadsDto,
LeadCallLogDto,
LeadFollowUpDto,
LeadNoteDto,
MoveLeadDto,
ReorderLeadStagesDto,
UpdateLeadDto,
UpdateLeadStageDto,
} from './dto';
import { LeadStagesService } from './lead-stages.service';
import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service';
@Controller('leads')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('leads')
export class LeadsController {
constructor(
private readonly leads: LeadsService,
private readonly stages: LeadStagesService,
private readonly imports: LeadsImportService,
) {}
@Get('stats')
stats(@CurrentUser() user: Actor) {
return this.leads.stats(user);
}
@Get('board')
board(@CurrentUser() user: Actor) {
return this.leads.board(user);
}
@Get('stages')
listStages(@CurrentUser() user: Actor) {
return this.stages.list(user.organizationId);
}
@Post('stages')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createStage(@CurrentUser() user: Actor, @Body() dto: CreateLeadStageDto) {
return this.stages.create(user.organizationId, dto);
}
@Patch('stages/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderLeadStagesDto) {
return this.stages.reorder(user.organizationId, dto.stageIds);
}
@Patch('stages/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateStage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateLeadStageDto,
) {
return this.stages.update(user.organizationId, id, dto);
}
@Delete('stages/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
try {
await this.stages.remove(user.organizationId, id);
return { ok: true };
} catch (e) {
throw new BadRequestException(
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
);
}
}
@Get('duplicate-check')
checkDuplicate(
@CurrentUser() user: Actor,
@Query('mobile') mobile: string,
) {
return this.leads.checkDuplicate(user, mobile ?? '');
}
@Get('imports/:id')
getImport(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.imports.getJob(user.organizationId, id);
}
@Post('import')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
importRows(@CurrentUser() user: Actor, @Body() dto: ImportLeadsDto) {
if (!dto.rows?.length) {
throw new BadRequestException('ردیفی برای ایمپورت نیست');
}
return this.imports.enqueue(
user.organizationId,
user.userId,
dto.rows,
dto.fileName,
);
}
@Post('import/file')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
@UseInterceptors(FileInterceptor('file'))
async importFile(
@CurrentUser() user: Actor,
@UploadedFile() file?: Express.Multer.File,
) {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
const sheet = wb.worksheets[0];
if (!sheet) throw new BadRequestException('برگه اکسل خالی است');
const headerRow = sheet.getRow(1);
const headers: string[] = [];
headerRow.eachCell({ includeEmpty: true }, (cell, col) => {
headers[col - 1] = cellStr(cell.value);
});
const rows: Array<Record<string, string>> = [];
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber === 1) return;
const obj: Record<string, string> = {};
headers.forEach((h, i) => {
if (!h) return;
obj[h] = cellStr(row.getCell(i + 1).value);
});
if (Object.values(obj).some((v) => v.trim())) rows.push(obj);
});
return this.imports.enqueue(
user.organizationId,
user.userId,
rows,
file.originalname,
);
}
@Get('export')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async export(
@CurrentUser() user: Actor,
@Res({ passthrough: true }) res: Response,
) {
const data = await this.leads.list(user, { page: 1, limit: 5000 });
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet('leads');
sheet.addRow([
'نام',
'موبایل',
'مالک',
'شهر',
'منطقه',
'صنف',
'وضعیت',
'کارشناس',
'منبع',
'پیگیری بعدی',
'آخرین تماس',
]);
for (const l of data.items) {
sheet.addRow([
l.title,
l.mobile,
l.contactName,
l.city,
l.region,
l.trade,
l.stageName,
l.assigneeName,
l.source,
l.nextFollowUpAt,
l.lastContactAt,
]);
}
const buffer = Buffer.from(await wb.xlsx.writeBuffer());
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${brand.filePrefix}-leads.xlsx"`,
});
return new StreamableFile(buffer);
}
@Get()
list(
@CurrentUser() user: Actor,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('stageId') stageId?: string,
@Query('assigneeId') assigneeId?: string,
@Query('city') city?: string,
@Query('trade') trade?: string,
@Query('source') source?: string,
@Query('unassigned') unassigned?: string,
@Query('archived') archived?: string,
@Query('sort') sort?: string,
) {
return this.leads.list(user, {
q,
page: page ? Number(page) : 1,
limit: limit ? Number(limit) : 20,
stageId,
assigneeId,
city,
trade,
source,
unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true',
sort,
});
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateLeadDto) {
return this.leads.create(user, dto);
}
@Post('assign')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
return this.leads
.assign(user, dto.leadIds, dto.assigneeId)
.then((count) => ({ count }));
}
@Post('bulk-stage')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
bulkStage(@CurrentUser() user: Actor, @Body() dto: BulkLeadStageDto) {
return this.leads
.bulkStage(user, dto.leadIds, dto.stageId)
.then((count) => ({ count }));
}
@Post('auto-assign')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
autoAssign(@CurrentUser() user: Actor, @Body() dto: AutoAssignLeadsDto) {
return this.leads.autoAssign(user, dto);
}
@Post('archive')
archive(@CurrentUser() user: Actor, @Body() body: { leadIds: string[] }) {
return this.leads
.archive(user, body.leadIds ?? [])
.then((count) => ({ count }));
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.leads.get(user, id);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateLeadDto,
) {
return this.leads.update(user, id, dto as unknown as Record<string, unknown>);
}
@Patch(':id/move')
move(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: MoveLeadDto,
) {
return this.leads.move(user, id, dto.stageId, dto.position);
}
@Post(':id/call')
logCall(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: LeadCallLogDto,
) {
return this.leads.logCall(user, id, dto);
}
@Post(':id/notes')
addNote(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: LeadNoteDto,
) {
return this.leads.addNote(user, id, dto.body);
}
@Post(':id/follow-up')
followUp(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: LeadFollowUpDto,
) {
return this.leads.setFollowUp(user, id, dto.nextFollowUpAt, dto.body);
}
@Post(':id/convert')
convert(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: ConvertLeadDto,
) {
return this.leads.convert(user, id, dto);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { LeadStagesService } from './lead-stages.service';
import { LeadsController } from './leads.controller';
import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [LeadsController],
providers: [LeadsService, LeadStagesService, LeadsImportService],
exports: [LeadsService, LeadStagesService],
})
export class LeadsModule {}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -46,7 +46,16 @@ const loyaltyActive = computed(() => pathIn(['/loyalty']));
const reportsActive = computed(() => pathIn(['/reports']));
const customersActive = computed(() => pathIn(['/contacts']));
const salesActive = computed(() =>
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
pathIn([
'/pipeline',
'/leads',
'/deals',
'/quotes',
'/invoices',
'/commissions',
'/sales/forecast',
'/settings/products',
]),
);
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
@@ -73,6 +82,7 @@ const settingsActive = computed(() =>
'/settings/team',
'/settings/audit-logs',
'/settings/public-api',
'/settings/lead-stages',
'/settings/custom-fields',
'/settings/currencies',
'/settings/tahesab',
+25
View File
@@ -116,6 +116,22 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'mint',
module: 'pipeline',
},
{
to: '/leads',
title: 'لیدها',
desc: 'فهرست سرنخ‌ها، تخصیص و پیگیری',
icon: 'user-plus',
tint: 'sky',
module: 'leads',
},
{
to: '/leads/board',
title: 'کاریز لیدها',
desc: 'نمای کانبان مراحل لید',
icon: 'filter',
tint: 'violet',
module: 'leads',
},
{
to: '/settings/products',
title: 'محصولات و خدمات',
@@ -387,6 +403,15 @@ export const NAV_HUBS: NavHubDef[] = [
module: 'public_api',
admin: true,
},
{
to: '/settings/lead-stages',
title: 'تنظیمات مراحل لید',
desc: 'وضعیت‌ها و برچسب‌های کاریز لید',
icon: 'layers',
tint: 'violet',
admin: true,
module: 'leads',
},
{
to: '/settings/custom-fields',
title: 'فیلدهای سفارشی',
+21
View File
@@ -182,6 +182,21 @@ export const router = createRouter({
name: 'pipeline',
component: () => import('@/views/PipelineBoardView.vue'),
},
{
path: 'leads',
name: 'leads',
component: () => import('@/views/LeadsView.vue'),
},
{
path: 'leads/board',
name: 'leads-board',
component: () => import('@/views/LeadBoardView.vue'),
},
{
path: 'leads/:id',
name: 'lead-detail',
component: () => import('@/views/LeadDetailView.vue'),
},
{
path: 'projects',
name: 'projects',
@@ -239,6 +254,12 @@ export const router = createRouter({
component: () => import('@/views/MessageAutomationsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/lead-stages',
name: 'lead-stages',
component: () => import('@/views/LeadStagesSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/lead-forms',
name: 'lead-forms',
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
const loading = ref(false);
const moving = ref(false);
const error = ref('');
const columns = ref<LeadBoardColumnDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
function formatDate(value: string | null) {
if (!value) return '';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<LeadBoardColumnDto[]>('/leads/board');
columns.value = data;
} catch (e) {
error.value =
axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری کاریز لیدها ناموفق بود.';
} finally {
loading.value = false;
}
}
function onDragStart(lead: LeadDto, event: DragEvent) {
draggingId.value = lead.id;
event.dataTransfer?.setData('text/plain', lead.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
event.preventDefault();
const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
draggingId.value = null;
dropStageId.value = null;
if (!leadId) return;
const fromCol = columns.value.find((c) =>
c.leads.some((l) => l.id === leadId),
);
if (!fromCol || fromCol.stage.id === stageId) return;
const lead = fromCol.leads.find((l) => l.id === leadId);
if (!lead) return;
const prev = columns.value.map((c) => ({
...c,
leads: [...c.leads],
}));
columns.value = columns.value.map((c) => {
if (c.stage.id === fromCol.stage.id) {
return { ...c, leads: c.leads.filter((l) => l.id !== leadId) };
}
if (c.stage.id === stageId) {
return {
...c,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
}
return c;
});
moving.value = true;
error.value = '';
try {
await api.patch(`/leads/${leadId}/move`, { stageId });
} catch {
columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.';
} finally {
moving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<UiPageHeader title="کاریز لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت">
<template #actions>
<RouterLink :to="{ name: 'leads' }" class="link-btn">
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="loading || moving" @click="load">
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="columns.length" class="board">
<section
v-for="col in columns"
:key="col.stage.id"
class="column"
:class="{ drop: dropStageId === col.stage.id }"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
>
<header class="col-head">
<span
class="dot"
:style="{ background: col.stage.color || 'var(--accent)' }"
/>
<h2>{{ col.stage.name }}</h2>
<em>{{ toFaDigits(col.leads.length) }}</em>
</header>
<div class="cards">
<article
v-for="lead in col.leads"
:key="lead.id"
class="card"
:class="{ dragging: draggingId === lead.id }"
draggable="true"
@dragstart="onDragStart(lead, $event)"
@dragend="onDragEnd"
>
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }">
{{ lead.title }}
</RouterLink>
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p>
<p v-if="lead.city" class="line">{{ lead.city }}</p>
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p>
<p v-if="lead.nextFollowUpAt" class="line muted">
پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
</p>
</article>
<p v-if="!col.leads.length" class="empty-col">خالی</p>
</div>
</section>
</div>
<UiEmptyState
v-else-if="!loading"
title="مرحله‌ای برای کاریز نیست."
description="در تنظیمات مراحل لید، نمایش روی کاریز را فعال کنید."
/>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.link-btn {
text-decoration: none;
}
.board {
display: flex;
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
min-height: 24rem;
}
.column {
flex: 0 0 16.5rem;
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff);
border: 1px solid var(--line);
border-radius: var(--radius);
display: grid;
grid-template-rows: auto 1fr;
max-height: calc(100vh - 12rem);
}
.column.drop {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.col-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
margin: 0;
font-size: 0.95rem;
flex: 1;
}
.col-head em {
font-style: normal;
font-size: 0.8rem;
color: var(--muted);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
}
.cards {
padding: 0.55rem;
display: grid;
gap: 0.5rem;
overflow-y: auto;
}
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: calc(var(--radius) - 2px);
padding: 0.65rem 0.7rem;
display: grid;
gap: 0.2rem;
cursor: grab;
}
.card.dragging {
opacity: 0.45;
}
.title {
font-weight: 600;
color: var(--ink);
text-decoration: none;
}
.title:hover {
color: var(--accent);
}
.line {
margin: 0;
font-size: 0.82rem;
}
.muted,
.empty-col {
color: var(--muted);
}
.empty-col {
margin: 0.5rem;
text-align: center;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
margin: 0;
}
</style>
+466
View File
@@ -0,0 +1,466 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiSelect,
} from '@/components/ui';
import type {
ConvertLeadResult,
LeadDto,
LeadEventDto,
LeadStageDto,
OrgUserDto,
} from '@mento/shared';
type LeadDetail = LeadDto & { events: LeadEventDto[] };
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const id = computed(() => String(route.params.id));
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const okMsg = ref('');
const lead = ref<LeadDetail | null>(null);
const stages = ref<LeadStageDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const stageId = ref('');
const assigneeId = ref('');
const callOutcome = ref('');
const callBody = ref('');
const callFollowUp = ref('');
const noteBody = ref('');
const followUpAt = ref('');
const followUpBody = ref('');
function formatDate(value: string | null | undefined) {
if (!value) return '—';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به API برقرار نشد.';
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [leadRes, stagesRes, usersRes] = await Promise.all([
api.get<LeadDetail>(`/leads/${id.value}`),
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
lead.value = leadRes.data;
stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false);
stageId.value = leadRes.data.stageId;
assigneeId.value = leadRes.data.assigneeId ?? '';
} catch (e) {
error.value = apiError(e, 'بارگذاری لید ناموفق بود.');
} finally {
loading.value = false;
}
}
async function changeStage() {
if (!stageId.value || stageId.value === lead.value?.stageId) return;
busy.value = true;
error.value = '';
try {
await api.patch(`/leads/${id.value}/move`, { stageId: stageId.value });
okMsg.value = 'وضعیت به‌روز شد.';
await load();
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ناموفق بود.');
} finally {
busy.value = false;
}
}
async function assignLead() {
if (!assigneeId.value) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/assign', {
leadIds: [id.value],
assigneeId: assigneeId.value,
});
okMsg.value = 'کارشناس تخصیص داده شد.';
await load();
} catch (e) {
error.value = apiError(e, 'تخصیص ناموفق بود.');
} finally {
busy.value = false;
}
}
async function logCall() {
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/call`, {
outcome: callOutcome.value || undefined,
body: callBody.value || undefined,
nextFollowUpAt: callFollowUp.value || undefined,
});
callOutcome.value = '';
callBody.value = '';
callFollowUp.value = '';
okMsg.value = 'تماس ثبت شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ثبت تماس ناموفق بود.');
} finally {
busy.value = false;
}
}
async function addNote() {
if (!noteBody.value.trim()) return;
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/notes`, { body: noteBody.value.trim() });
noteBody.value = '';
okMsg.value = 'یادداشت ثبت شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ثبت یادداشت ناموفق بود.');
} finally {
busy.value = false;
}
}
async function setFollowUp() {
if (!followUpAt.value) return;
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/follow-up`, {
nextFollowUpAt: new Date(followUpAt.value).toISOString(),
body: followUpBody.value || undefined,
});
followUpAt.value = '';
followUpBody.value = '';
okMsg.value = 'پیگیری تنظیم شد.';
await load();
} catch (e) {
error.value = apiError(e, 'تنظیم پیگیری ناموفق بود.');
} finally {
busy.value = false;
}
}
async function convertLead() {
if (!confirm('این لید به مشتری تبدیل شود؟')) return;
busy.value = true;
error.value = '';
try {
const { data } = await api.post<ConvertLeadResult>(`/leads/${id.value}/convert`, {
createIfMissing: true,
});
okMsg.value = data.createdPerson
? 'لید تبدیل و شخص جدید ساخته شد.'
: 'لید به مشتری موجود متصل شد.';
await load();
if (data.personId) {
void router.push({ name: 'person-detail', params: { id: data.personId } });
}
} catch (e) {
error.value = apiError(e, 'تبدیل به مشتری ناموفق بود.');
} finally {
busy.value = false;
}
}
async function archiveLead() {
if (!confirm('این لید بایگانی شود؟')) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: [id.value] });
okMsg.value = 'لید بایگانی شد.';
void router.push({ name: 'leads' });
} catch (e) {
error.value = apiError(e, 'بایگانی ناموفق بود.');
} finally {
busy.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<UiPageHeader
:title="lead?.title || 'جزئیات لید'"
:lead="lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…'"
:back-to="{ name: 'leads' }"
>
<template #actions>
<UiButton
v-if="lead && !lead.convertedPersonId"
variant="primary"
size="sm"
:disabled="busy"
@click="convertLead"
>
تبدیل به مشتری
</UiButton>
<UiButton
v-if="lead && !lead.archivedAt"
variant="ghost"
size="sm"
:disabled="busy"
@click="archiveLead"
>
بایگانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="lead">
<div class="layout">
<div class="main">
<UiCard title="اطلاعات لید">
<dl class="meta">
<div><dt>نام</dt><dd>{{ lead.title }}</dd></div>
<div><dt>مالک/مخاطب</dt><dd>{{ lead.contactName || '—' }}</dd></div>
<div><dt>موبایل</dt><dd dir="ltr">{{ lead.mobile }}</dd></div>
<div><dt>تلفن</dt><dd dir="ltr">{{ lead.phone || '—' }}</dd></div>
<div><dt>شهر</dt><dd>{{ lead.city || '—' }}</dd></div>
<div><dt>منطقه</dt><dd>{{ lead.region || '—' }}</dd></div>
<div><dt>صنف</dt><dd>{{ lead.trade || '—' }}</dd></div>
<div><dt>منبع</dt><dd>{{ lead.source || '—' }}</dd></div>
<div><dt>وضعیت</dt><dd>{{ lead.stageName }}</dd></div>
<div><dt>کارشناس</dt><dd>{{ lead.assigneeName || '—' }}</dd></div>
<div><dt>آخرین تماس</dt><dd>{{ formatDate(lead.lastContactAt) }}</dd></div>
<div><dt>پیگیری بعدی</dt><dd>{{ formatDate(lead.nextFollowUpAt) }}</dd></div>
<div><dt>روز در مرحله</dt><dd>{{ toFaDigits(lead.daysInStage) }}</dd></div>
<div v-if="lead.notes" class="full">
<dt>یادداشت</dt>
<dd>{{ lead.notes }}</dd>
</div>
</dl>
</UiCard>
<UiCard title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
<strong>{{ ev.title }}</strong>
<span class="muted">{{ formatDate(ev.createdAt) }}</span>
<p v-if="ev.body">{{ ev.body }}</p>
</li>
</ul>
<UiEmptyState
v-else
title="رویدادی ثبت نشده."
description="تماس، یادداشت یا پیگیری اضافه کنید."
/>
</UiCard>
</div>
<aside class="side">
<UiCard title="تغییر وضعیت">
<UiField label="وضعیت">
<UiSelect v-model="stageId">
<option v-for="s in stages" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</UiSelect>
</UiField>
<UiButton size="sm" :disabled="busy" @click="changeStage">اعمال</UiButton>
</UiCard>
<UiCard v-if="isAdmin" title="تخصیص کارشناس">
<UiField label="کارشناس">
<UiSelect v-model="assigneeId">
<option value="">انتخاب…</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
</UiField>
<UiButton size="sm" :disabled="busy || !assigneeId" @click="assignLead">
تخصیص
</UiButton>
</UiCard>
<UiCard title="ثبت تماس">
<UiField label="نتیجه">
<UiInput v-model="callOutcome" placeholder="مثلاً پاسخ داد / اشغال" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="callBody" placeholder="خلاصه مکالمه" />
</UiField>
<UiField label="پیگیری بعدی">
<input v-model="callFollowUp" class="native" type="datetime-local" />
</UiField>
<UiButton size="sm" :disabled="busy" @click="logCall">ثبت تماس</UiButton>
</UiCard>
<UiCard title="یادداشت">
<UiField label="متن">
<UiInput v-model="noteBody" placeholder="یادداشت…" />
</UiField>
<UiButton size="sm" :disabled="busy || !noteBody.trim()" @click="addNote">
ثبت یادداشت
</UiButton>
</UiCard>
<UiCard title="پیگیری بعدی">
<UiField label="زمان">
<input v-model="followUpAt" class="native" type="datetime-local" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="followUpBody" placeholder="اختیاری" />
</UiField>
<UiButton size="sm" :disabled="busy || !followUpAt" @click="setFollowUp">
ذخیره پیگیری
</UiButton>
</UiCard>
</aside>
</div>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.layout {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 20rem);
align-items: start;
}
.main,
.side {
display: grid;
gap: 1rem;
}
.meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin: 0;
}
.meta .full {
grid-column: 1 / -1;
}
.meta dt {
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 0.15rem;
}
.meta dd {
margin: 0;
font-weight: 500;
}
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.timeline li {
display: grid;
gap: 0.2rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--line);
}
.timeline li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.timeline p {
margin: 0;
color: var(--ink);
}
.native {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.65rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.side :deep(.ui-card) {
display: grid;
gap: 0.65rem;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.ok {
color: #0f766e;
margin: 0;
}
.error {
color: #b91c1c;
margin: 0;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.meta {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import type {
CreateLeadStageRequest,
LeadStageDto,
UpdateLeadStageRequest,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const okMsg = ref('');
const stages = ref<LeadStageDto[]>([]);
const name = ref('');
const color = ref('#14b8a6');
const showOnBoard = ref(true);
const isConverted = ref(false);
const isLost = ref(false);
const isUnqualified = ref(false);
const editingId = ref<string | null>(null);
const editName = ref('');
const editColor = ref('#14b8a6');
const editShowOnBoard = ref(true);
const editConverted = ref(false);
const editLost = ref(false);
const editUnqualified = ref(false);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به API برقرار نشد.';
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<LeadStageDto[]>('/leads/stages');
stages.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری مراحل ناموفق بود.');
} finally {
loading.value = false;
}
}
async function createStage() {
if (!name.value.trim()) {
error.value = 'نام مرحله را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
okMsg.value = '';
try {
const body: CreateLeadStageRequest = {
name: name.value.trim(),
color: color.value || undefined,
showOnBoard: showOnBoard.value,
isConverted: isConverted.value,
isLost: isLost.value,
isUnqualified: isUnqualified.value,
};
await api.post('/leads/stages', body);
name.value = '';
color.value = '#14b8a6';
showOnBoard.value = true;
isConverted.value = false;
isLost.value = false;
isUnqualified.value = false;
okMsg.value = 'مرحله افزوده شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ثبت مرحله ناموفق بود.');
} finally {
saving.value = false;
}
}
function startEdit(stage: LeadStageDto) {
editingId.value = stage.id;
editName.value = stage.name;
editColor.value = stage.color || '#14b8a6';
editShowOnBoard.value = stage.showOnBoard;
editConverted.value = stage.isConverted;
editLost.value = stage.isLost;
editUnqualified.value = stage.isUnqualified;
}
function cancelEdit() {
editingId.value = null;
}
async function saveEdit() {
if (!editingId.value || !editName.value.trim()) return;
saving.value = true;
error.value = '';
try {
const body: UpdateLeadStageRequest = {
name: editName.value.trim(),
color: editColor.value,
showOnBoard: editShowOnBoard.value,
isConverted: editConverted.value,
isLost: editLost.value,
isUnqualified: editUnqualified.value,
};
await api.patch(`/leads/stages/${editingId.value}`, body);
editingId.value = null;
okMsg.value = 'مرحله ذخیره شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ویرایش مرحله ناموفق بود.');
} finally {
saving.value = false;
}
}
async function removeStage(stage: LeadStageDto) {
if (!confirm(`مرحله «${stage.name}» حذف شود؟`)) return;
error.value = '';
try {
await api.delete(`/leads/stages/${stage.id}`);
okMsg.value = 'مرحله حذف شد.';
await load();
} catch (e) {
error.value = apiError(e, 'حذف مرحله ناموفق بود.');
}
}
async function move(stage: LeadStageDto, dir: -1 | 1) {
const idx = stages.value.findIndex((s) => s.id === stage.id);
const swap = idx + dir;
if (idx < 0 || swap < 0 || swap >= stages.value.length) return;
const next = [...stages.value];
const [item] = next.splice(idx, 1);
next.splice(swap, 0, item);
stages.value = next;
try {
await api.patch('/leads/stages/reorder', {
stageIds: next.map((s) => s.id),
});
} catch (e) {
error.value = apiError(e, 'مرتب‌سازی ناموفق بود.');
await load();
}
}
onMounted(load);
</script>
<template>
<section class="crm-page">
<UiPageHeader
title="مراحل لید"
lead="تعریف وضعیت‌های کاریز لیدها و برچسب‌های تبدیل/از دست‌رفته"
/>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<form class="crm-form" @submit.prevent="createStage">
<UiCard>
<template #header>
<div class="card-title">
<UiIcon name="plus" :size="18" />
<h3>مرحله جدید</h3>
</div>
</template>
<div class="form-grid">
<UiField label="نام" required for-id="ls-name">
<UiInput id="ls-name" v-model="name" required placeholder="مثلاً تماس اولیه" />
</UiField>
<UiField label="رنگ" for-id="ls-color">
<input id="ls-color" v-model="color" class="color" type="color" />
</UiField>
<label class="check">
<input v-model="showOnBoard" type="checkbox" />
نمایش روی کاریز
</label>
<label class="check">
<input v-model="isConverted" type="checkbox" />
مرحله تبدیل‌شده
</label>
<label class="check">
<input v-model="isLost" type="checkbox" />
از دست‌رفته
</label>
<label class="check">
<input v-model="isUnqualified" type="checkbox" />
غیرواجد شرایط
</label>
</div>
<template #footer>
<UiButton type="submit" :loading="saving">افزودن مرحله</UiButton>
</template>
</UiCard>
</form>
<div v-if="stages.length" class="list">
<UiCard v-for="stage in stages" :key="stage.id" padding="sm" class="stage-card">
<template v-if="editingId === stage.id">
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="editName" />
</UiField>
<UiField label="رنگ">
<input v-model="editColor" class="color" type="color" />
</UiField>
<label class="check">
<input v-model="editShowOnBoard" type="checkbox" />
نمایش روی کاریز
</label>
<label class="check">
<input v-model="editConverted" type="checkbox" />
تبدیل‌شده
</label>
<label class="check">
<input v-model="editLost" type="checkbox" />
از دست‌رفته
</label>
<label class="check">
<input v-model="editUnqualified" type="checkbox" />
غیرواجد شرایط
</label>
</div>
<div class="ops">
<UiButton size="sm" :disabled="saving" @click="saveEdit">ذخیره</UiButton>
<UiButton size="sm" variant="ghost" @click="cancelEdit">انصراف</UiButton>
</div>
</template>
<template v-else>
<div class="row">
<span class="dot" :style="{ background: stage.color || 'var(--accent)' }" />
<div class="info">
<strong>{{ stage.name }}</strong>
<span class="muted">
{{ stage.showOnBoard ? 'روی کاریز' : 'مخفی از کاریز' }}
<template v-if="stage.leadCount != null">
· {{ toFaDigits(stage.leadCount) }} لید
</template>
</span>
<span class="flags">
<em v-if="stage.isConverted">تبدیل</em>
<em v-if="stage.isLost">از دست‌رفته</em>
<em v-if="stage.isUnqualified">غیرواجد</em>
</span>
</div>
<div class="ops">
<UiButton size="sm" variant="ghost" @click="move(stage, -1)">↑</UiButton>
<UiButton size="sm" variant="ghost" @click="move(stage, 1)">↓</UiButton>
<UiButton size="sm" variant="ghost" @click="startEdit(stage)">ویرایش</UiButton>
<UiButton size="sm" variant="ghost" @click="removeStage(stage)">حذف</UiButton>
</div>
</div>
</template>
</UiCard>
</div>
<UiEmptyState
v-else-if="!loading"
title="مرحله‌ای تعریف نشده."
description="اولین وضعیت لید را اضافه کنید."
/>
</section>
</template>
<style scoped>
.crm-page {
display: grid;
gap: 1rem;
}
.card-title {
display: flex;
align-items: center;
gap: 0.45rem;
}
.card-title h3 {
margin: 0;
font-size: 1rem;
}
.form-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
align-items: end;
}
.color {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
padding: 0.2rem;
}
.check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
min-height: var(--control-h);
}
.list {
display: grid;
gap: 0.65rem;
}
.row {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.info {
flex: 1 1 10rem;
display: grid;
gap: 0.15rem;
min-width: 0;
}
.flags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.flags em {
font-style: normal;
font-size: 0.75rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
}
.ops {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.ok {
color: #0f766e;
margin: 0;
}
.error {
color: #b91c1c;
margin: 0;
}
</style>
+831
View File
@@ -0,0 +1,831 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import {
UiButton,
UiEmptyState,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import type {
LeadDto,
LeadImportJobDto,
LeadStageDto,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
const PAGE_SIZE = 20;
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const okMsg = ref('');
const data = ref<PaginatedResponse<LeadDto> | null>(null);
const stages = ref<LeadStageDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const selected = ref<Set<string>>(new Set());
const sortOpen = ref(false);
const filterOpen = ref(false);
const assignOpen = ref(false);
const stageOpen = ref(false);
const assignUserId = ref('');
const bulkStageId = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const importJob = ref<LeadImportJobDto | null>(null);
let importTimer: ReturnType<typeof setInterval> | null = null;
const page = computed(() => {
const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1;
});
const sort = computed<SortKey>(() => {
const s = route.query.sort;
if (s === 'newest' || s === 'follow_up' || s === 'last_contact') return s;
return 'updated';
});
const stageId = computed(() =>
typeof route.query.stageId === 'string' ? route.query.stageId : '',
);
const assigneeId = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
const unassigned = computed(
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const allSelected = computed(
() =>
Boolean(data.value?.items.length) &&
data.value!.items.every((l) => selected.value.has(l.id)),
);
const selectedIds = computed(() => [...selected.value]);
const sortLabels: Record<SortKey, string> = {
updated: 'آخرین به‌روزرسانی',
newest: 'جدیدترین',
follow_up: 'پیگیری بعدی',
last_contact: 'آخرین تماس',
};
function formatDate(value: string | null) {
if (!value) return '—';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function closePopups() {
sortOpen.value = false;
filterOpen.value = false;
assignOpen.value = false;
stageOpen.value = false;
}
function patchQuery(next: Record<string, string | undefined>) {
const query = { ...route.query, ...next };
for (const key of Object.keys(query)) {
if (query[key] == null || query[key] === '') delete query[key];
}
void router.replace({ name: 'leads', query });
}
function onSearch() {
patchQuery({ q: q.value.trim() || undefined, page: undefined });
}
function setSort(next: SortKey) {
sortOpen.value = false;
patchQuery({ sort: next === 'updated' ? undefined : next, page: undefined });
}
function setStageFilter(id: string) {
filterOpen.value = false;
patchQuery({ stageId: id || undefined, page: undefined });
}
function setAssigneeFilter(id: string) {
filterOpen.value = false;
patchQuery({
assigneeId: id || undefined,
unassigned: undefined,
page: undefined,
});
}
function setUnassigned(on: boolean) {
filterOpen.value = false;
patchQuery({
unassigned: on ? '1' : undefined,
assigneeId: undefined,
page: undefined,
});
}
function setPage(next: number) {
if (next < 1 || next > pageCount.value) return;
patchQuery({ page: next === 1 ? undefined : String(next) });
}
function toggleOne(id: string, checked: boolean) {
const next = new Set(selected.value);
if (checked) next.add(id);
else next.delete(id);
selected.value = next;
}
function toggleAll(checked: boolean) {
if (!checked) {
selected.value = new Set();
return;
}
selected.value = new Set(data.value?.items.map((l) => l.id) ?? []);
}
async function loadMeta() {
const [stagesRes, usersRes] = await Promise.all([
api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false);
}
async function load() {
loading.value = true;
error.value = '';
closePopups();
try {
const { data: res } = await api.get<PaginatedResponse<LeadDto>>('/leads', {
params: {
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
sort: sort.value === 'updated' ? undefined : sort.value,
stageId: stageId.value || undefined,
assigneeId: assigneeId.value || undefined,
unassigned: unassigned.value ? '1' : undefined,
},
});
data.value = res;
selected.value = new Set(
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
);
} catch (e) {
error.value =
axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری لیدها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function assignSelected() {
if (!assignUserId.value || !selectedIds.value.length) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/assign', {
leadIds: selectedIds.value,
assigneeId: assignUserId.value,
});
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید تخصیص داده شد.`;
selected.value = new Set();
assignOpen.value = false;
await load();
} catch {
error.value = 'تخصیص ناموفق بود.';
} finally {
busy.value = false;
}
}
async function bulkStageSelected() {
if (!bulkStageId.value || !selectedIds.value.length) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/bulk-stage', {
leadIds: selectedIds.value,
stageId: bulkStageId.value,
});
okMsg.value = `وضعیت ${toFaDigits(selectedIds.value.length)} لید به‌روز شد.`;
selected.value = new Set();
stageOpen.value = false;
await load();
} catch {
error.value = 'تغییر وضعیت گروهی ناموفق بود.';
} finally {
busy.value = false;
}
}
async function archiveSelected() {
if (!selectedIds.value.length) return;
if (!confirm(`${toFaDigits(selectedIds.value.length)} لید بایگانی شود؟`)) return;
busy.value = true;
error.value = '';
try {
await api.post('/leads/archive', { leadIds: selectedIds.value });
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید بایگانی شد.`;
selected.value = new Set();
await load();
} catch {
error.value = 'بایگانی ناموفق بود.';
} finally {
busy.value = false;
}
}
async function exportExcel() {
busy.value = true;
error.value = '';
try {
const { data: blob } = await api.get<Blob>('/leads/export', {
responseType: 'blob',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-leads.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
}
}
function stopImportPoll() {
if (importTimer) {
clearInterval(importTimer);
importTimer = null;
}
}
async function pollImport(jobId: string) {
try {
const { data: job } = await api.get<LeadImportJobDto>(`/leads/imports/${jobId}`);
importJob.value = job;
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
stopImportPoll();
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
await load();
}
} catch {
stopImportPoll();
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
}
}
function startImportPoll(jobId: string) {
stopImportPoll();
void pollImport(jobId);
importTimer = setInterval(() => void pollImport(jobId), 1500);
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
error.value = '';
okMsg.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data: job } = await api.post<LeadImportJobDto>('/leads/import/file', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
importJob.value = job;
startImportPoll(job.id);
} catch {
error.value = 'ورود فایل ناموفق بود.';
} finally {
busy.value = false;
}
}
function goDetail(id: string) {
void router.push({ name: 'lead-detail', params: { id } });
}
watch(
() => [
route.query.q,
route.query.page,
route.query.sort,
route.query.stageId,
route.query.assigneeId,
route.query.unassigned,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
},
);
onMounted(async () => {
await loadMeta();
await load();
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
if (jobId) startImportPoll(jobId);
});
onBeforeUnmount(stopImportPoll);
</script>
<template>
<section class="page" @click="closePopups">
<UiPageHeader title="لیدها" lead="مدیریت سرنخ‌ها، تخصیص و پیگیری">
<template #actions>
<RouterLink class="link-btn" :to="{ name: 'leads-board' }">
<UiButton variant="ghost" size="sm">کاریز لیدها</UiButton>
</RouterLink>
<UiButton
v-if="isAdmin"
variant="ghost"
size="sm"
:disabled="busy"
@click.stop="exportExcel"
>
خروجی اکسل
</UiButton>
<UiButton
v-if="isAdmin"
variant="ghost"
size="sm"
:disabled="busy"
@click.stop="fileInput?.click()"
>
ورود از فایل
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls,.csv"
hidden
@change="onImportFile"
/>
</template>
</UiPageHeader>
<div class="toolbar">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="16" />
<input v-model="q" type="search" placeholder="جستجو نام، موبایل، شهر…" />
</form>
<div class="toolbar-tools">
<div class="pop">
<button
class="tool"
type="button"
@click.stop="sortOpen = !sortOpen; filterOpen = false"
>
<UiIcon name="trend" :size="16" />
<span class="tool-label">مرتب‌سازی</span>
</button>
<ul v-if="sortOpen" class="menu">
<li v-for="(label, key) in sortLabels" :key="key">
<button
type="button"
:class="{ on: sort === key }"
@click.stop="setSort(key as SortKey)"
>
{{ label }}
</button>
</li>
</ul>
</div>
<div class="pop">
<button
class="tool"
type="button"
@click.stop="filterOpen = !filterOpen; sortOpen = false"
>
<UiIcon name="filter" :size="16" />
<span class="tool-label">فیلتر</span>
</button>
<ul v-if="filterOpen" class="menu wide">
<li class="menu-label">وضعیت</li>
<li>
<button
type="button"
:class="{ on: !stageId }"
@click.stop="setStageFilter('')"
>
همه
</button>
</li>
<li v-for="s in stages" :key="s.id">
<button
type="button"
:class="{ on: stageId === s.id }"
@click.stop="setStageFilter(s.id)"
>
{{ s.name }}
</button>
</li>
<li class="menu-label">کارشناس</li>
<li>
<button
type="button"
:class="{ on: !assigneeId && !unassigned }"
@click.stop="setAssigneeFilter('')"
>
همه
</button>
</li>
<li>
<button
type="button"
:class="{ on: unassigned }"
@click.stop="setUnassigned(true)"
>
بدون کارشناس
</button>
</li>
<li v-for="u in users" :key="u.id">
<button
type="button"
:class="{ on: assigneeId === u.id }"
@click.stop="setAssigneeFilter(u.id)"
>
{{ u.fullName }}
</button>
</li>
</ul>
</div>
</div>
</div>
<div v-if="selectedIds.length" class="bulk-bar" @click.stop>
<span>{{ toFaDigits(selectedIds.length) }} مورد انتخاب‌شده</span>
<template v-if="isAdmin">
<div class="pop">
<UiButton size="sm" variant="ghost" @click="assignOpen = !assignOpen">
تخصیص
</UiButton>
<div v-if="assignOpen" class="menu panel">
<select v-model="assignUserId">
<option value="">انتخاب کارشناس…</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
<UiButton size="sm" :disabled="!assignUserId || busy" @click="assignSelected">
اعمال
</UiButton>
</div>
</div>
<div class="pop">
<UiButton size="sm" variant="ghost" @click="stageOpen = !stageOpen">
تغییر وضعیت
</UiButton>
<div v-if="stageOpen" class="menu panel">
<select v-model="bulkStageId">
<option value="">انتخاب وضعیت…</option>
<option v-for="s in stages" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
<UiButton size="sm" :disabled="!bulkStageId || busy" @click="bulkStageSelected">
اعمال
</UiButton>
</div>
</div>
</template>
<UiButton size="sm" variant="ghost" :disabled="busy" @click="archiveSelected">
بایگانی
</UiButton>
</div>
<p v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'" class="ok">
ورود فایل: {{ toFaDigits(importJob.processedCount) }} / {{ toFaDigits(importJob.totalCount) }}
</p>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="data?.items.length">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="check-col">
<input
type="checkbox"
:checked="allSelected"
@click.stop
@change="toggleAll(($event.target as HTMLInputElement).checked)"
/>
</th>
<th>نام</th>
<th>موبایل</th>
<th>شهر</th>
<th>وضعیت</th>
<th>کارشناس</th>
<th>آخرین تماس</th>
<th>پیگیری بعدی</th>
<th>منبع</th>
</tr>
</thead>
<tbody>
<tr
v-for="lead in data.items"
:key="lead.id"
class="row-click"
@click="goDetail(lead.id)"
>
<td class="check-col" @click.stop>
<input
type="checkbox"
:checked="selected.has(lead.id)"
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td>
<strong>{{ lead.title }}</strong>
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
</td>
<td dir="ltr">{{ lead.mobile || '—' }}</td>
<td>{{ lead.city || '—' }}</td>
<td>
<span class="stage-pill">{{ lead.stageName }}</span>
</td>
<td>{{ lead.assigneeName || '—' }}</td>
<td>{{ formatDate(lead.lastContactAt) }}</td>
<td>{{ formatDate(lead.nextFollowUpAt) }}</td>
<td>{{ lead.source || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
@update:page="setPage"
/>
</template>
<UiEmptyState
v-else-if="!loading"
title="لیدی ثبت نشده است."
description="از ورود فایل استفاده کنید یا لید جدید بسازید."
/>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: clip;
box-sizing: border-box;
}
.link-btn {
text-decoration: none;
}
.toolbar {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.toolbar-tools {
display: flex;
gap: 0.45rem;
align-items: center;
}
.search {
flex: 1 1 14rem;
min-width: 180px;
max-width: 400px;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
min-height: var(--control-h);
height: var(--control-h);
color: var(--muted);
box-sizing: border-box;
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.tool {
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
font: inherit;
color: var(--ink);
}
.pop {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 4px);
inset-inline-end: 0;
z-index: 20;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
min-width: 11rem;
max-height: 18rem;
overflow: auto;
}
.menu.wide {
min-width: 14rem;
}
.menu.panel {
display: grid;
gap: 0.5rem;
padding: 0.65rem;
min-width: 14rem;
}
.menu button {
width: 100%;
border: 0;
background: none;
text-align: start;
padding: 0.45rem 0.55rem;
border-radius: calc(var(--radius) - 2px);
font: inherit;
cursor: pointer;
color: var(--ink);
}
.menu button.on,
.menu button:hover {
background: var(--accent-soft);
color: var(--accent);
}
.menu-label {
padding: 0.35rem 0.55rem 0.15rem;
font-size: 0.75rem;
color: var(--muted);
font-weight: 600;
}
.menu select {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.5rem;
font: inherit;
}
.bulk-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.55rem 0.75rem;
background: var(--accent-soft);
border-radius: var(--radius);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.data-table th,
.data-table td {
padding: 0.65rem 0.75rem;
text-align: start;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.data-table th {
font-weight: 600;
color: var(--muted);
background: color-mix(in srgb, var(--surface-2, #f8fafc) 80%, #fff);
}
.row-click {
cursor: pointer;
}
.row-click:hover {
background: color-mix(in srgb, var(--accent-soft) 55%, #fff);
}
.check-col {
width: 2.25rem;
}
.sub {
display: block;
color: var(--muted);
font-size: 0.8rem;
font-weight: 400;
}
.stage-pill {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.8rem;
}
.muted {
color: var(--muted);
}
.ok {
color: #0f766e;
margin: 0;
}
.error {
color: #b91c1c;
margin: 0;
}
@media (max-width: 640px) {
.tool-label {
display: none;
}
}
</style>
+1 -1
View File
@@ -23,7 +23,7 @@ SMS_PASSWORD=
SMS_SENDER=
SMS_BASE_URL=http://smspanel.Trez.ir
SMS_SEND_PATH=/SendMessageWithCode.ashx
SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
AI_ENABLED=true
AI_PROVIDER=openai
+1 -1
View File
@@ -25,7 +25,7 @@ SMS_PASSWORD=
SMS_SENDER=
SMS_BASE_URL=http://smspanel.Trez.ir
SMS_SEND_PATH=/SendMessageWithCode.ashx
SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
# SMS_TIMEOUT_MS=20000
# AI — GapGPT (OpenAI-compatible)
+3
View File
@@ -151,6 +151,9 @@ AI پیشنهاد Next Action می‌دهد؛ اعمال فقط با تأیید
## سرنخ و مالی سبک
### مدیریت لیدها
دیتابیس جدا از مشتریان برای مخاطبین بالقوه: ایمپورت اکسل، تخصیص به کارشناس، کاریز لید، ثبت تماس/پیگیری، و تبدیل به مشتری.
### فرم‌ساز سرنخ
فرم وب عمومی با لینک، iframe و QR؛ ساخت شخص و معامله.
+2
View File
@@ -14,6 +14,7 @@
| تاریخ | مورد |
|---|---|
| ۱۴۰۵/۷/۵ | ماژول مدیریت لیدها: ایمپورت اکسل، تخصیص، Kanban، تبدیل به مشتری، جلوگیری از تکراری |
| ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع |
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
| ۱۴۰۵/۷/۵ | هم‌ترازی چک‌لیست: API عمومی/وب‌هوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
@@ -88,6 +89,7 @@
| وضعیت | مورد |
|---|---|
| [x] | فرم‌ساز سرنخ + لینک / iframe / QR | شخص+معامله؛ round-robin |
| [x] | مدیریت لیدها (جدا از مشتری) | ایمپورت اکسل پس‌زمینه، وضعیت/Kanban، تخصیص دستی/نوبتی، تبدیل به مشتری، ضدتکراری |
| [x] | پیش‌فاکتور | لیست/فرم، از معامله، چاپ |
| [x] | فاکتور / پرداخت / اقساط | صدور، وصول، برنامه اقساط |
| [x] | هدف و پیش‌بینی فروش | هدف ماهانه + خانه |
+211 -1
View File
@@ -124,7 +124,8 @@ export type OrgModuleKey =
| "teams"
| "woocommerce"
| "voip"
| "reservations";
| "reservations"
| "leads";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
@@ -139,6 +140,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"workflow_automations",
"message_automations",
"lead_forms",
"leads",
"project_boards",
"inventory",
"cheques",
@@ -174,6 +176,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
workflow_automations: "اتوماسیون گردش کار",
message_automations: "اتوماسیون پیامک",
lead_forms: "�رم‌ساز سرنخ",
leads: "مدیریت لیدها",
project_boards: "کاریز پروژه",
inventory: "انبار",
cheques: "چک‌ها",
@@ -205,6 +208,7 @@ export const ORG_MODULE_GROUPS: Array<{
title: "�روش",
keys: [
"pipeline",
"leads",
"products",
"invoices",
"quotes",
@@ -3657,3 +3661,209 @@ export interface ReservationListResponse {
items: ReservationAppointmentDto[];
total: number;
}
/* ——— ãÏíÑíÊ áíÏåÇ ——— */
export type LeadAssignRule = "round_robin" | "equal_split";
export interface LeadStageDto {
id: string;
name: string;
position: number;
color: string;
showOnBoard: boolean;
isConverted: boolean;
isLost: boolean;
isUnqualified: boolean;
leadCount?: number;
}
export interface CreateLeadStageRequest {
name: string;
color?: string;
showOnBoard?: boolean;
isConverted?: boolean;
isLost?: boolean;
isUnqualified?: boolean;
}
export interface UpdateLeadStageRequest {
name?: string;
color?: string;
showOnBoard?: boolean;
isConverted?: boolean;
isLost?: boolean;
isUnqualified?: boolean;
position?: number;
}
export interface LeadDto {
id: string;
title: string;
contactName: string | null;
mobile: string;
phone: string | null;
city: string | null;
region: string | null;
trade: string | null;
source: string | null;
notes: string | null;
estimatedValue: number | null;
stageId: string;
stageName: string;
position: number;
assigneeId: string | null;
assigneeName: string | null;
lastContactAt: string | null;
nextFollowUpAt: string | null;
convertedPersonId: string | null;
convertedAt: string | null;
archivedAt: string | null;
daysInStage: number;
createdAt: string;
updatedAt: string;
}
export interface LeadEventDto {
id: string;
type: string;
title: string;
body: string | null;
actorId: string | null;
createdAt: string;
}
export interface CreateLeadRequest {
title: string;
mobile: string;
contactName?: string | null;
phone?: string | null;
city?: string | null;
region?: string | null;
trade?: string | null;
source?: string | null;
notes?: string | null;
estimatedValue?: number | null;
stageId?: string;
assigneeId?: string | null;
nextFollowUpAt?: string | null;
}
export interface UpdateLeadRequest {
title?: string;
mobile?: string;
contactName?: string | null;
phone?: string | null;
city?: string | null;
region?: string | null;
trade?: string | null;
source?: string | null;
notes?: string | null;
estimatedValue?: number | null;
stageId?: string;
assigneeId?: string | null;
nextFollowUpAt?: string | null;
lastContactAt?: string | null;
}
export interface MoveLeadRequest {
stageId: string;
position?: number;
}
export interface AssignLeadsRequest {
leadIds: string[];
assigneeId: string;
}
export interface BulkLeadStageRequest {
leadIds: string[];
stageId: string;
}
export interface AutoAssignLeadsRequest {
rule?: LeadAssignRule;
leadIds?: string[];
assigneeIds?: string[];
onlyUnassigned?: boolean;
}
export interface LeadCallLogRequest {
outcome?: string;
body?: string;
nextFollowUpAt?: string | null;
stageId?: string;
}
export interface LeadNoteRequest {
body: string;
}
export interface LeadFollowUpRequest {
nextFollowUpAt: string;
body?: string;
}
export interface ConvertLeadRequest {
firstName?: string;
lastName?: string;
createIfMissing?: boolean;
}
export interface ConvertLeadResult {
lead: LeadDto;
personId: string;
createdPerson: boolean;
}
export interface LeadDuplicateCheckResult {
exists: boolean;
kind: "lead" | "person" | null;
id: string | null;
title: string | null;
}
export interface LeadImportJobDto {
id: string;
fileName: string | null;
status: string;
totalCount: number;
processedCount: number;
successCount: number;
duplicateCount: number;
errorCount: number;
errorSamples: string[];
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
}
export interface LeadImportRow {
title?: string;
contactName?: string;
mobile?: string;
phone?: string;
city?: string;
region?: string;
trade?: string;
source?: string;
notes?: string;
}
export interface LeadStatsDto {
total: number;
newCount: number;
unassigned: number;
byAssignee: Array<{ userId: string; fullName: string; count: number }>;
callsLogged: number;
withFollowUp: number;
interested: number;
converted: number;
lost: number;
conversionRate: number;
}
export interface LeadBoardColumnDto {
stage: LeadStageDto;
leads: LeadDto[];
}