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:
+1
-1
@@ -17,7 +17,7 @@ SMS_PASSWORD=
|
|||||||
SMS_SENDER=
|
SMS_SENDER=
|
||||||
# SMS_BASE_URL=http://smspanel.Trez.ir
|
# SMS_BASE_URL=http://smspanel.Trez.ir
|
||||||
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||||
# SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
|
# SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
|
||||||
# SMS_TIMEOUT_MS=20000
|
# SMS_TIMEOUT_MS=20000
|
||||||
|
|
||||||
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت بهصورت hex (۶۴ کاراکتر)
|
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت بهصورت 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;
|
||||||
@@ -203,6 +203,10 @@ model Organization {
|
|||||||
voipConnections VoipConnection[]
|
voipConnections VoipConnection[]
|
||||||
reservationServices ReservationService[]
|
reservationServices ReservationService[]
|
||||||
reservationAppointments ReservationAppointment[]
|
reservationAppointments ReservationAppointment[]
|
||||||
|
leads Lead[]
|
||||||
|
leadStages LeadStage[]
|
||||||
|
leadEvents LeadEvent[]
|
||||||
|
leadImportJobs LeadImportJob[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -251,6 +255,8 @@ model User {
|
|||||||
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
||||||
teamMemberships TeamMember[]
|
teamMemberships TeamMember[]
|
||||||
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
||||||
|
assignedLeads Lead[] @relation("LeadAssignee")
|
||||||
|
createdLeads Lead[] @relation("LeadCreator")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1922,3 +1928,116 @@ model ReservationAppointment {
|
|||||||
@@index([personId])
|
@@index([personId])
|
||||||
@@index([assigneeId, startsAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { TeamsModule } from './teams/teams.module';
|
|||||||
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
|
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
|
||||||
import { VoipModule } from './integrations/voip/voip.module';
|
import { VoipModule } from './integrations/voip/voip.module';
|
||||||
import { ReservationsModule } from './reservations/reservations.module';
|
import { ReservationsModule } from './reservations/reservations.module';
|
||||||
|
import { LeadsModule } from './leads/leads.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,6 +62,7 @@ import { ReservationsModule } from './reservations/reservations.module';
|
|||||||
OrgRolesModule,
|
OrgRolesModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
ContactsModule,
|
ContactsModule,
|
||||||
|
LeadsModule,
|
||||||
PipelinesModule,
|
PipelinesModule,
|
||||||
ActivitiesModule,
|
ActivitiesModule,
|
||||||
NotesModule,
|
NotesModule,
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ function maskEmail(email: string): string {
|
|||||||
|
|
||||||
function maskMobile(mobile: string): string {
|
function maskMobile(mobile: string): string {
|
||||||
if (mobile.length < 4) return '****';
|
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 {
|
function generateOtp(): string {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { brand } from '@mento/shared';
|
|
||||||
|
|
||||||
/** پنل ترِز / رایگان — همان الگوی montime */
|
/** پنل ترِز / رایگان — همان الگوی montime */
|
||||||
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
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> {
|
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
||||||
const template =
|
const template =
|
||||||
this.config.get<string>('SMS_OTP_TEMPLATE') ??
|
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
|
||||||
`کد ورود پرتال ${brand.nameFa}: {code}`;
|
`کد ورود منتکت: {code}`;
|
||||||
const text = template.replace('{code}', code);
|
const text = template.replace('{code}', code);
|
||||||
const result = await this.sendSmsDetailed(mobile, text);
|
const result = await this.sendSmsDetailed(mobile, text);
|
||||||
return result.ok;
|
return result.ok;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -46,7 +46,16 @@ const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
|||||||
const reportsActive = computed(() => pathIn(['/reports']));
|
const reportsActive = computed(() => pathIn(['/reports']));
|
||||||
const customersActive = computed(() => pathIn(['/contacts']));
|
const customersActive = computed(() => pathIn(['/contacts']));
|
||||||
const salesActive = computed(() =>
|
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 activitiesActive = computed(() => pathIn(['/activities']));
|
||||||
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
|
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
|
||||||
@@ -73,6 +82,7 @@ const settingsActive = computed(() =>
|
|||||||
'/settings/team',
|
'/settings/team',
|
||||||
'/settings/audit-logs',
|
'/settings/audit-logs',
|
||||||
'/settings/public-api',
|
'/settings/public-api',
|
||||||
|
'/settings/lead-stages',
|
||||||
'/settings/custom-fields',
|
'/settings/custom-fields',
|
||||||
'/settings/currencies',
|
'/settings/currencies',
|
||||||
'/settings/tahesab',
|
'/settings/tahesab',
|
||||||
|
|||||||
@@ -116,6 +116,22 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
tint: 'mint',
|
tint: 'mint',
|
||||||
module: 'pipeline',
|
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',
|
to: '/settings/products',
|
||||||
title: 'محصولات و خدمات',
|
title: 'محصولات و خدمات',
|
||||||
@@ -387,6 +403,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
module: 'public_api',
|
module: 'public_api',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/lead-stages',
|
||||||
|
title: 'تنظیمات مراحل لید',
|
||||||
|
desc: 'وضعیتها و برچسبهای کاریز لید',
|
||||||
|
icon: 'layers',
|
||||||
|
tint: 'violet',
|
||||||
|
admin: true,
|
||||||
|
module: 'leads',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/custom-fields',
|
to: '/settings/custom-fields',
|
||||||
title: 'فیلدهای سفارشی',
|
title: 'فیلدهای سفارشی',
|
||||||
|
|||||||
@@ -182,6 +182,21 @@ export const router = createRouter({
|
|||||||
name: 'pipeline',
|
name: 'pipeline',
|
||||||
component: () => import('@/views/PipelineBoardView.vue'),
|
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',
|
path: 'projects',
|
||||||
name: 'projects',
|
name: 'projects',
|
||||||
@@ -239,6 +254,12 @@ export const router = createRouter({
|
|||||||
component: () => import('@/views/MessageAutomationsView.vue'),
|
component: () => import('@/views/MessageAutomationsView.vue'),
|
||||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
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',
|
path: 'settings/lead-forms',
|
||||||
name: 'lead-forms',
|
name: 'lead-forms',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -23,7 +23,7 @@ SMS_PASSWORD=
|
|||||||
SMS_SENDER=
|
SMS_SENDER=
|
||||||
SMS_BASE_URL=http://smspanel.Trez.ir
|
SMS_BASE_URL=http://smspanel.Trez.ir
|
||||||
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||||
SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
|
SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
|
||||||
|
|
||||||
AI_ENABLED=true
|
AI_ENABLED=true
|
||||||
AI_PROVIDER=openai
|
AI_PROVIDER=openai
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ SMS_PASSWORD=
|
|||||||
SMS_SENDER=
|
SMS_SENDER=
|
||||||
SMS_BASE_URL=http://smspanel.Trez.ir
|
SMS_BASE_URL=http://smspanel.Trez.ir
|
||||||
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||||
SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
|
SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
|
||||||
# SMS_TIMEOUT_MS=20000
|
# SMS_TIMEOUT_MS=20000
|
||||||
|
|
||||||
# AI — GapGPT (OpenAI-compatible)
|
# AI — GapGPT (OpenAI-compatible)
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ AI پیشنهاد Next Action میدهد؛ اعمال فقط با تأیید
|
|||||||
|
|
||||||
## سرنخ و مالی سبک
|
## سرنخ و مالی سبک
|
||||||
|
|
||||||
|
### مدیریت لیدها
|
||||||
|
دیتابیس جدا از مشتریان برای مخاطبین بالقوه: ایمپورت اکسل، تخصیص به کارشناس، کاریز لید، ثبت تماس/پیگیری، و تبدیل به مشتری.
|
||||||
|
|
||||||
### فرمساز سرنخ
|
### فرمساز سرنخ
|
||||||
فرم وب عمومی با لینک، iframe و QR؛ ساخت شخص و معامله.
|
فرم وب عمومی با لینک، iframe و QR؛ ساخت شخص و معامله.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
| تاریخ | مورد |
|
| تاریخ | مورد |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| ۱۴۰۵/۷/۵ | ماژول مدیریت لیدها: ایمپورت اکسل، تخصیص، Kanban، تبدیل به مشتری، جلوگیری از تکراری |
|
||||||
| ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع |
|
| ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع |
|
||||||
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
|
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
|
||||||
| ۱۴۰۵/۷/۵ | همترازی چکلیست: API عمومی/وبهوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
|
| ۱۴۰۵/۷/۵ | همترازی چکلیست: API عمومی/وبهوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
| وضعیت | مورد |
|
| وضعیت | مورد |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [x] | فرمساز سرنخ + لینک / iframe / QR | شخص+معامله؛ round-robin |
|
| [x] | فرمساز سرنخ + لینک / iframe / QR | شخص+معامله؛ round-robin |
|
||||||
|
| [x] | مدیریت لیدها (جدا از مشتری) | ایمپورت اکسل پسزمینه، وضعیت/Kanban، تخصیص دستی/نوبتی، تبدیل به مشتری، ضدتکراری |
|
||||||
| [x] | پیشفاکتور | لیست/فرم، از معامله، چاپ |
|
| [x] | پیشفاکتور | لیست/فرم، از معامله، چاپ |
|
||||||
| [x] | فاکتور / پرداخت / اقساط | صدور، وصول، برنامه اقساط |
|
| [x] | فاکتور / پرداخت / اقساط | صدور، وصول، برنامه اقساط |
|
||||||
| [x] | هدف و پیشبینی فروش | هدف ماهانه + خانه |
|
| [x] | هدف و پیشبینی فروش | هدف ماهانه + خانه |
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export type OrgModuleKey =
|
|||||||
| "teams"
|
| "teams"
|
||||||
| "woocommerce"
|
| "woocommerce"
|
||||||
| "voip"
|
| "voip"
|
||||||
| "reservations";
|
| "reservations"
|
||||||
|
| "leads";
|
||||||
|
|
||||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||||
"customers",
|
"customers",
|
||||||
@@ -139,6 +140,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
|||||||
"workflow_automations",
|
"workflow_automations",
|
||||||
"message_automations",
|
"message_automations",
|
||||||
"lead_forms",
|
"lead_forms",
|
||||||
|
"leads",
|
||||||
"project_boards",
|
"project_boards",
|
||||||
"inventory",
|
"inventory",
|
||||||
"cheques",
|
"cheques",
|
||||||
@@ -174,6 +176,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
|||||||
workflow_automations: "اتوماسیون گردش کار",
|
workflow_automations: "اتوماسیون گردش کار",
|
||||||
message_automations: "اتوماسیون پیامک",
|
message_automations: "اتوماسیون پیامک",
|
||||||
lead_forms: "�رم‌ساز سرنخ",
|
lead_forms: "�رم‌ساز سرنخ",
|
||||||
|
leads: "مدیریت لیدها",
|
||||||
project_boards: "کاریز پروژه",
|
project_boards: "کاریز پروژه",
|
||||||
inventory: "انبار",
|
inventory: "انبار",
|
||||||
cheques: "چک‌ها",
|
cheques: "چک‌ها",
|
||||||
@@ -205,6 +208,7 @@ export const ORG_MODULE_GROUPS: Array<{
|
|||||||
title: "�روش",
|
title: "�روش",
|
||||||
keys: [
|
keys: [
|
||||||
"pipeline",
|
"pipeline",
|
||||||
|
"leads",
|
||||||
"products",
|
"products",
|
||||||
"invoices",
|
"invoices",
|
||||||
"quotes",
|
"quotes",
|
||||||
@@ -3657,3 +3661,209 @@ export interface ReservationListResponse {
|
|||||||
items: ReservationAppointmentDto[];
|
items: ReservationAppointmentDto[];
|
||||||
total: number;
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user