diff --git a/.env.example b/.env.example index 2cecd0a..0b8a9f6 100644 --- a/.env.example +++ b/.env.example @@ -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 (۶۴ کاراکتر) diff --git a/apps/api/prisma/migrations/20260927190000_leads_module/migration.sql b/apps/api/prisma/migrations/20260927190000_leads_module/migration.sql new file mode 100644 index 0000000..5f7052b --- /dev/null +++ b/apps/api/prisma/migrations/20260927190000_leads_module/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 834ae89..1b03d3f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8713bed..2f2a573 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d366ae7..4e5bf95 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { diff --git a/apps/api/src/common/sms.service.ts b/apps/api/src/common/sms.service.ts index 217a849..51d6eea 100644 --- a/apps/api/src/common/sms.service.ts +++ b/apps/api/src/common/sms.service.ts @@ -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 { const template = - this.config.get('SMS_OTP_TEMPLATE') ?? - `کد ورود پرتال ${brand.nameFa}: {code}`; + this.config.get('SMS_OTP_TEMPLATE')?.trim() || + `کد ورود منتکت: {code}`; const text = template.replace('{code}', code); const result = await this.sendSmsDetailed(mobile, text); return result.ok; diff --git a/apps/api/src/leads/dto.ts b/apps/api/src/leads/dto.ts new file mode 100644 index 0000000..81cd7c9 --- /dev/null +++ b/apps/api/src/leads/dto.ts @@ -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>; + @IsOptional() @IsString() fileName?: string; +} diff --git a/apps/api/src/leads/lead-stages.service.ts b/apps/api/src/leads/lead-stages.service.ts new file mode 100644 index 0000000..963dad6 --- /dev/null +++ b/apps/api/src/leads/lead-stages.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/leads/leads-import.service.ts b/apps/api/src/leads/leads-import.service.ts new file mode 100644 index 0000000..511e1fe --- /dev/null +++ b/apps/api/src/leads/leads-import.service.ts @@ -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>, + fileName?: string, + ): Promise { + 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 { + const job = await this.prisma.leadImportJob.findFirst({ + where: { id, organizationId }, + }); + return job ? this.toDto(job) : null; + } + + @Cron('*/1 * * * *') + async processDue(): Promise { + 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 { + 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>) ?? []; + 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): Record { + 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(), + }; + } +} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts new file mode 100644 index 0000000..1a2a2a5 --- /dev/null +++ b/apps/api/src/leads/leads.controller.ts @@ -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> = []; + sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (rowNumber === 1) return; + const obj: Record = {}; + 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); + } + + @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); + } +} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts new file mode 100644 index 0000000..4f6799b --- /dev/null +++ b/apps/api/src/leads/leads.module.ts @@ -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 {} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts new file mode 100644 index 0000000..cd2ff1f --- /dev/null +++ b/apps/api/src/leads/leads.service.ts @@ -0,0 +1,1001 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + ConvertLeadResult, + LeadBoardColumnDto, + LeadDto, + LeadDuplicateCheckResult, + LeadEventDto, + LeadStatsDto, + PaginatedResponse, +} from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { + type Actor, + elevated, +} from '../common/record-scope'; +import { emptyToNull, normalizePhone } from '../contacts/normalize'; +import { LeadStagesService } from './lead-stages.service'; + +function mobileKey(raw: string | null | undefined): string | null { + const n = normalizePhone(raw); + if (!n) return null; + const d = n.replace(/\D/g, ''); + if (!d) return null; + if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; + if (d.length === 10 && d.startsWith('9')) return `0${d}`; + return d.startsWith('0') ? d : d; +} + +function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput { + if (elevated(actor.role)) return {}; + if (actor.dataVisibility === 'org') return {}; + if ( + actor.dataVisibility === 'team' && + actor.visibleUserIds && + actor.visibleUserIds.length + ) { + return { assigneeId: { in: actor.visibleUserIds } }; + } + return { assigneeId: actor.userId }; +} + +const leadInclude = { + stage: true, + assignee: { select: { id: true, fullName: true } }, +} as const; + +type LeadRow = Prisma.LeadGetPayload<{ include: typeof leadInclude }>; + +@Injectable() +export class LeadsService { + constructor( + private readonly prisma: PrismaService, + private readonly stages: LeadStagesService, + ) {} + + async list( + actor: Actor, + opts: { + q?: string; + page?: number; + limit?: number; + stageId?: string; + assigneeId?: string; + city?: string; + trade?: string; + source?: string; + unassigned?: boolean; + archived?: boolean; + sort?: string; + }, + ): Promise> { + await this.stages.ensureDefaults(actor.organizationId); + const page = Math.max(1, opts.page ?? 1); + const limit = Math.min(100, Math.max(1, opts.limit ?? 20)); + const q = opts.q?.trim(); + + const where: Prisma.LeadWhereInput = { + organizationId: actor.organizationId, + ...leadAccessWhere(actor), + archivedAt: opts.archived ? { not: null } : null, + ...(opts.stageId ? { stageId: opts.stageId } : {}), + ...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), + ...(opts.unassigned ? { assigneeId: null } : {}), + ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), + ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), + ...(opts.source + ? { source: { contains: opts.source, mode: 'insensitive' } } + : {}), + ...(q + ? { + OR: [ + { title: { contains: q, mode: 'insensitive' } }, + { contactName: { contains: q, mode: 'insensitive' } }, + { mobile: { contains: q } }, + { city: { contains: q, mode: 'insensitive' } }, + { trade: { contains: q, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const orderBy: Prisma.LeadOrderByWithRelationInput = + opts.sort === 'follow_up' + ? { nextFollowUpAt: 'asc' } + : opts.sort === 'last_contact' + ? { lastContactAt: 'desc' } + : opts.sort === 'newest' + ? { createdAt: 'desc' } + : { updatedAt: 'desc' }; + + const [total, rows] = await Promise.all([ + this.prisma.lead.count({ where }), + this.prisma.lead.findMany({ + where, + include: leadInclude, + orderBy, + skip: (page - 1) * limit, + take: limit, + }), + ]); + + return { + items: rows.map((r) => this.toDto(r)), + total, + page, + limit, + }; + } + + async board(actor: Actor): Promise { + const stages = await this.stages.list(actor.organizationId); + const boardStages = stages.filter((s) => s.showOnBoard); + const leads = await this.prisma.lead.findMany({ + where: { + organizationId: actor.organizationId, + ...leadAccessWhere(actor), + archivedAt: null, + stageId: { in: boardStages.map((s) => s.id) }, + }, + include: leadInclude, + orderBy: [{ stageId: 'asc' }, { position: 'asc' }, { updatedAt: 'desc' }], + take: 500, + }); + const byStage = new Map(); + for (const s of boardStages) byStage.set(s.id, []); + for (const row of leads) { + const list = byStage.get(row.stageId); + if (list) list.push(this.toDto(row)); + } + return boardStages.map((stage) => ({ + stage, + leads: byStage.get(stage.id) ?? [], + })); + } + + async get(actor: Actor, id: string): Promise { + const row = await this.findAccessible(actor, id); + const events = await this.prisma.leadEvent.findMany({ + where: { leadId: id }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + return { + ...this.toDto(row), + events: events.map((e) => ({ + id: e.id, + type: e.type, + title: e.title, + body: e.body, + actorId: e.actorId, + createdAt: e.createdAt.toISOString(), + })), + }; + } + + async checkDuplicate( + actor: Actor, + mobileRaw: string, + ): Promise { + const mobile = mobileKey(mobileRaw); + if (!mobile) { + return { exists: false, kind: null, id: null, title: null }; + } + const lead = await this.prisma.lead.findFirst({ + where: { organizationId: actor.organizationId, mobile }, + select: { id: true, title: true }, + }); + if (lead) { + return { + exists: true, + kind: 'lead', + id: lead.id, + title: lead.title, + }; + } + const person = await this.prisma.person.findFirst({ + where: { + organizationId: actor.organizationId, + OR: [{ mobile }, { phone: mobile }], + }, + select: { id: true, firstName: true, lastName: true }, + }); + if (person) { + return { + exists: true, + kind: 'person', + id: person.id, + title: `${person.firstName} ${person.lastName}`.trim(), + }; + } + return { exists: false, kind: null, id: null, title: null }; + } + + async create( + actor: Actor, + dto: { + 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; + }, + ): Promise { + await this.stages.ensureDefaults(actor.organizationId); + const mobile = mobileKey(dto.mobile); + if (!mobile) throw new BadRequestException('شماره موبایل معتبر نیست'); + + const dup = await this.checkDuplicate(actor, mobile); + if (dup.exists) { + throw new ConflictException( + dup.kind === 'person' + ? `این شماره قبلاً به‌عنوان مشتری ثبت شده است (${dup.title})` + : `این شماره قبلاً به‌عنوان لید ثبت شده است (${dup.title})`, + ); + } + + const stages = await this.stages.list(actor.organizationId); + const stageId = dto.stageId || stages[0]?.id; + if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده'); + + let assigneeId: string | null = null; + if (elevated(actor.role) && dto.assigneeId) { + assigneeId = dto.assigneeId; + } else if (!elevated(actor.role)) { + assigneeId = actor.userId; + } + + const row = await this.prisma.lead.create({ + data: { + organizationId: actor.organizationId, + title: dto.title.trim(), + contactName: emptyToNull(dto.contactName), + mobile, + phone: emptyToNull(dto.phone), + city: emptyToNull(dto.city), + region: emptyToNull(dto.region), + trade: emptyToNull(dto.trade), + source: emptyToNull(dto.source), + notes: emptyToNull(dto.notes), + estimatedValue: + dto.estimatedValue != null + ? new Prisma.Decimal(dto.estimatedValue) + : null, + stageId, + assigneeId, + createdById: actor.userId, + nextFollowUpAt: dto.nextFollowUpAt + ? new Date(dto.nextFollowUpAt) + : null, + }, + include: leadInclude, + }); + + await this.addEvent(actor, row.id, 'created', 'لید ایجاد شد', null); + if (assigneeId) { + await this.addEvent( + actor, + row.id, + 'assigned', + `به کارشناس اختصاص داده شد`, + null, + { assigneeId }, + ); + } + return this.toDto(row); + } + + async update( + actor: Actor, + id: string, + dto: Record, + ): Promise { + const existing = await this.findAccessible(actor, id); + if (existing.archivedAt) { + throw new BadRequestException('لید بایگانی‌شده قابل ویرایش نیست'); + } + + let mobile = existing.mobile; + if (typeof dto.mobile === 'string') { + const next = mobileKey(dto.mobile); + if (!next) throw new BadRequestException('شماره موبایل معتبر نیست'); + if (next !== existing.mobile) { + const dup = await this.checkDuplicate(actor, next); + if (dup.exists && dup.id !== id) { + throw new ConflictException( + dup.kind === 'person' + ? 'این شماره متعلق به یک مشتری است' + : 'این شماره متعلق به لید دیگری است', + ); + } + mobile = next; + } + } + + const data: Prisma.LeadUpdateInput = { + ...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}), + mobile, + ...(dto.contactName !== undefined + ? { contactName: emptyToNull(dto.contactName as string) } + : {}), + ...(dto.phone !== undefined + ? { phone: emptyToNull(dto.phone as string) } + : {}), + ...(dto.city !== undefined ? { city: emptyToNull(dto.city as string) } : {}), + ...(dto.region !== undefined + ? { region: emptyToNull(dto.region as string) } + : {}), + ...(dto.trade !== undefined + ? { trade: emptyToNull(dto.trade as string) } + : {}), + ...(dto.source !== undefined + ? { source: emptyToNull(dto.source as string) } + : {}), + ...(dto.notes !== undefined + ? { notes: emptyToNull(dto.notes as string) } + : {}), + ...(dto.estimatedValue !== undefined + ? { + estimatedValue: + dto.estimatedValue == null + ? null + : new Prisma.Decimal(Number(dto.estimatedValue)), + } + : {}), + ...(dto.nextFollowUpAt !== undefined + ? { + nextFollowUpAt: dto.nextFollowUpAt + ? new Date(String(dto.nextFollowUpAt)) + : null, + } + : {}), + ...(dto.lastContactAt !== undefined + ? { + lastContactAt: dto.lastContactAt + ? new Date(String(dto.lastContactAt)) + : null, + } + : {}), + }; + + if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) { + data.stage = { connect: { id: dto.stageId } }; + } + if (elevated(actor.role) && dto.assigneeId !== undefined) { + const aid = dto.assigneeId ? String(dto.assigneeId) : null; + data.assignee = aid + ? { connect: { id: aid } } + : { disconnect: true }; + } + + const row = await this.prisma.lead.update({ + where: { id }, + data, + include: leadInclude, + }); + + if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) { + await this.addEvent( + actor, + id, + 'stage_changed', + `وضعیت به «${row.stage.name}» تغییر کرد`, + ); + } + return this.toDto(row); + } + + async move( + actor: Actor, + id: string, + stageId: string, + position?: number, + ): Promise { + await this.findAccessible(actor, id); + const stage = await this.prisma.leadStage.findFirst({ + where: { id: stageId, organizationId: actor.organizationId }, + }); + if (!stage) throw new NotFoundException('مرحله پیدا نشد'); + + const row = await this.prisma.lead.update({ + where: { id }, + data: { + stageId, + position: position ?? 0, + ...(stage.isConverted + ? {} + : {}), + }, + include: leadInclude, + }); + await this.addEvent( + actor, + id, + 'stage_changed', + `به مرحله «${stage.name}» منتقل شد`, + ); + return this.toDto(row); + } + + async assign( + actor: Actor, + leadIds: string[], + assigneeId: string, + ): Promise { + if (!elevated(actor.role)) { + throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد'); + } + const user = await this.prisma.user.findFirst({ + where: { id: assigneeId, organizationId: actor.organizationId }, + }); + if (!user) throw new NotFoundException('کارشناس پیدا نشد'); + + const result = await this.prisma.lead.updateMany({ + where: { + organizationId: actor.organizationId, + id: { in: leadIds }, + archivedAt: null, + }, + data: { assigneeId }, + }); + + for (const leadId of leadIds) { + await this.addEvent( + actor, + leadId, + 'assigned', + `به «${user.fullName}» اختصاص داده شد`, + null, + { assigneeId }, + ); + } + return result.count; + } + + async bulkStage( + actor: Actor, + leadIds: string[], + stageId: string, + ): Promise { + if (!elevated(actor.role)) { + throw new BadRequestException('فقط مدیر می‌تواند وضعیت گروهی تغییر دهد'); + } + const stage = await this.prisma.leadStage.findFirst({ + where: { id: stageId, organizationId: actor.organizationId }, + }); + if (!stage) throw new NotFoundException('مرحله پیدا نشد'); + const result = await this.prisma.lead.updateMany({ + where: { + organizationId: actor.organizationId, + id: { in: leadIds }, + archivedAt: null, + }, + data: { stageId }, + }); + for (const leadId of leadIds) { + await this.addEvent( + actor, + leadId, + 'stage_changed', + `وضعیت گروهی به «${stage.name}»`, + ); + } + return result.count; + } + + async autoAssign( + actor: Actor, + opts: { + rule?: string; + leadIds?: string[]; + assigneeIds?: string[]; + onlyUnassigned?: boolean; + }, + ): Promise<{ assigned: number }> { + if (!elevated(actor.role)) { + throw new BadRequestException('فقط مدیر می‌تواند تخصیص خودکار کند'); + } + const rule = opts.rule === 'equal_split' ? 'equal_split' : 'round_robin'; + let assignees = opts.assigneeIds?.length + ? await this.prisma.user.findMany({ + where: { + organizationId: actor.organizationId, + id: { in: opts.assigneeIds }, + isActive: true, + }, + select: { id: true, fullName: true }, + }) + : await this.prisma.user.findMany({ + where: { + organizationId: actor.organizationId, + isActive: true, + memberships: { + some: { + organizationId: actor.organizationId, + isActive: true, + role: { in: ['sales', 'admin', 'owner'] }, + }, + }, + }, + select: { id: true, fullName: true }, + }); + + assignees = assignees.filter((u) => u.id); + if (!assignees.length) { + throw new BadRequestException('کارشناسی برای تخصیص پیدا نشد'); + } + + const leads = await this.prisma.lead.findMany({ + where: { + organizationId: actor.organizationId, + archivedAt: null, + ...(opts.leadIds?.length ? { id: { in: opts.leadIds } } : {}), + ...(opts.onlyUnassigned !== false ? { assigneeId: null } : {}), + }, + orderBy: { createdAt: 'asc' }, + select: { id: true }, + }); + + if (!leads.length) return { assigned: 0 }; + + let assigned = 0; + if (rule === 'equal_split') { + const chunk = Math.ceil(leads.length / assignees.length); + for (let i = 0; i < assignees.length; i += 1) { + const slice = leads.slice(i * chunk, (i + 1) * chunk); + if (!slice.length) continue; + await this.assign( + actor, + slice.map((l) => l.id), + assignees[i].id, + ); + assigned += slice.length; + } + } else { + for (let i = 0; i < leads.length; i += 1) { + const user = assignees[i % assignees.length]; + await this.prisma.lead.update({ + where: { id: leads[i].id }, + data: { assigneeId: user.id }, + }); + await this.addEvent( + actor, + leads[i].id, + 'assigned', + `تخصیص خودکار (نوبتی) به «${user.fullName}»`, + null, + { assigneeId: user.id }, + ); + assigned += 1; + } + } + return { assigned }; + } + + async logCall( + actor: Actor, + id: string, + dto: { + outcome?: string; + body?: string; + nextFollowUpAt?: string | null; + stageId?: string; + }, + ): Promise { + await this.findAccessible(actor, id); + const title = dto.outcome + ? `تماس ثبت شد — ${dto.outcome}` + : 'تماس ثبت شد'; + await this.addEvent(actor, id, 'call', title, dto.body ?? null); + const data: Prisma.LeadUpdateInput = { + lastContactAt: new Date(), + ...(dto.nextFollowUpAt !== undefined + ? { + nextFollowUpAt: dto.nextFollowUpAt + ? new Date(dto.nextFollowUpAt) + : null, + } + : {}), + }; + if (dto.stageId) data.stage = { connect: { id: dto.stageId } }; + const row = await this.prisma.lead.update({ + where: { id }, + data, + include: leadInclude, + }); + return this.toDto(row); + } + + async addNote(actor: Actor, id: string, body: string): Promise { + await this.findAccessible(actor, id); + await this.addEvent(actor, id, 'note', 'یادداشت', body); + const row = await this.findAccessible(actor, id); + return this.toDto(row); + } + + async setFollowUp( + actor: Actor, + id: string, + nextFollowUpAt: string, + body?: string, + ): Promise { + await this.findAccessible(actor, id); + const at = new Date(nextFollowUpAt); + await this.addEvent( + actor, + id, + 'follow_up', + 'پیگیری بعدی ثبت شد', + body ?? null, + { nextFollowUpAt: at.toISOString() }, + ); + const row = await this.prisma.lead.update({ + where: { id }, + data: { nextFollowUpAt: at }, + include: leadInclude, + }); + return this.toDto(row); + } + + async archive(actor: Actor, ids: string[]): Promise { + if (!elevated(actor.role) && ids.length > 1) { + throw new BadRequestException('حذف گروهی فقط برای مدیر'); + } + for (const id of ids) { + await this.findAccessible(actor, id); + await this.addEvent(actor, id, 'archived', 'لید بایگانی شد'); + } + const result = await this.prisma.lead.updateMany({ + where: { + organizationId: actor.organizationId, + id: { in: ids }, + }, + data: { archivedAt: new Date() }, + }); + return result.count; + } + + async convert( + actor: Actor, + id: string, + dto: { firstName?: string; lastName?: string }, + ): Promise { + const lead = await this.findAccessible(actor, id); + if (lead.convertedPersonId) { + return { + lead: this.toDto(lead), + personId: lead.convertedPersonId, + createdPerson: false, + }; + } + + let person = await this.prisma.person.findFirst({ + where: { + organizationId: actor.organizationId, + OR: [{ mobile: lead.mobile }, { phone: lead.mobile }], + }, + }); + let createdPerson = false; + + if (!person) { + const rawName = dto.firstName || dto.lastName + ? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim() + : (lead.contactName || lead.title).trim(); + const nameParts = rawName.split(/\s+/).filter(Boolean); + const firstName = nameParts[0] || lead.title; + const lastName = nameParts.slice(1).join(' ') || '—'; + person = await this.prisma.person.create({ + data: { + organizationId: actor.organizationId, + firstName, + lastName, + mobile: lead.mobile, + phone: lead.phone, + notes: [ + lead.notes, + lead.city ? `شهر: ${lead.city}` : null, + lead.trade ? `صنف: ${lead.trade}` : null, + lead.source ? `منبع لید: ${lead.source}` : null, + `تبدیل از لید: ${lead.title}`, + ] + .filter(Boolean) + .join('\n'), + ownerId: lead.assigneeId ?? actor.userId, + }, + }); + createdPerson = true; + } + + const convertedStage = await this.prisma.leadStage.findFirst({ + where: { organizationId: actor.organizationId, isConverted: true }, + orderBy: { position: 'asc' }, + }); + + const row = await this.prisma.lead.update({ + where: { id }, + data: { + convertedPersonId: person.id, + convertedAt: new Date(), + ...(convertedStage ? { stageId: convertedStage.id } : {}), + }, + include: leadInclude, + }); + + await this.addEvent( + actor, + id, + 'converted', + `تبدیل به مشتری انجام شد`, + null, + { personId: person.id }, + ); + + await this.prisma.activity + .create({ + data: { + organizationId: actor.organizationId, + title: `لید تبدیل‌شده: ${lead.title}`, + type: 'note', + status: 'done', + priority: 'medium', + description: `این مشتری از لید «${lead.title}» تبدیل شده است.`, + personId: person.id, + assigneeId: lead.assigneeId ?? actor.userId, + createdById: actor.userId, + tags: ['lead-convert'], + dueAt: new Date(), + }, + }) + .catch(() => undefined); + + return { + lead: this.toDto(row), + personId: person.id, + createdPerson, + }; + } + + async stats(actor: Actor): Promise { + await this.stages.ensureDefaults(actor.organizationId); + const base: Prisma.LeadWhereInput = { + organizationId: actor.organizationId, + ...leadAccessWhere(actor), + archivedAt: null, + }; + 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 [ + total, + newCount, + unassigned, + converted, + lost, + interestedCount, + withFollowUp, + callsLogged, + byAssigneeRaw, + ] = await Promise.all([ + this.prisma.lead.count({ where: base }), + newStage + ? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } }) + : Promise.resolve(0), + this.prisma.lead.count({ where: { ...base, assigneeId: null } }), + convertedIds.length + ? this.prisma.lead.count({ + where: { ...base, stageId: { in: convertedIds } }, + }) + : Promise.resolve(0), + lostIds.length + ? this.prisma.lead.count({ + 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 } }, + }), + this.prisma.leadEvent.count({ + where: { + organizationId: actor.organizationId, + type: 'call', + }, + }), + this.prisma.lead.groupBy({ + by: ['assigneeId'], + where: { ...base, assigneeId: { not: null } }, + _count: { _all: true }, + }), + ]); + + const userIds = byAssigneeRaw + .map((r) => r.assigneeId) + .filter((id): id is string => Boolean(id)); + const users = userIds.length + ? await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, fullName: true }, + }) + : []; + const nameById = new Map(users.map((u) => [u.id, u.fullName])); + + const denom = total || 1; + return { + total, + newCount, + unassigned, + byAssignee: byAssigneeRaw + .filter((r) => r.assigneeId) + .map((r) => ({ + userId: r.assigneeId!, + fullName: nameById.get(r.assigneeId!) ?? '—', + count: r._count._all, + })) + .sort((a, b) => b.count - a.count), + callsLogged, + withFollowUp, + interested: interestedCount, + converted, + lost, + conversionRate: Math.round((converted / denom) * 1000) / 10, + }; + } + + async createFromImportRow( + organizationId: string, + createdById: string | null, + row: { + title?: string; + contactName?: string; + mobile?: string; + phone?: string; + city?: string; + region?: string; + trade?: string; + source?: string; + notes?: string; + }, + defaultStageId: string, + ): Promise<'ok' | 'duplicate' | 'error'> { + const mobile = mobileKey(row.mobile); + const title = String(row.title ?? row.contactName ?? '').trim(); + if (!mobile || !title) return 'error'; + + const existingLead = await this.prisma.lead.findFirst({ + where: { organizationId, mobile }, + select: { id: true }, + }); + if (existingLead) return 'duplicate'; + + const existingPerson = await this.prisma.person.findFirst({ + where: { + organizationId, + OR: [{ mobile }, { phone: mobile }], + }, + select: { id: true }, + }); + if (existingPerson) return 'duplicate'; + + try { + const lead = await this.prisma.lead.create({ + data: { + organizationId, + title, + contactName: emptyToNull(row.contactName), + mobile, + phone: emptyToNull(row.phone), + city: emptyToNull(row.city), + region: emptyToNull(row.region), + trade: emptyToNull(row.trade), + source: emptyToNull(row.source) ?? 'ایمپورت اکسل', + notes: emptyToNull(row.notes), + stageId: defaultStageId, + createdById, + }, + }); + await this.prisma.leadEvent.create({ + data: { + organizationId, + leadId: lead.id, + type: 'import', + title: 'ورود از فایل اکسل', + actorId: createdById, + }, + }); + return 'ok'; + } catch { + return 'error'; + } + } + + private async findAccessible(actor: Actor, id: string): Promise { + const row = await this.prisma.lead.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...leadAccessWhere(actor), + }, + include: leadInclude, + }); + if (!row) throw new NotFoundException('لید پیدا نشد'); + return row; + } + + private async addEvent( + actor: Actor, + leadId: string, + type: string, + title: string, + body?: string | null, + meta?: Record | null, + ) { + await this.prisma.leadEvent.create({ + data: { + organizationId: actor.organizationId, + leadId, + type, + title, + body: body ?? null, + meta: meta ? (meta as Prisma.InputJsonValue) : undefined, + actorId: actor.userId, + }, + }); + } + + private toDto(row: LeadRow): LeadDto { + const daysInStage = Math.max( + 0, + Math.floor( + (Date.now() - row.updatedAt.getTime()) / (24 * 60 * 60 * 1000), + ), + ); + return { + id: row.id, + title: row.title, + contactName: row.contactName, + mobile: row.mobile, + phone: row.phone, + city: row.city, + region: row.region, + trade: row.trade, + source: row.source, + notes: row.notes, + estimatedValue: + row.estimatedValue != null ? Number(row.estimatedValue) : null, + stageId: row.stageId, + stageName: row.stage.name, + position: row.position, + assigneeId: row.assigneeId, + assigneeName: row.assignee?.fullName ?? null, + lastContactAt: row.lastContactAt?.toISOString() ?? null, + nextFollowUpAt: row.nextFollowUpAt?.toISOString() ?? null, + convertedPersonId: row.convertedPersonId, + convertedAt: row.convertedAt?.toISOString() ?? null, + archivedAt: row.archivedAt?.toISOString() ?? null, + daysInStage, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 5b2ecea..0dbe663 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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', diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index 063dada..84dea83 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -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: 'فیلدهای سفارشی', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 1e2ffd8..2c5a0c6 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', diff --git a/apps/web/src/views/LeadBoardView.vue b/apps/web/src/views/LeadBoardView.vue new file mode 100644 index 0000000..0bd9c52 --- /dev/null +++ b/apps/web/src/views/LeadBoardView.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/apps/web/src/views/LeadDetailView.vue b/apps/web/src/views/LeadDetailView.vue new file mode 100644 index 0000000..8c64bf6 --- /dev/null +++ b/apps/web/src/views/LeadDetailView.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/apps/web/src/views/LeadStagesSettingsView.vue b/apps/web/src/views/LeadStagesSettingsView.vue new file mode 100644 index 0000000..015b6ca --- /dev/null +++ b/apps/web/src/views/LeadStagesSettingsView.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/apps/web/src/views/LeadsView.vue b/apps/web/src/views/LeadsView.vue new file mode 100644 index 0000000..203d439 --- /dev/null +++ b/apps/web/src/views/LeadsView.vue @@ -0,0 +1,831 @@ + + + + + diff --git a/deploy/.env.crm2.example b/deploy/.env.crm2.example index 385c86e..824a638 100644 --- a/deploy/.env.crm2.example +++ b/deploy/.env.crm2.example @@ -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 diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 28e35ec..6840be8 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -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) diff --git a/docs/قابلیت‌های-منتکت.md b/docs/قابلیت‌های-منتکت.md index bdb5dd7..0de16da 100644 --- a/docs/قابلیت‌های-منتکت.md +++ b/docs/قابلیت‌های-منتکت.md @@ -151,6 +151,9 @@ AI پیشنهاد Next Action می‌دهد؛ اعمال فقط با تأیید ## سرنخ و مالی سبک +### مدیریت لیدها +دیتابیس جدا از مشتریان برای مخاطبین بالقوه: ایمپورت اکسل، تخصیص به کارشناس، کاریز لید، ثبت تماس/پیگیری، و تبدیل به مشتری. + ### فرم‌ساز سرنخ فرم وب عمومی با لینک، iframe و QR؛ ساخت شخص و معامله. diff --git a/docs/وضعیت-پیشرفت.md b/docs/وضعیت-پیشرفت.md index bfb135b..58abfbd 100644 --- a/docs/وضعیت-پیشرفت.md +++ b/docs/وضعیت-پیشرفت.md @@ -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] | هدف و پیش‌بینی فروش | هدف ماهانه + خانه | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6e9b273..849cc11 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 = { 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[]; +}