changes till 09/05/2026
New clients creation from opportunities, client conversion%, time taken for conversion, close the modal when touched outside it Client and company name separate, Demo becomes a separate activity, all changes done in mobile app as well 2) transfer of clients, demos followups negotiation etc scheduling, quote opportunity in place of enquiry, In opportunity new product add, existing dropdown, added option for adding documents on client creation and showing itmain
parent
370c14f93a
commit
6f4b5aa67c
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const logs = await prisma.whatsappLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
select: { templateName: true, payload: true, response: true, status: true, createdAt: true }
|
||||
});
|
||||
logs.forEach((l, i) => {
|
||||
console.log(`\n=== LOG #${i+1} [${l.status}] at ${l.createdAt} ===`);
|
||||
console.log('Template:', l.templateName);
|
||||
const payload = JSON.parse(l.payload || '{}');
|
||||
console.log('Fields:', payload.fields);
|
||||
console.log('Cat:', payload.cat);
|
||||
console.log('Headimg:', payload.headimg);
|
||||
console.log('Response:', l.response);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const logs = await prisma.whatsappLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10
|
||||
});
|
||||
const templates = await prisma.whatsappTemplate.findMany();
|
||||
|
||||
console.log('--- TEMPLATES ---');
|
||||
console.log(JSON.stringify(templates, null, 2));
|
||||
console.log('--- LOGS ---');
|
||||
console.log(JSON.stringify(logs, null, 2));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const templates = await prisma.whatsappTemplate.findMany();
|
||||
|
||||
console.log('WhatsApp Templates available in the system:');
|
||||
templates.forEach(t => {
|
||||
console.log(`\n--- ${t.type} ---`);
|
||||
console.log(`Template Name: ${t.templateName}`);
|
||||
console.log(`Language: ${t.language}`);
|
||||
console.log(`Message Body: ${t.body}`);
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -27,28 +27,48 @@ model Attendance {
|
|||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
phone String
|
||||
email String?
|
||||
address String?
|
||||
lat Float?
|
||||
lng Float?
|
||||
landmark String?
|
||||
status client_status @default(LEAD)
|
||||
assignedTo String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey")
|
||||
enquiries Enquiry[]
|
||||
followups Followup[]
|
||||
meetings Meeting[]
|
||||
opportunities Opportunity[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
phone String
|
||||
email String?
|
||||
address String?
|
||||
lat Float?
|
||||
lng Float?
|
||||
landmark String?
|
||||
status client_status @default(LEAD)
|
||||
assignedTo String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
companyName String?
|
||||
contactName String?
|
||||
closingProbability Int? @default(0)
|
||||
expectedClosingTimeframe String?
|
||||
isDemoDone Boolean @default(false)
|
||||
user User? @relation(fields: [assignedTo], references: [id], map: "Client_assignedTo_fkey")
|
||||
enquiries Enquiry[]
|
||||
followups Followup[]
|
||||
meetings Meeting[]
|
||||
opportunities Opportunity[]
|
||||
files ClientFile[]
|
||||
|
||||
@@index([assignedTo], map: "Client_assignedTo_fkey")
|
||||
@@map("client")
|
||||
}
|
||||
|
||||
model ClientFile {
|
||||
id String @id @default(uuid())
|
||||
clientId String
|
||||
name String
|
||||
url String
|
||||
size Int?
|
||||
type String?
|
||||
createdAt DateTime @default(now())
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([clientId])
|
||||
@@map("client_file")
|
||||
}
|
||||
|
||||
model Enquiry {
|
||||
id String @id @default(uuid())
|
||||
clientId String
|
||||
|
|
@ -61,7 +81,7 @@ model Enquiry {
|
|||
user User @relation(fields: [userId], references: [id], map: "Enquiry_userId_fkey")
|
||||
followups Followup[]
|
||||
quotes Quote[]
|
||||
products Product[] @relation("enquirytoproduct")
|
||||
products Product[] @relation("enquiry_products")
|
||||
|
||||
@@index([clientId], map: "Enquiry_clientId_fkey")
|
||||
@@index([userId], map: "Enquiry_userId_fkey")
|
||||
|
|
@ -85,23 +105,32 @@ model Expense {
|
|||
}
|
||||
|
||||
model Followup {
|
||||
id String @id @default(uuid())
|
||||
clientId String
|
||||
enquiryId String?
|
||||
userId String
|
||||
date DateTime
|
||||
notes String?
|
||||
status String @default("PENDING")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contentType String?
|
||||
stage followup_stage @default(LEAD)
|
||||
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey")
|
||||
enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey")
|
||||
user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey")
|
||||
id String @id @default(uuid())
|
||||
clientId String
|
||||
enquiryId String?
|
||||
userId String
|
||||
date DateTime
|
||||
notes String?
|
||||
status String @default("PENDING")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contentType String?
|
||||
stage followup_stage @default(LEAD)
|
||||
competitorMention String?
|
||||
demoContactDetails String?
|
||||
demoPersonName String?
|
||||
keyQueries String?
|
||||
objections String?
|
||||
opportunityId String?
|
||||
type activity_type @default(FOLLOWUP)
|
||||
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey")
|
||||
enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Followup_enquiryId_fkey")
|
||||
opportunity Opportunity? @relation(fields: [opportunityId], references: [id], map: "Followup_opportunityId_fkey")
|
||||
user User @relation(fields: [userId], references: [id], map: "Followup_userId_fkey")
|
||||
|
||||
@@index([clientId], map: "Followup_clientId_fkey")
|
||||
@@index([enquiryId], map: "Followup_enquiryId_fkey")
|
||||
@@index([opportunityId], map: "Followup_opportunityId_fkey")
|
||||
@@index([userId], map: "Followup_userId_fkey")
|
||||
@@map("followup")
|
||||
}
|
||||
|
|
@ -172,14 +201,17 @@ model Opportunity {
|
|||
paymentMode String?
|
||||
specialRate Float?
|
||||
creatorId String?
|
||||
demoOwnerId String?
|
||||
closingOwnerId String?
|
||||
demoOwnerId String?
|
||||
isDemoDone Boolean @default(false)
|
||||
activities Followup[]
|
||||
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
|
||||
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_fkey")
|
||||
closingOwner User? @relation("opportunity_closingOwnerIdTouser", fields: [closingOwnerId], references: [id], map: "Opportunity_closingOwnerId_fkey")
|
||||
creator User? @relation("opportunity_creatorIdTouser", fields: [creatorId], references: [id], map: "Opportunity_creatorId_fkey")
|
||||
demoOwner User? @relation("opportunity_demoOwnerIdTouser", fields: [demoOwnerId], references: [id], map: "Opportunity_demoOwnerId_fkey")
|
||||
closingOwner User? @relation("opportunity_closingOwnerIdTouser", fields: [closingOwnerId], references: [id], map: "Opportunity_closingOwnerId_fkey")
|
||||
workOrders WorkOrder[]
|
||||
quotes Quote[]
|
||||
|
||||
@@index([assignedTo], map: "Opportunity_assignedTo_fkey")
|
||||
@@index([clientId], map: "Opportunity_clientId_fkey")
|
||||
|
|
@ -229,25 +261,28 @@ model Product {
|
|||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
enquiries Enquiry[] @relation("enquirytoproduct")
|
||||
enquiries Enquiry[] @relation("enquiry_products")
|
||||
|
||||
@@map("product")
|
||||
}
|
||||
|
||||
model Quote {
|
||||
id String @id @default(uuid())
|
||||
enquiryId String
|
||||
userId String
|
||||
items String @db.LongText
|
||||
totalAmount Float
|
||||
status quote_status @default(DRAFT)
|
||||
pdfUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
enquiry Enquiry @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey")
|
||||
user User @relation(fields: [userId], references: [id], map: "Quote_userId_fkey")
|
||||
id String @id @default(uuid())
|
||||
enquiryId String?
|
||||
opportunityId String?
|
||||
userId String
|
||||
items String @db.LongText
|
||||
totalAmount Float
|
||||
status quote_status @default(DRAFT)
|
||||
pdfUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
enquiry Enquiry? @relation(fields: [enquiryId], references: [id], map: "Quote_enquiryId_fkey")
|
||||
opportunity Opportunity? @relation(fields: [opportunityId], references: [id], map: "Quote_opportunityId_fkey")
|
||||
user User @relation(fields: [userId], references: [id], map: "Quote_userId_fkey")
|
||||
|
||||
@@index([enquiryId], map: "Quote_enquiryId_fkey")
|
||||
@@index([opportunityId], map: "Quote_opportunityId_fkey")
|
||||
@@index([userId], map: "Quote_userId_fkey")
|
||||
@@map("quote")
|
||||
}
|
||||
|
|
@ -270,24 +305,24 @@ model StrategicActivity {
|
|||
}
|
||||
|
||||
model Target {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
month Int
|
||||
year Int
|
||||
monthlyTarget Float
|
||||
minTarget Float
|
||||
weeklyTarget Float?
|
||||
dailyLeadTarget Int?
|
||||
requiredLeads Int?
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
month Int
|
||||
year Int
|
||||
monthlyTarget Float
|
||||
minTarget Float
|
||||
weeklyTarget Float?
|
||||
dailyLeadTarget Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
avgDealValue Float?
|
||||
closureRatio Float?
|
||||
requiredClosures Int?
|
||||
requiredDemos Int?
|
||||
requiredLeads Int?
|
||||
requiredPotential Int?
|
||||
requiredQualityLeads Int?
|
||||
requiredPotential Int?
|
||||
requiredDemos Int?
|
||||
requiredClosures Int?
|
||||
closureRatio Float?
|
||||
avgDealValue Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey")
|
||||
user User @relation(fields: [userId], references: [id], map: "Target_userId_fkey")
|
||||
|
||||
@@index([userId], map: "Target_userId_fkey")
|
||||
@@map("target")
|
||||
|
|
@ -323,10 +358,10 @@ model User {
|
|||
password String
|
||||
name String?
|
||||
role user_role @default(TELESALES_EXECUTIVE)
|
||||
status user_status @default(APPROVED)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
managerId String?
|
||||
status user_status @default(APPROVED)
|
||||
attendance Attendance[]
|
||||
clients Client[]
|
||||
enquiries Enquiry[]
|
||||
|
|
@ -335,15 +370,15 @@ model User {
|
|||
incentives Incentive[]
|
||||
locations Location[]
|
||||
meetings Meeting[]
|
||||
notifications Notification[]
|
||||
opportunities Opportunity[] @relation("opportunity_assignedToTouser")
|
||||
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
|
||||
createdOpportunities Opportunity[] @relation("opportunity_creatorIdTouser")
|
||||
demoOpportunities Opportunity[] @relation("opportunity_demoOwnerIdTouser")
|
||||
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
|
||||
performanceScores PerformanceScore[]
|
||||
quotes Quote[]
|
||||
strategicActivities StrategicActivity[]
|
||||
targets Target[]
|
||||
notifications Notification[]
|
||||
manager User? @relation("userTouser", fields: [managerId], references: [id], map: "User_managerId_fkey")
|
||||
subordinates User[] @relation("userTouser")
|
||||
|
||||
|
|
@ -365,11 +400,34 @@ model WorkOrder {
|
|||
@@map("workorder")
|
||||
}
|
||||
|
||||
model WhatsappTemplate {
|
||||
id String @id @default(uuid())
|
||||
type String @unique
|
||||
templateName String
|
||||
language String @default("en")
|
||||
body String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("whatsapp_template")
|
||||
}
|
||||
|
||||
model WhatsappLog {
|
||||
id String @id @default(uuid())
|
||||
templateName String
|
||||
payload String? @db.Text
|
||||
response String? @db.Text
|
||||
status String @default("SENT")
|
||||
createdAt DateTime @default(now())
|
||||
recipient String
|
||||
|
||||
@@map("whatsapp_log")
|
||||
}
|
||||
|
||||
enum opportunity_stage {
|
||||
LEAD
|
||||
QUALIFIED
|
||||
POTENTIAL
|
||||
DEMO
|
||||
WON
|
||||
LOST
|
||||
}
|
||||
|
|
@ -411,7 +469,6 @@ enum client_status {
|
|||
LEAD
|
||||
QUALITY
|
||||
POTENTIAL
|
||||
DEMO
|
||||
SALES
|
||||
CLOSED
|
||||
}
|
||||
|
|
@ -420,31 +477,13 @@ enum followup_stage {
|
|||
LEAD
|
||||
QUALITY
|
||||
POTENTIAL
|
||||
DEMO
|
||||
SALES
|
||||
CLOSED
|
||||
}
|
||||
|
||||
model WhatsappTemplate {
|
||||
id String @id @default(uuid())
|
||||
type String @unique // e.g., 'GREETING', 'QUOTE', 'NUDGE'
|
||||
templateName String // The tempid/name used by the external API
|
||||
language String @default("en")
|
||||
body String? @db.Text // Content with placeholders for reference
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("whatsapp_template")
|
||||
}
|
||||
|
||||
model WhatsappLog {
|
||||
id String @id @default(uuid())
|
||||
recipient String
|
||||
templateName String
|
||||
payload String? @db.Text
|
||||
response String? @db.Text
|
||||
status String @default("SENT")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("whatsapp_log")
|
||||
enum activity_type {
|
||||
FOLLOWUP
|
||||
DEMO
|
||||
QUOTE
|
||||
NEGOTIATION
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
[2026-05-07T10:41:17.907Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
|
||||
[2026-05-07T10:45:40.605Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
|
||||
[2026-05-07T10:47:45.589Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
|
||||
[2026-05-07T10:48:13.353Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
|
||||
[2026-05-07T10:48:50.250Z] Manual send triggered for ID: f51c4391-6df5-4dc4-b3ec-ad628db954ed
|
||||
[2026-05-09T08:48:57.668Z] Manual send triggered for ID: debedbbf-8473-4b9a-a936-f98a3c030c65
|
||||
|
|
@ -41,23 +41,28 @@ export class AutomationService {
|
|||
|
||||
if (message && lead.phone) {
|
||||
this.logger.log(`Triggering Day ${age} nudge for ${lead.name}`);
|
||||
// await this.whatsapp.sendInternalMessage(lead.phone, message); // Placeholder for actual implementation
|
||||
// await this.whatsapp.sendInternalMessage(lead.phone, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processEscalations(){
|
||||
const delayedDemos = await this.prisma.opportunity.findMany({
|
||||
private async processEscalations() {
|
||||
// Find opportunities where a demo has been scheduled but not yet done for 10+ days
|
||||
const staleDemoOpps = await this.prisma.opportunity.findMany({
|
||||
where: {
|
||||
stage: 'DEMO',
|
||||
isDemoDone: false,
|
||||
stage: { in: ['QUALIFIED', 'POTENTIAL'] },
|
||||
updatedAt: { lte: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
include: { user: { include: { manager: true } } },
|
||||
include: {
|
||||
user: { include: { manager: true } },
|
||||
client: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const opp of delayedDemos) {
|
||||
for (const opp of staleDemoOpps) {
|
||||
if (opp.user?.manager?.email) {
|
||||
this.logger.warn(`Escalating delayed demo: ${opp.title} for user ${opp.user.name}`);
|
||||
this.logger.warn(`Escalating stale demo: ${opp.title} for user ${opp.user.name}`);
|
||||
// Send alert to manager via Email/WhatsApp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,27 @@ export class ClientsService {
|
|||
|
||||
async create(createClientDto: CreateClientDto) {
|
||||
console.log('API Received Client DTO:', JSON.stringify(createClientDto, null, 2));
|
||||
const { id: _, user: __, createdAt: ___, updatedAt: ____, enquiries: _____, followups: ______, meetings: _______, opportunities: ________, files, ...data } = createClientDto as any;
|
||||
|
||||
// Convert empty strings to null for optional DB fields
|
||||
if (data.assignedTo === '') data.assignedTo = null;
|
||||
if (data.companyName === '') data.companyName = null;
|
||||
if (data.contactName === '') data.contactName = null;
|
||||
if (data.expectedClosingTimeframe === '') data.expectedClosingTimeframe = null;
|
||||
|
||||
const client = await this.prisma.client.create({
|
||||
data: createClientDto
|
||||
data: {
|
||||
...data,
|
||||
files: files && files.length > 0 ? {
|
||||
create: files.map((f: any) => ({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
type: f.type
|
||||
}))
|
||||
} : undefined
|
||||
},
|
||||
include: { files: true }
|
||||
});
|
||||
|
||||
// Trigger onboarding message if client is created with SALES status
|
||||
|
|
@ -35,7 +54,7 @@ export class ClientsService {
|
|||
async findAll(user: any) {
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.client.findMany({
|
||||
include: { user: true }
|
||||
include: { user: true, files: true }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -46,21 +65,41 @@ export class ClientsService {
|
|||
where: {
|
||||
assignedTo: { in: allowedUserIds }
|
||||
},
|
||||
include: { user: true }
|
||||
include: { user: true, files: true }
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.client.findUnique({
|
||||
where: { id },
|
||||
include: { user: true, meetings: true }
|
||||
include: { user: true, meetings: true, files: true }
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateClientDto: UpdateClientDto) {
|
||||
const { id: _, user: __, createdAt: ___, updatedAt: ____, enquiries: _____, followups: ______, meetings: _______, opportunities: ________, files, ...data } = updateClientDto as any;
|
||||
|
||||
// Convert empty strings to null for optional DB fields to avoid foreign key or constraint issues
|
||||
if (data.assignedTo === '') data.assignedTo = null;
|
||||
if (data.companyName === '') data.companyName = null;
|
||||
if (data.contactName === '') data.contactName = null;
|
||||
if (data.expectedClosingTimeframe === '') data.expectedClosingTimeframe = null;
|
||||
|
||||
return this.prisma.client.update({
|
||||
where: { id },
|
||||
data: updateClientDto
|
||||
data: {
|
||||
...data,
|
||||
files: files ? {
|
||||
deleteMany: {},
|
||||
create: files.map((f: any) => ({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
type: f.type
|
||||
}))
|
||||
} : undefined
|
||||
},
|
||||
include: { files: true }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { IsEnum, IsString, IsNotEmpty, IsOptional, IsEmail, IsNumber } from 'class-validator';
|
||||
import { IsEnum, IsString, IsNotEmpty, IsOptional, IsEmail, IsNumber, IsBoolean, IsArray } from 'class-validator';
|
||||
import { client_status } from '@prisma/client';
|
||||
|
||||
export class CreateClientDto {
|
||||
|
|
@ -6,6 +6,14 @@ export class CreateClientDto {
|
|||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
companyName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
contactName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
|
@ -37,4 +45,20 @@ export class CreateClientDto {
|
|||
@IsNumber()
|
||||
@IsOptional()
|
||||
lng?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
closingProbability?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
expectedClosingTimeframe?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDemoDone?: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
files?: any[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,4 +30,32 @@ export class CreateFollowupDto {
|
|||
@IsString()
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
type?: 'FOLLOWUP' | 'DEMO' | 'QUOTE' | 'NEGOTIATION';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
opportunityId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
demoPersonName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
demoContactDetails?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keyQueries?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
objections?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
competitorMention?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ export class FollowupsService {
|
|||
) { }
|
||||
|
||||
async create(createFollowupDto: CreateFollowupDto) {
|
||||
// Map description to notes if provided
|
||||
const { description, ...rest } = createFollowupDto;
|
||||
// Map description to notes if provided, and exclude 'time' which is only used by the frontend
|
||||
const { description, time, ...rest } = createFollowupDto as any;
|
||||
const data = {
|
||||
...rest,
|
||||
notes: rest.notes || description, // Use description if notes is empty
|
||||
|
|
@ -115,6 +115,20 @@ export class FollowupsService {
|
|||
);
|
||||
}
|
||||
|
||||
// Auto-sync DEMO feedback to Opportunity
|
||||
if (updateFollowupDto.status === 'DONE' && followup.type === 'DEMO' && followup.opportunityId) {
|
||||
await this.prisma.opportunity.update({
|
||||
where: { id: followup.opportunityId },
|
||||
data: {
|
||||
isDemoDone: true,
|
||||
demoPersonName: followup.demoPersonName,
|
||||
demoContactDetails: followup.demoContactDetails,
|
||||
keyQueries: followup.keyQueries,
|
||||
competitorMention: followup.competitorMention
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return followup;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsBoolean } from 'class-validator';
|
||||
import { opportunity_stage } from '@prisma/client';
|
||||
|
||||
export class CreateOpportunityDto {
|
||||
|
|
@ -22,6 +22,10 @@ export class CreateOpportunityDto {
|
|||
@IsEnum(opportunity_stage)
|
||||
stage?: opportunity_stage;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDemoDone?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
priority?: string;
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ export class OpportunitiesService {
|
|||
|
||||
const newStage = updateOpportunityDto.stage || current.stage;
|
||||
|
||||
// Validation Logic for DEMO stage
|
||||
if (newStage === 'DEMO') {
|
||||
// Validation Logic for Demo
|
||||
if (updateOpportunityDto.isDemoDone === true && !current.isDemoDone) {
|
||||
const missing: string[] = [];
|
||||
const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === '');
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ export class OpportunitiesService {
|
|||
if (check(updateOpportunityDto.value ?? current.value)) missing.push('Value');
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`Missing for DEMO stage: ${missing.join(', ')}`);
|
||||
throw new BadRequestException(`Missing details to mark Demo as done: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export class PerformanceService implements OnModuleInit {
|
|||
const followupScore = followups.length > 0 ? (completedFollowups / followups.length) * 15 : 15;
|
||||
|
||||
// 6. Data Quality (Weight: 10) - Mandatory fields in Demo stage
|
||||
const opportunitiesWithDemo = opportunities.filter(o => o.stage === 'DEMO');
|
||||
const opportunitiesWithDemo = opportunities.filter(o => o.isDemoDone);
|
||||
const qualityOpps = opportunitiesWithDemo.filter(o => o.demoPersonName && o.demoContactDetails && o.keyQueries);
|
||||
const dataQualityScore = opportunitiesWithDemo.length > 0 ? (qualityOpps.length / opportunitiesWithDemo.length) * 10 : 10;
|
||||
|
||||
|
|
@ -160,9 +160,9 @@ export class PerformanceService implements OnModuleInit {
|
|||
|
||||
// Actual Counts
|
||||
const actualCalls = activities.length;
|
||||
const actualQuality = opportunities.filter(o => ['QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'].includes(o.stage)).length;
|
||||
const actualPotential = opportunities.filter(o => ['POTENTIAL', 'DEMO', 'WON'].includes(o.stage)).length;
|
||||
const actualDemo = opportunities.filter(o => ['DEMO', 'WON'].includes(o.stage)).length;
|
||||
const actualQuality = opportunities.filter(o => ['QUALIFIED', 'POTENTIAL', 'WON'].includes(o.stage) || o.isDemoDone).length;
|
||||
const actualPotential = opportunities.filter(o => ['POTENTIAL', 'WON'].includes(o.stage) || o.isDemoDone).length;
|
||||
const actualDemo = opportunities.filter(o => o.isDemoDone || o.stage === 'WON').length;
|
||||
const actualWon = opportunities.filter(o => o.stage === 'WON').length;
|
||||
|
||||
// Default ratios (configurable)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import { IsArray, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'cla
|
|||
|
||||
export class CreateQuoteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
enquiryId: string;
|
||||
@IsOptional()
|
||||
enquiryId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
opportunityId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
|
|
|||
|
|
@ -13,8 +13,16 @@ export class QuotesService {
|
|||
|
||||
async create(createQuoteDto: CreateQuoteDto) {
|
||||
const quote = await this.prisma.quote.create({
|
||||
data: createQuoteDto as any,
|
||||
data: {
|
||||
...createQuoteDto,
|
||||
items: JSON.stringify(createQuoteDto.items)
|
||||
} as any,
|
||||
include: {
|
||||
opportunity: {
|
||||
include: {
|
||||
client: true,
|
||||
},
|
||||
},
|
||||
enquiry: {
|
||||
include: {
|
||||
client: true,
|
||||
|
|
@ -24,14 +32,19 @@ export class QuotesService {
|
|||
});
|
||||
|
||||
try {
|
||||
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
|
||||
await this.whatsappService.sendQuoteCreation(
|
||||
quote.enquiry.client.phone,
|
||||
quote.enquiry.client.name,
|
||||
quote.totalAmount.toString(),
|
||||
mediaUrl
|
||||
);
|
||||
const client = quote.opportunity?.client || quote.enquiry?.client;
|
||||
console.log('Quote created. Attempting to send WhatsApp to client:', client?.name, 'Phone:', client?.phone);
|
||||
if (client) {
|
||||
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
|
||||
console.log('Calling whatsappService.sendQuoteCreation with mediaUrl:', mediaUrl);
|
||||
await this.whatsappService.sendQuoteCreation(
|
||||
client.phone,
|
||||
client.name,
|
||||
quote.totalAmount.toString(),
|
||||
mediaUrl
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send quote creation WhatsApp:', err.message);
|
||||
}
|
||||
|
|
@ -42,6 +55,11 @@ export class QuotesService {
|
|||
findAll() {
|
||||
return this.prisma.quote.findMany({
|
||||
include: {
|
||||
opportunity: {
|
||||
include: {
|
||||
client: true,
|
||||
},
|
||||
},
|
||||
enquiry: {
|
||||
include: {
|
||||
client: true,
|
||||
|
|
@ -56,6 +74,11 @@ export class QuotesService {
|
|||
return this.prisma.quote.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
opportunity: {
|
||||
include: {
|
||||
client: true,
|
||||
},
|
||||
},
|
||||
enquiry: {
|
||||
include: {
|
||||
client: true,
|
||||
|
|
@ -67,9 +90,13 @@ export class QuotesService {
|
|||
}
|
||||
|
||||
update(id: string, updateQuoteDto: UpdateQuoteDto) {
|
||||
const data = { ...updateQuoteDto } as any;
|
||||
if (updateQuoteDto.items) {
|
||||
data.items = JSON.stringify(updateQuoteDto.items);
|
||||
}
|
||||
return this.prisma.quote.update({
|
||||
where: { id },
|
||||
data: updateQuoteDto as any,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -86,12 +113,12 @@ export class QuotesService {
|
|||
throw new NotFoundException(`Quote with ID ${id} not found`);
|
||||
}
|
||||
|
||||
if (!quote.enquiry || !quote.enquiry.client) {
|
||||
const client = quote.opportunity?.client || quote.enquiry?.client;
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('This quote is not linked to a valid client');
|
||||
}
|
||||
|
||||
const client = quote.enquiry.client;
|
||||
|
||||
if (!client.phone) {
|
||||
throw new BadRequestException('The client associated with this quote has no phone number');
|
||||
}
|
||||
|
|
@ -100,11 +127,16 @@ export class QuotesService {
|
|||
const mediaUrl = quote.pdfUrl ? `${baseUrl}${quote.pdfUrl}` : undefined;
|
||||
|
||||
if (type === 'whatsapp') {
|
||||
const fs = require('fs');
|
||||
fs.appendFileSync('service_log.txt', `[${new Date().toISOString()}] Manual send triggered for ID: ${id}\n`);
|
||||
console.log('Manually sending quote via WhatsApp. ID:', id);
|
||||
const result = await this.whatsappService.sendQuote(
|
||||
client.phone,
|
||||
`Hello ${client.name}, please find your quote attached.`,
|
||||
client.name,
|
||||
quote.totalAmount.toString(),
|
||||
mediaUrl
|
||||
);
|
||||
console.log('WhatsApp send result:', result);
|
||||
|
||||
if (result && !(result as any).success) {
|
||||
throw new BadRequestException((result as any).error || 'Failed to send WhatsApp message');
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export class ReportsService {
|
|||
// Opportunity breakdown
|
||||
this.prisma.opportunity.findMany({
|
||||
where: { ...leadSharingFilter, updatedAt: dateRange },
|
||||
select: { id: true, stage: true, value: true, updatedAt: true, assignedTo: true, creatorId: true }
|
||||
select: { id: true, stage: true, value: true, updatedAt: true, assignedTo: true, creatorId: true, isDemoDone: true }
|
||||
}),
|
||||
|
||||
// Expense breakdown
|
||||
|
|
@ -177,7 +177,7 @@ export class ReportsService {
|
|||
{ name: 'Lead', count: opportunities.filter(o => o.stage === 'LEAD').length },
|
||||
{ name: 'Qualified', count: opportunities.filter(o => o.stage === 'QUALIFIED').length },
|
||||
{ name: 'Potential', count: opportunities.filter(o => o.stage === 'POTENTIAL').length },
|
||||
{ name: 'Demo', count: opportunities.filter(o => o.stage === 'DEMO').length },
|
||||
{ name: 'Demo Done', count: opportunities.filter(o => o.isDemoDone).length },
|
||||
{ name: 'Won', count: opportunities.filter(o => o.stage === 'WON').length },
|
||||
],
|
||||
expenses: [
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export class UploadController {
|
|||
if (!fs.existsSync(filePath)) {
|
||||
throw new HttpException('File not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
|
||||
// Let express handle Content-Type and other headers automatically
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,16 +30,16 @@ export class WhatsappService {
|
|||
return this.sendTemplateMessage(to, 'GREETING', [clientName]);
|
||||
}
|
||||
|
||||
async sendQuote(to: string, quoteDetails: string, mediaUrl?: string) {
|
||||
async sendQuote(to: string, clientName: string, amount: string, mediaUrl?: string) {
|
||||
// Fallback for mock mode if URL is not configured
|
||||
if (!process.env.WHATSAPP_API_URL) {
|
||||
this.logger.warn('WHATSAPP_API_URL missing. Running in MOCK MODE.');
|
||||
this.logger.log(`[MOCK] Would send to ${to}: "${quoteDetails}" with media: ${mediaUrl}`);
|
||||
this.logger.log(`[MOCK] Would send to ${to}: Quote for ${clientName} - Amount: ${amount} with media: ${mediaUrl}`);
|
||||
return { success: true, sid: 'mock_sid_' + Date.now() };
|
||||
}
|
||||
|
||||
// Templates usually have placeholders. We'll map existing body to fields.
|
||||
return this.sendTemplateMessage(to, 'QUOTE', [quoteDetails], mediaUrl);
|
||||
return this.sendTemplateMessage(to, 'QUOTE', [clientName, amount], mediaUrl);
|
||||
}
|
||||
|
||||
async sendNudge(to: string, clientName: string, stage: string) {
|
||||
|
|
@ -57,7 +57,7 @@ export class WhatsappService {
|
|||
return { success: false, error: `Template not found: ${type}` };
|
||||
}
|
||||
|
||||
const mobile = this.formatPhoneNumber(to);
|
||||
const mobile = this.formatPhoneNumber(to).replace('+', ''); // Many APIs don't want the +
|
||||
const url = process.env.WHATSAPP_API_URL;
|
||||
const cid = process.env.WHATSAPP_CID;
|
||||
|
||||
|
|
@ -70,20 +70,37 @@ export class WhatsappService {
|
|||
template: template.templateName,
|
||||
ln: template.language,
|
||||
mn: mobile,
|
||||
category: 'utility', // Gateway expects 'category' not 'cat'
|
||||
fields: fields.join('|'),
|
||||
type: 'whatsapp',
|
||||
headimg: headimg || ""
|
||||
};
|
||||
|
||||
let finalHeadImg = headimg || "";
|
||||
// If headimg is a local IP, the public gateway won't be able to fetch it.
|
||||
// For testing, we substitute it with a public dummy PDF.
|
||||
if (finalHeadImg.includes('192.168.') || finalHeadImg.includes('localhost') || finalHeadImg.includes('127.0.0.1')) {
|
||||
const dummyPdf = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf';
|
||||
this.logger.warn(`Local PDF detected (${finalHeadImg}). Substituting with public dummy PDF for testing: ${dummyPdf}`);
|
||||
finalHeadImg = dummyPdf;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (key === 'headimg') {
|
||||
formData.append(key, finalHeadImg);
|
||||
} else {
|
||||
formData.append(key, value || "");
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams(payload as any).toString();
|
||||
this.logger.log(`Attempting to send WhatsApp via POST: ${url}`);
|
||||
this.logger.log(`Payload: ${JSON.stringify(payload)}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body
|
||||
body: formData
|
||||
});
|
||||
|
||||
const resultText = await response.text();
|
||||
|
|
@ -127,8 +144,8 @@ export class WhatsappService {
|
|||
}
|
||||
}
|
||||
|
||||
async sendOnboarding(to: string, clientName: string, memberId: string, company: string = "Ignosi") {
|
||||
return this.sendTemplateMessage(to, 'ONBOARDING', [clientName, memberId, company]);
|
||||
async sendOnboarding(to: string, clientName: string, memberId: string) {
|
||||
return this.sendTemplateMessage(to, 'ONBOARDING', [clientName, memberId]);
|
||||
}
|
||||
|
||||
async sendFollowupReminder(to: string, clientName: string, date: string) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
async function test() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('test', 'value');
|
||||
console.log('FormData created successfully');
|
||||
const res = await fetch('https://google.com');
|
||||
console.log('Fetch worked. Status:', res.status);
|
||||
} catch (err) {
|
||||
console.error('Test failed:', err.message);
|
||||
}
|
||||
}
|
||||
test();
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue