parent
71ca116ad8
commit
370c14f93a
|
|
@ -0,0 +1,58 @@
|
|||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
/generated/prisma
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^5.22.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
console.log("DATABASE_URL:", process.env.DATABASE_URL);
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NULL,
|
||||
`role` ENUM('ADMIN', 'SALES_PERSON') NOT NULL DEFAULT 'SALES_PERSON',
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `User_email_key`(`email`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Attendance` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`checkInTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`checkOutTime` DATETIME(3) NULL,
|
||||
`checkInLat` DOUBLE NULL,
|
||||
`checkInLng` DOUBLE NULL,
|
||||
`checkInLoc` VARCHAR(191) NULL,
|
||||
`checkOutLat` DOUBLE NULL,
|
||||
`checkOutLng` DOUBLE NULL,
|
||||
`checkOutLoc` VARCHAR(191) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Client` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`phone` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NULL,
|
||||
`address` VARCHAR(191) NULL,
|
||||
`lat` DOUBLE NULL,
|
||||
`lng` DOUBLE NULL,
|
||||
`landmark` VARCHAR(191) NULL,
|
||||
`status` ENUM('LEAD', 'PROSPECT', 'CUSTOMER', 'CLOSED') NOT NULL DEFAULT 'LEAD',
|
||||
`assignedTo` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Meeting` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NULL,
|
||||
`date` DATETIME(3) NOT NULL,
|
||||
`clientId` VARCHAR(191) NOT NULL,
|
||||
`createdBy` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Product` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NULL,
|
||||
`price` DOUBLE NOT NULL,
|
||||
`imageUrl` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Enquiry` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`clientId` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`conversation` TEXT NULL,
|
||||
`status` VARCHAR(191) NOT NULL DEFAULT 'OPEN',
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Followup` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`clientId` VARCHAR(191) NOT NULL,
|
||||
`enquiryId` VARCHAR(191) NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`date` DATETIME(3) NOT NULL,
|
||||
`notes` VARCHAR(191) NULL,
|
||||
`status` VARCHAR(191) NOT NULL DEFAULT 'PENDING',
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Expense` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`amount` DOUBLE NOT NULL,
|
||||
`description` VARCHAR(191) NOT NULL,
|
||||
`date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`imageUrl` VARCHAR(191) NULL,
|
||||
`status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'REIMBURSED') NOT NULL DEFAULT 'PENDING',
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Incentive` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`targetAmount` DOUBLE NOT NULL,
|
||||
`achievedAmount` DOUBLE NOT NULL DEFAULT 0,
|
||||
`rewardAmount` DOUBLE NULL,
|
||||
`type` ENUM('DAILY', 'MONTHLY') NOT NULL,
|
||||
`startDate` DATETIME(3) NOT NULL,
|
||||
`endDate` DATETIME(3) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Quote` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`enquiryId` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
`items` JSON NOT NULL,
|
||||
`totalAmount` DOUBLE NOT NULL,
|
||||
`status` ENUM('DRAFT', 'SENT', 'ACCEPTED', 'REJECTED') NOT NULL DEFAULT 'DRAFT',
|
||||
`pdfUrl` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `_EnquiryToProduct` (
|
||||
`A` VARCHAR(191) NOT NULL,
|
||||
`B` VARCHAR(191) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `_EnquiryToProduct_AB_unique`(`A`, `B`),
|
||||
INDEX `_EnquiryToProduct_B_index`(`B`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Attendance` ADD CONSTRAINT `Attendance_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Client` ADD CONSTRAINT `Client_assignedTo_fkey` FOREIGN KEY (`assignedTo`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Meeting` ADD CONSTRAINT `Meeting_clientId_fkey` FOREIGN KEY (`clientId`) REFERENCES `Client`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Meeting` ADD CONSTRAINT `Meeting_createdBy_fkey` FOREIGN KEY (`createdBy`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Enquiry` ADD CONSTRAINT `Enquiry_clientId_fkey` FOREIGN KEY (`clientId`) REFERENCES `Client`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Enquiry` ADD CONSTRAINT `Enquiry_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Followup` ADD CONSTRAINT `Followup_clientId_fkey` FOREIGN KEY (`clientId`) REFERENCES `Client`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Followup` ADD CONSTRAINT `Followup_enquiryId_fkey` FOREIGN KEY (`enquiryId`) REFERENCES `Enquiry`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Followup` ADD CONSTRAINT `Followup_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Expense` ADD CONSTRAINT `Expense_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Incentive` ADD CONSTRAINT `Incentive_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Quote` ADD CONSTRAINT `Quote_enquiryId_fkey` FOREIGN KEY (`enquiryId`) REFERENCES `Enquiry`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Quote` ADD CONSTRAINT `Quote_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `_EnquiryToProduct` ADD CONSTRAINT `_EnquiryToProduct_A_fkey` FOREIGN KEY (`A`) REFERENCES `Enquiry`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `_EnquiryToProduct` ADD CONSTRAINT `_EnquiryToProduct_B_fkey` FOREIGN KEY (`B`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "mysql"
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
engineType = "library"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Attendance {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
date DateTime @default(now())
|
||||
checkInTime DateTime @default(now())
|
||||
checkOutTime DateTime?
|
||||
checkInLat Float?
|
||||
checkInLng Float?
|
||||
checkInLoc String?
|
||||
checkOutLat Float?
|
||||
checkOutLng Float?
|
||||
checkOutLoc String?
|
||||
user User @relation(fields: [userId], references: [id], map: "Attendance_userId_fkey")
|
||||
|
||||
@@index([userId], map: "Attendance_userId_fkey")
|
||||
@@map("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[]
|
||||
|
||||
@@index([assignedTo], map: "Client_assignedTo_fkey")
|
||||
@@map("client")
|
||||
}
|
||||
|
||||
model Enquiry {
|
||||
id String @id @default(uuid())
|
||||
clientId String
|
||||
userId String
|
||||
conversation String? @db.Text
|
||||
status String @default("OPEN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
client Client @relation(fields: [clientId], references: [id], map: "Enquiry_clientId_fkey")
|
||||
user User @relation(fields: [userId], references: [id], map: "Enquiry_userId_fkey")
|
||||
followups Followup[]
|
||||
quotes Quote[]
|
||||
products Product[] @relation("enquirytoproduct")
|
||||
|
||||
@@index([clientId], map: "Enquiry_clientId_fkey")
|
||||
@@index([userId], map: "Enquiry_userId_fkey")
|
||||
@@map("enquiry")
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
amount Float
|
||||
description String
|
||||
date DateTime @default(now())
|
||||
imageUrl String?
|
||||
status expense_status @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], map: "Expense_userId_fkey")
|
||||
|
||||
@@index([userId], map: "Expense_userId_fkey")
|
||||
@@map("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")
|
||||
|
||||
@@index([clientId], map: "Followup_clientId_fkey")
|
||||
@@index([enquiryId], map: "Followup_enquiryId_fkey")
|
||||
@@index([userId], map: "Followup_userId_fkey")
|
||||
@@map("followup")
|
||||
}
|
||||
|
||||
model Incentive {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
targetAmount Float
|
||||
achievedAmount Float @default(0)
|
||||
rewardAmount Float?
|
||||
type incentive_type
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], map: "Incentive_userId_fkey")
|
||||
|
||||
@@index([userId], map: "Incentive_userId_fkey")
|
||||
@@map("incentive")
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
lat Float
|
||||
lng Float
|
||||
timestamp DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], map: "Location_userId_fkey")
|
||||
|
||||
@@index([userId], map: "Location_userId_fkey")
|
||||
@@map("location")
|
||||
}
|
||||
|
||||
model Meeting {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
date DateTime
|
||||
clientId String
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
client Client @relation(fields: [clientId], references: [id], map: "Meeting_clientId_fkey")
|
||||
user User @relation(fields: [createdBy], references: [id], map: "Meeting_createdBy_fkey")
|
||||
|
||||
@@index([clientId], map: "Meeting_clientId_fkey")
|
||||
@@index([createdBy], map: "Meeting_createdBy_fkey")
|
||||
@@map("meeting")
|
||||
}
|
||||
|
||||
model Opportunity {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
value Float
|
||||
stage opportunity_stage @default(LEAD)
|
||||
priority String?
|
||||
clientId String
|
||||
assignedTo String
|
||||
expectedCloseDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
competitorMention String?
|
||||
demoContactDetails String?
|
||||
demoPersonName String?
|
||||
freeOffers String?
|
||||
keyQueries String?
|
||||
negotiationRemarks String?
|
||||
objections String?
|
||||
paymentMode String?
|
||||
specialRate Float?
|
||||
creatorId String?
|
||||
demoOwnerId String?
|
||||
closingOwnerId String?
|
||||
user User @relation("opportunity_assignedToTouser", fields: [assignedTo], references: [id], map: "Opportunity_assignedTo_fkey")
|
||||
client Client @relation(fields: [clientId], references: [id], map: "Opportunity_clientId_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[]
|
||||
|
||||
@@index([assignedTo], map: "Opportunity_assignedTo_fkey")
|
||||
@@index([clientId], map: "Opportunity_clientId_fkey")
|
||||
@@index([creatorId], map: "Opportunity_creatorId_fkey")
|
||||
@@index([demoOwnerId], map: "Opportunity_demoOwnerId_fkey")
|
||||
@@index([closingOwnerId], map: "Opportunity_closingOwnerId_fkey")
|
||||
@@map("opportunity")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
workOrderId String
|
||||
amount Float
|
||||
mode String?
|
||||
status String @default("PENDING")
|
||||
paymentLink String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], map: "Payment_workOrderId_fkey")
|
||||
|
||||
@@index([workOrderId], map: "Payment_workOrderId_fkey")
|
||||
@@map("payment")
|
||||
}
|
||||
|
||||
model PerformanceScore {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
score Float @default(0)
|
||||
revenueScore Float @default(0)
|
||||
conversionScore Float @default(0)
|
||||
activityScore Float @default(0)
|
||||
disciplineScore Float @default(0)
|
||||
dataQualityScore Float @default(0)
|
||||
tag String @default("ON_TRACK")
|
||||
date DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], map: "PerformanceScore_userId_fkey")
|
||||
|
||||
@@index([userId], map: "PerformanceScore_userId_fkey")
|
||||
@@map("performancescore")
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String?
|
||||
price Float
|
||||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
enquiries Enquiry[] @relation("enquirytoproduct")
|
||||
|
||||
@@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")
|
||||
|
||||
@@index([enquiryId], map: "Quote_enquiryId_fkey")
|
||||
@@index([userId], map: "Quote_userId_fkey")
|
||||
@@map("quote")
|
||||
}
|
||||
|
||||
model StrategicActivity {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
type String
|
||||
description String?
|
||||
leadsGenerated Int @default(0)
|
||||
revenueGenerated Float @default(0)
|
||||
date DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
metadata String? @db.LongText
|
||||
user User @relation(fields: [userId], references: [id], map: "StrategicActivity_userId_fkey")
|
||||
|
||||
@@index([userId], map: "StrategicActivity_userId_fkey")
|
||||
@@map("strategicactivity")
|
||||
}
|
||||
|
||||
model Target {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
month Int
|
||||
year Int
|
||||
monthlyTarget Float
|
||||
minTarget Float
|
||||
weeklyTarget Float?
|
||||
dailyLeadTarget Int?
|
||||
requiredLeads 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")
|
||||
|
||||
@@index([userId], map: "Target_userId_fkey")
|
||||
@@map("target")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
title String
|
||||
body String @db.Text
|
||||
type String @default("INFO")
|
||||
isRead Boolean @default(false)
|
||||
metadata String? @db.LongText
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], map: "Notification_userId_fkey")
|
||||
|
||||
@@index([userId], map: "Notification_userId_fkey")
|
||||
@@map("notification")
|
||||
}
|
||||
|
||||
model SystemConfig {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
value String @db.Text
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("systemconfig")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique(map: "User_email_key")
|
||||
password String
|
||||
name String?
|
||||
role user_role @default(TELESALES_EXECUTIVE)
|
||||
status user_status @default(APPROVED)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
managerId String?
|
||||
attendance Attendance[]
|
||||
clients Client[]
|
||||
enquiries Enquiry[]
|
||||
expenses Expense[]
|
||||
followups Followup[]
|
||||
incentives Incentive[]
|
||||
locations Location[]
|
||||
meetings Meeting[]
|
||||
opportunities Opportunity[] @relation("opportunity_assignedToTouser")
|
||||
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")
|
||||
|
||||
@@index([managerId], map: "User_managerId_fkey")
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model WorkOrder {
|
||||
id String @id @default(uuid())
|
||||
opportunityId String
|
||||
status String @default("PENDING")
|
||||
details String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
payments Payment[]
|
||||
opportunity Opportunity @relation(fields: [opportunityId], references: [id], map: "WorkOrder_opportunityId_fkey")
|
||||
|
||||
@@index([opportunityId], map: "WorkOrder_opportunityId_fkey")
|
||||
@@map("workorder")
|
||||
}
|
||||
|
||||
enum opportunity_stage {
|
||||
LEAD
|
||||
QUALIFIED
|
||||
POTENTIAL
|
||||
DEMO
|
||||
WON
|
||||
LOST
|
||||
}
|
||||
|
||||
enum user_role {
|
||||
ADMIN
|
||||
GENERAL_MANAGER
|
||||
MANAGER
|
||||
OFFICER
|
||||
TELESALES_EXECUTIVE
|
||||
}
|
||||
|
||||
enum user_status {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum incentive_type {
|
||||
DAILY
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
enum quote_status {
|
||||
DRAFT
|
||||
SENT
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum expense_status {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
REIMBURSED
|
||||
}
|
||||
|
||||
enum client_status {
|
||||
LEAD
|
||||
QUALITY
|
||||
POTENTIAL
|
||||
DEMO
|
||||
SALES
|
||||
CLOSED
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const password = await bcrypt.hash('password', 10);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'admin@igcrm.com' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'd9b0a1d0-1e1e-4b1e-8e1e-1e1e1e1e1e1e', // Fixed ID for admin
|
||||
email: 'admin@igcrm.com',
|
||||
name: 'Admin User',
|
||||
password,
|
||||
role: 'ADMIN',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log('User created:', user);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
-- Seed WhatsApp Templates for Igcrm (Updated with user preference)
|
||||
|
||||
-- 1. Onboarding Template
|
||||
INSERT INTO whatsapp_template (id, type, templateName, language, body)
|
||||
VALUES (UUID(), 'ONBOARDING', 'igcrm_membership_activation', 'en', 'Membership activation has been completed for {{1}}. Your Member ID {{2}} is registered with {{3}}. Powered By Ignosi')
|
||||
ON DUPLICATE KEY UPDATE templateName = 'igcrm_membership_activation', body = 'Membership activation has been completed for {{1}}. Your Member ID {{2}} is registered with {{3}}. Powered By Ignosi';
|
||||
|
||||
-- 2. Followup Template
|
||||
INSERT INTO whatsapp_template (id, type, templateName, language, body)
|
||||
VALUES (UUID(), 'FOLLOWUP', 'igcrm_followup_reminder', 'en', 'Hi {{1}}, this is a reminder for your scheduled follow-up on {{2}}.')
|
||||
ON DUPLICATE KEY UPDATE templateName = 'igcrm_followup_reminder';
|
||||
|
||||
-- 3. Pipeline Update Template
|
||||
INSERT INTO whatsapp_template (id, type, templateName, language, body)
|
||||
VALUES (UUID(), 'PIPELINE_UPDATE', 'igcrm_stage_update', 'en', 'Hi {{1}}, your inquiry has moved from {{2}} to {{3}}.')
|
||||
ON DUPLICATE KEY UPDATE templateName = 'igcrm_stage_update';
|
||||
|
||||
-- 4. Quote Template
|
||||
INSERT INTO whatsapp_template (id, type, templateName, language, body)
|
||||
VALUES (UUID(), 'QUOTE', 'igcrm_quote_created', 'en', 'Hi {{1}}, your quote for amount {{2}} has been generated.')
|
||||
ON DUPLICATE KEY UPDATE templateName = 'igcrm_quote_created';
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Running raw SQL to fix statuses...');
|
||||
try {
|
||||
// Fix empty strings
|
||||
const res1 = await prisma.$executeRawUnsafe("UPDATE Client SET status = 'LEAD' WHERE status = ''");
|
||||
console.log('Fixed empty statuses:', res1);
|
||||
|
||||
// Fix 'CUSTOMER' if it exists (mapping it to SALES)
|
||||
const res2 = await prisma.$executeRawUnsafe("UPDATE Client SET status = 'SALES' WHERE status = 'CUSTOMER'");
|
||||
console.log('Fixed CUSTOMER statuses:', res2);
|
||||
|
||||
// Also check Opportunity stages
|
||||
const res3 = await prisma.$executeRawUnsafe("UPDATE Opportunity SET stage = 'LEAD' WHERE stage = ''");
|
||||
console.log('Fixed empty Opportunity stages:', res3);
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Fetching distinct statuses...');
|
||||
try {
|
||||
const statuses = await prisma.client.groupBy({
|
||||
by: ['status'],
|
||||
});
|
||||
console.log('Current statuses in DB:', statuses);
|
||||
|
||||
// Update invalid statuses
|
||||
// If status is empty string or 'CUSTOMER', change to 'SALES' or 'LEAD'
|
||||
const results = await prisma.client.updateMany({
|
||||
where: { status: '' },
|
||||
data: { status: 'LEAD' }
|
||||
});
|
||||
console.log('Updated empty statuses:', results.count);
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
const { PrismaClient } = require('@prisma/client');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function syncLeadsToPipeline() {
|
||||
console.log('--- Syncing Quality/Potential Clients to Pipeline ---');
|
||||
|
||||
// Find clients with statuses that should be in the pipeline
|
||||
const clients = await prisma.client.findMany({
|
||||
where: {
|
||||
status: { in: ['QUALITY', 'POTENTIAL', 'DEMO', 'SALES', 'CLOSED'] }
|
||||
},
|
||||
include: {
|
||||
opportunities: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${clients.length} clients with relevant status.`);
|
||||
|
||||
const stageMap = {
|
||||
'QUALITY': 'QUALIFIED',
|
||||
'POTENTIAL': 'POTENTIAL',
|
||||
'DEMO': 'DEMO',
|
||||
'SALES': 'WON',
|
||||
'CLOSED': 'WON'
|
||||
};
|
||||
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const client of clients) {
|
||||
const targetStage = stageMap[client.status];
|
||||
if (!targetStage) continue;
|
||||
|
||||
// Check if they already have an active opportunity
|
||||
const activeOpp = client.opportunities.find(o => !['WON', 'LOST'].includes(o.stage));
|
||||
|
||||
if (activeOpp) {
|
||||
if (activeOpp.stage !== targetStage) {
|
||||
await prisma.opportunity.update({
|
||||
where: { id: activeOpp.id },
|
||||
data: { stage: targetStage }
|
||||
});
|
||||
updatedCount++;
|
||||
}
|
||||
} else {
|
||||
// Check if they have a WON opportunity if status is SALES/CLOSED
|
||||
const wonOpp = client.opportunities.find(o => o.stage === 'WON');
|
||||
if (wonOpp) continue;
|
||||
|
||||
// Create new opportunity
|
||||
await prisma.opportunity.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
title: `${client.name} - ${client.status}`,
|
||||
value: 0,
|
||||
clientId: client.id,
|
||||
assignedTo: client.assignedTo || '805c8cf3-db7c-47c3-b42e-588531ba89a1', // Default to Admin if no owner
|
||||
stage: targetStage,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
createdCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Finished: Created ${createdCount} and updated ${updatedCount} opportunities.`);
|
||||
}
|
||||
|
||||
syncLeadsToPipeline()
|
||||
.catch(e => console.error(e))
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AttendanceModule } from './attendance/attendance.module';
|
||||
import { ClientsModule } from './clients/clients.module';
|
||||
import { MeetingsModule } from './meetings/meetings.module';
|
||||
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { EnquiriesModule } from './enquiries/enquiries.module';
|
||||
import { ExpensesModule } from './expenses/expenses.module';
|
||||
import { IncentivesModule } from './incentives/incentives.module';
|
||||
import { QuotesModule } from './quotes/quotes.module';
|
||||
import { FollowupsModule } from './followups/followups.module';
|
||||
import { UploadModule } from './upload/upload.module';
|
||||
import { OpportunitiesModule } from './opportunities/opportunities.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { LocationsModule } from './locations/locations.module';
|
||||
import { TargetsModule } from './targets/targets.module';
|
||||
import { PerformanceModule } from './performance/performance.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { AutomationModule } from './automation/automation.module';
|
||||
import { StrategicActivitiesModule } from './strategic-activities/strategic-activities.module';
|
||||
import { WorkOrdersModule } from './work-orders/work-orders.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
PrismaModule,
|
||||
AttendanceModule,
|
||||
ClientsModule,
|
||||
MeetingsModule,
|
||||
WhatsappModule,
|
||||
ProductsModule,
|
||||
EnquiriesModule,
|
||||
ExpensesModule,
|
||||
IncentivesModule,
|
||||
QuotesModule,
|
||||
FollowupsModule,
|
||||
UploadModule,
|
||||
OpportunitiesModule,
|
||||
DashboardModule,
|
||||
ReportsModule,
|
||||
LocationsModule,
|
||||
TargetsModule,
|
||||
PerformanceModule,
|
||||
AutomationModule,
|
||||
StrategicActivitiesModule,
|
||||
WorkOrdersModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule { }
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { CreateAttendanceDto } from './dto/create-attendance.dto';
|
||||
import { UpdateAttendanceDto } from './dto/update-attendance.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('attendance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class AttendanceController {
|
||||
constructor(private readonly attendanceService: AttendanceService) { }
|
||||
|
||||
@Post('check-in')
|
||||
checkIn(@Request() req, @Body() createAttendanceDto: CreateAttendanceDto) {
|
||||
return this.attendanceService.checkIn(req.user.userId, createAttendanceDto);
|
||||
}
|
||||
|
||||
@Patch('check-out/:id')
|
||||
checkOut(@Param('id') id: string, @Body() updateAttendanceDto: UpdateAttendanceDto) {
|
||||
return this.attendanceService.checkOut(id, updateAttendanceDto);
|
||||
}
|
||||
|
||||
@Get('my-history')
|
||||
getMyHistory(@Request() req) {
|
||||
return this.attendanceService.findMyHistory(req.user.userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req, @Query('date') date?: string) {
|
||||
return this.attendanceService.findAll(req.user, date);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.attendanceService.findOne(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
controllers: [AttendanceController],
|
||||
providers: [AttendanceService],
|
||||
})
|
||||
export class AttendanceModule {}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateAttendanceDto } from './dto/create-attendance.dto';
|
||||
import { UpdateAttendanceDto } from './dto/update-attendance.dto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private usersService: UsersService
|
||||
) { }
|
||||
|
||||
private getDateRange(dateStr?: string) {
|
||||
const start = dateStr ? new Date(dateStr) : new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
async checkIn(userId: string, createAttendanceDto: CreateAttendanceDto) {
|
||||
// Check if already checked in today? (Optional enhancement)
|
||||
return this.prisma.attendance.create({
|
||||
data: {
|
||||
userId,
|
||||
checkInLat: createAttendanceDto.latitude,
|
||||
checkInLng: createAttendanceDto.longitude,
|
||||
checkInLoc: createAttendanceDto.address,
|
||||
checkInTime: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async checkOut(id: string, updateAttendanceDto: UpdateAttendanceDto) {
|
||||
return this.prisma.attendance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
checkOutLat: updateAttendanceDto.latitude,
|
||||
checkOutLng: updateAttendanceDto.longitude,
|
||||
checkOutLoc: updateAttendanceDto.address,
|
||||
checkOutTime: new Date(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(user: any, date?: string) {
|
||||
const { start, end } = this.getDateRange(date);
|
||||
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.attendance.findMany({
|
||||
where: {
|
||||
checkInTime: { gte: start, lte: end }
|
||||
},
|
||||
include: { user: true },
|
||||
orderBy: { checkInTime: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.attendance.findMany({
|
||||
where: {
|
||||
userId: { in: allowedUserIds },
|
||||
checkInTime: { gte: start, lte: end }
|
||||
},
|
||||
include: { user: true },
|
||||
orderBy: { checkInTime: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.attendance.findUnique({ where: { id }, include: { user: true } });
|
||||
}
|
||||
|
||||
// Find active check-in for user
|
||||
findActiveCheckIn(userId: string) {
|
||||
// Logic to find if user has a check-in without check-out today
|
||||
// For simplicity, returning the latest one
|
||||
return this.prisma.attendance.findFirst({
|
||||
where: { userId, checkOutTime: null },
|
||||
orderBy: { checkInTime: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
findMyHistory(userId: string) {
|
||||
return this.prisma.attendance.findMany({
|
||||
where: { userId },
|
||||
orderBy: { checkInTime: 'desc' },
|
||||
take: 30 // Last 30 records for now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAttendanceDto {
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
latitude: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
longitude: number;
|
||||
|
||||
@IsOptional()
|
||||
address?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateAttendanceDto } from './create-attendance.dto';
|
||||
import { IsDateString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateAttendanceDto extends PartialType(CreateAttendanceDto) {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
checkOutTime?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Attendance {}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Controller, Request, Post, UseGuards, Body, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { CreateUserDto } from '../users/dto/create-user.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private usersService: UsersService
|
||||
) { }
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() req) {
|
||||
const user = await this.authService.validateUser(req.email, req.password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConstants } from './constants';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: jwtConstants.secret,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule { }
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
) { }
|
||||
|
||||
async validateUser(email: string, pass: string): Promise<any> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (user && (await bcrypt.compare(pass, user.password))) {
|
||||
if ((user as any).status === 'PENDING') {
|
||||
throw new UnauthorizedException('Account pending admin approval');
|
||||
}
|
||||
if ((user as any).status === 'REJECTED') {
|
||||
throw new UnauthorizedException('Account approval rejected');
|
||||
}
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: any) {
|
||||
const payload = { email: user.email, sub: user.id, role: user.role };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export class jwtConstants {
|
||||
static secret = 'secretKey'; // In production, use environment variable
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { jwtConstants } from './constants';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtConstants.secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return { id: payload.sub, userId: payload.sub, email: payload.email, role: payload.role };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AutomationService } from './automation.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, WhatsappModule],
|
||||
providers: [AutomationService],
|
||||
})
|
||||
export class AutomationModule {}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class AutomationService {
|
||||
private readonly logger = new Logger(AutomationService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private whatsapp: WhatsappService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_9AM)
|
||||
async handleDailyAutomation() {
|
||||
this.logger.log('Running Daily Follow-up Automation...');
|
||||
|
||||
await Promise.all([
|
||||
this.processPhase1Nudges(),
|
||||
this.processEscalations(),
|
||||
]);
|
||||
}
|
||||
|
||||
private async processPhase1Nudges() {
|
||||
const leads = await this.prisma.client.findMany({
|
||||
where: { status: 'LEAD' },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
for (const lead of leads) {
|
||||
const age = differenceInDays(new Date(), lead.createdAt);
|
||||
|
||||
let message = '';
|
||||
if (age === 1) message = `Hi ${lead.name}, thanks for inquiring with Ignosi! Here is a quick intro to our services.`;
|
||||
else if (age === 2) message = `Hi ${lead.name}, did you know that our CRM can increase your sales by 30%? Check this video.`;
|
||||
else if (age === 3) message = `Hi ${lead.name}, here is what our customers say about us. [Testimonial Link]`;
|
||||
else if (age === 5) message = `Still looking for a solution, ${lead.name}? We have a special offer for you today.`;
|
||||
else if (age === 7) message = `Final follow-up, ${lead.name}. Would you like a free demo this week?`;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processEscalations(){
|
||||
const delayedDemos = await this.prisma.opportunity.findMany({
|
||||
where: {
|
||||
stage: 'DEMO',
|
||||
updatedAt: { lte: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
include: { user: { include: { manager: true } } },
|
||||
});
|
||||
|
||||
for (const opp of delayedDemos) {
|
||||
if (opp.user?.manager?.email) {
|
||||
this.logger.warn(`Escalating delayed demo: ${opp.title} for user ${opp.user.name}`);
|
||||
// Send alert to manager via Email/WhatsApp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { ClientsService } from './clients.service';
|
||||
import { CreateClientDto } from './dto/create-client.dto';
|
||||
import { UpdateClientDto } from './dto/update-client.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('clients')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class ClientsController {
|
||||
constructor(private readonly clientsService: ClientsService) { }
|
||||
|
||||
@Post()
|
||||
create(@Request() req, @Body() createClientDto: CreateClientDto) {
|
||||
if (!createClientDto.assignedTo) {
|
||||
createClientDto.assignedTo = req.user.id;
|
||||
}
|
||||
return this.clientsService.create(createClientDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req) {
|
||||
return this.clientsService.findAll(req.user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.clientsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateClientDto: UpdateClientDto) {
|
||||
return this.clientsService.update(id, updateClientDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.clientsService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ClientsService } from './clients.service';
|
||||
import { ClientsController } from './clients.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, WhatsappModule],
|
||||
controllers: [ClientsController],
|
||||
providers: [ClientsService],
|
||||
})
|
||||
export class ClientsModule { }
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateClientDto } from './dto/create-client.dto';
|
||||
import { UpdateClientDto } from './dto/update-client.dto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
import { user_role, client_status } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ClientsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private usersService: UsersService,
|
||||
private whatsappService: WhatsappService
|
||||
) { }
|
||||
|
||||
async create(createClientDto: CreateClientDto) {
|
||||
console.log('API Received Client DTO:', JSON.stringify(createClientDto, null, 2));
|
||||
const client = await this.prisma.client.create({
|
||||
data: createClientDto
|
||||
});
|
||||
|
||||
// Trigger onboarding message if client is created with SALES status
|
||||
if (client.status === client_status.SALES || client.status === client_status.CLOSED) {
|
||||
try {
|
||||
await this.whatsappService.sendOnboarding(client.phone, client.name, client.id.substring(0, 8));
|
||||
} catch (err) {
|
||||
console.error('Failed to send onboarding WhatsApp:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async findAll(user: any) {
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.client.findMany({
|
||||
include: { user: true }
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.client.findMany({
|
||||
where: {
|
||||
assignedTo: { in: allowedUserIds }
|
||||
},
|
||||
include: { user: true }
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.client.findUnique({
|
||||
where: { id },
|
||||
include: { user: true, meetings: true }
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateClientDto: UpdateClientDto) {
|
||||
return this.prisma.client.update({
|
||||
where: { id },
|
||||
data: updateClientDto
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.client.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { IsEnum, IsString, IsNotEmpty, IsOptional, IsEmail, IsNumber } from 'class-validator';
|
||||
import { client_status } from '@prisma/client';
|
||||
|
||||
export class CreateClientDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
address?: string;
|
||||
|
||||
@IsEnum(client_status)
|
||||
@IsOptional()
|
||||
status?: client_status;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
assignedTo?: string; // User ID
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
landmark?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
lat?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
lng?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateClientDto } from './create-client.dto';
|
||||
|
||||
export class UpdateClientDto extends PartialType(CreateClientDto) { }
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Client {}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('dashboard')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) { }
|
||||
|
||||
@Get('stats')
|
||||
getStats(@Request() req) {
|
||||
return this.dashboardService.getStats(req.user);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule { }
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
import { startOfDay, startOfMonth } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService
|
||||
) { }
|
||||
|
||||
async getStats(user: any) {
|
||||
let allowedUserIds: string[] = [user.id];
|
||||
|
||||
if (user.role === user_role.ADMIN) {
|
||||
// Admin sees everything, leave allowedUserIds empty or filter differently
|
||||
// But for simplicity in query, we can use empty where if Admin
|
||||
} else {
|
||||
const subordinates = await this.usersService.getSubordinateIds(user.id);
|
||||
allowedUserIds = [user.id, ...subordinates];
|
||||
}
|
||||
|
||||
const userFilter = user.role === user_role.ADMIN ? {} : { userId: { in: allowedUserIds } };
|
||||
const leadSharingFilter = user.role === user_role.ADMIN ? {} : { OR: [{ assignedTo: { in: allowedUserIds } }, { creatorId: { in: allowedUserIds } }] };
|
||||
const clientFilter = user.role === user_role.ADMIN ? {} : { assignedTo: { in: allowedUserIds } };
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const monthStart = startOfMonth(new Date());
|
||||
|
||||
const [
|
||||
enquiriesCount,
|
||||
enquiriesToday,
|
||||
opportunityStats,
|
||||
monthlyRevenue,
|
||||
clientsCount,
|
||||
recentEnquiries,
|
||||
recentOpportunities,
|
||||
pendingExpenses,
|
||||
overdueFollowups,
|
||||
todayFollowups,
|
||||
newItems
|
||||
] = await Promise.all([
|
||||
// Total Enquiries
|
||||
this.prisma.enquiry.count({ where: userFilter }),
|
||||
|
||||
// Enquiries Today
|
||||
this.prisma.enquiry.count({
|
||||
where: {
|
||||
...userFilter,
|
||||
createdAt: { gte: today }
|
||||
}
|
||||
}),
|
||||
|
||||
// Pipeline Stats (Non-WON)
|
||||
this.prisma.opportunity.aggregate({
|
||||
where: {
|
||||
...leadSharingFilter,
|
||||
stage: { not: 'WON' }
|
||||
},
|
||||
_sum: { value: true },
|
||||
_count: true
|
||||
}),
|
||||
|
||||
// Monthly Revenue (WON this month)
|
||||
this.prisma.opportunity.aggregate({
|
||||
where: {
|
||||
...leadSharingFilter,
|
||||
stage: 'WON',
|
||||
updatedAt: { gte: monthStart }
|
||||
},
|
||||
_sum: { value: true }
|
||||
}),
|
||||
|
||||
// Total Customers (for conversion rate)
|
||||
this.prisma.client.count({
|
||||
where: {
|
||||
...clientFilter,
|
||||
status: { in: ['SALES', 'CLOSED'] }
|
||||
}
|
||||
}),
|
||||
|
||||
// Recent Activity - Enquiries
|
||||
this.prisma.enquiry.findMany({
|
||||
where: userFilter,
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { client: true, user: true }
|
||||
}),
|
||||
|
||||
// Recent Activity - Opportunities
|
||||
this.prisma.opportunity.findMany({
|
||||
where: leadSharingFilter,
|
||||
take: 5,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { client: true, user: true }
|
||||
}),
|
||||
|
||||
// Pending Expenses (Managers/Admins)
|
||||
this.prisma.expense.count({
|
||||
where: {
|
||||
...userFilter,
|
||||
status: 'PENDING'
|
||||
}
|
||||
}),
|
||||
|
||||
// Overdue Followups
|
||||
this.prisma.followup.count({
|
||||
where: {
|
||||
...userFilter,
|
||||
status: 'PENDING',
|
||||
date: { lt: today }
|
||||
}
|
||||
}),
|
||||
|
||||
// Today's Followups
|
||||
this.prisma.followup.count({
|
||||
where: {
|
||||
...userFilter,
|
||||
status: 'PENDING',
|
||||
date: { gte: today, lt: new Date(today.getTime() + 86400000) }
|
||||
}
|
||||
}),
|
||||
|
||||
// New Leads/Opportunities (Last 24h)
|
||||
this.prisma.opportunity.count({
|
||||
where: {
|
||||
...leadSharingFilter,
|
||||
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Fetch Latest Performance Score
|
||||
const latestScore = await this.prisma.performanceScore.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
|
||||
// Fetch Current Target
|
||||
const target = await this.prisma.target.findFirst({
|
||||
where: { userId: user.id, month: new Date().getMonth() + 1, year: new Date().getFullYear() }
|
||||
});
|
||||
|
||||
// Calculate Contribution Revenue (50/50 Split)
|
||||
const wonOpportunities = await this.prisma.opportunity.findMany({
|
||||
where: {
|
||||
...leadSharingFilter,
|
||||
stage: 'WON',
|
||||
updatedAt: { gte: monthStart }
|
||||
},
|
||||
select: { value: true, assignedTo: true, creatorId: true }
|
||||
});
|
||||
|
||||
const contributionRevenue = wonOpportunities.reduce((total, o) => {
|
||||
let share = 0;
|
||||
const isCloser = o.assignedTo === user.id;
|
||||
const isCreator = o.creatorId === user.id;
|
||||
if (isCloser && isCreator) share = o.value;
|
||||
else if (isCloser || isCreator) share = o.value * 0.5;
|
||||
return total + share;
|
||||
}, 0);
|
||||
|
||||
// Calculate Conversion Rate
|
||||
const conversionRate = enquiriesCount > 0
|
||||
? Math.round((clientsCount / enquiriesCount) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
enquiriesToday,
|
||||
pipelineValue: opportunityStats._sum.value || 0,
|
||||
pipelineCount: opportunityStats._count || 0,
|
||||
monthlyRevenue: monthlyRevenue._sum.value || 0,
|
||||
contributionRevenue,
|
||||
conversionRate,
|
||||
pendingExpenses: (user.role === user_role.ADMIN || [user_role.GENERAL_MANAGER, user_role.MANAGER, user_role.OFFICER].includes(user.role)) ? pendingExpenses : 0,
|
||||
overdueCount: overdueFollowups,
|
||||
todayCount: todayFollowups,
|
||||
newCount: newItems
|
||||
},
|
||||
performance: latestScore ? {
|
||||
score: latestScore.score,
|
||||
tag: latestScore.tag,
|
||||
breakdown: {
|
||||
revenue: latestScore.revenueScore,
|
||||
conversion: latestScore.conversionScore,
|
||||
activity: latestScore.activityScore,
|
||||
discipline: latestScore.disciplineScore,
|
||||
quality: latestScore.dataQualityScore
|
||||
}
|
||||
} : null,
|
||||
target: target ? {
|
||||
monthly: target.monthlyTarget,
|
||||
minimum: target.minTarget,
|
||||
weekly: target.weeklyTarget,
|
||||
dailyLead: target.dailyLeadTarget,
|
||||
achieved: monthlyRevenue._sum.value || 0
|
||||
} : null,
|
||||
recentActivity: {
|
||||
enquiries: recentEnquiries,
|
||||
opportunities: recentOpportunities
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateEnquiryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
conversation?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
productIds?: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateEnquiryDto } from './create-enquiry.dto';
|
||||
|
||||
export class UpdateEnquiryDto extends PartialType(CreateEnquiryDto) {}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { EnquiriesService } from './enquiries.service';
|
||||
import { CreateEnquiryDto } from './dto/create-enquiry.dto';
|
||||
import { UpdateEnquiryDto } from './dto/update-enquiry.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('enquiries')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class EnquiriesController {
|
||||
constructor(private readonly enquiriesService: EnquiriesService) { }
|
||||
|
||||
@Post()
|
||||
create(@Request() req, @Body() createEnquiryDto: CreateEnquiryDto) {
|
||||
if (!createEnquiryDto.userId) {
|
||||
createEnquiryDto.userId = req.user.id;
|
||||
}
|
||||
return this.enquiriesService.create(createEnquiryDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req) {
|
||||
return this.enquiriesService.findAll(req.user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.enquiriesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateEnquiryDto: UpdateEnquiryDto) {
|
||||
return this.enquiriesService.update(id, updateEnquiryDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.enquiriesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EnquiriesService } from './enquiries.service';
|
||||
import { EnquiriesController } from './enquiries.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
controllers: [EnquiriesController],
|
||||
providers: [EnquiriesService],
|
||||
})
|
||||
export class EnquiriesModule {}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateEnquiryDto } from './dto/create-enquiry.dto';
|
||||
import { UpdateEnquiryDto } from './dto/update-enquiry.dto';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class EnquiriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService
|
||||
) { }
|
||||
|
||||
create(createEnquiryDto: CreateEnquiryDto) {
|
||||
const { productIds, ...data } = createEnquiryDto;
|
||||
return this.prisma.enquiry.create({
|
||||
data: {
|
||||
...data,
|
||||
products: productIds?.length
|
||||
? { connect: productIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
products: true,
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(user: any) {
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.enquiry.findMany({
|
||||
include: {
|
||||
products: true,
|
||||
client: true,
|
||||
user: true,
|
||||
quotes: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.enquiry.findMany({
|
||||
where: {
|
||||
userId: { in: allowedUserIds }
|
||||
},
|
||||
include: {
|
||||
products: true,
|
||||
client: true,
|
||||
user: true,
|
||||
quotes: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.enquiry.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
products: true,
|
||||
client: true,
|
||||
user: true,
|
||||
followups: true,
|
||||
quotes: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateEnquiryDto: UpdateEnquiryDto) {
|
||||
const { productIds, ...data } = updateEnquiryDto;
|
||||
return this.prisma.enquiry.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
products: productIds
|
||||
? { set: productIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.enquiry.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Enquiry {}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateExpenseDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['PENDING', 'APPROVED', 'REJECTED', 'REIMBURSED'])
|
||||
status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'REIMBURSED';
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExpenseDto } from './create-expense.dto';
|
||||
|
||||
export class UpdateExpenseDto extends PartialType(CreateExpenseDto) {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Expense {}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { ExpensesService } from './expenses.service';
|
||||
import { CreateExpenseDto } from './dto/create-expense.dto';
|
||||
import { UpdateExpenseDto } from './dto/update-expense.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('expenses')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class ExpensesController {
|
||||
constructor(private readonly expensesService: ExpensesService) { }
|
||||
|
||||
@Post()
|
||||
create(@Request() req, @Body() createExpenseDto: CreateExpenseDto) {
|
||||
console.log('Creating expense:', createExpenseDto);
|
||||
if (!createExpenseDto.userId) {
|
||||
createExpenseDto.userId = req.user.id;
|
||||
}
|
||||
return this.expensesService.create(createExpenseDto).catch(err => {
|
||||
console.error('Error creating expense:', err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req) {
|
||||
return this.expensesService.findAll(req.user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.expensesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateExpenseDto: UpdateExpenseDto) {
|
||||
return this.expensesService.update(id, updateExpenseDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.expensesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExpensesService } from './expenses.service';
|
||||
import { ExpensesController } from './expenses.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
controllers: [ExpensesController],
|
||||
providers: [ExpensesService],
|
||||
})
|
||||
export class ExpensesModule {}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateExpenseDto } from './dto/create-expense.dto';
|
||||
import { UpdateExpenseDto } from './dto/update-expense.dto';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService
|
||||
) { }
|
||||
|
||||
create(createExpenseDto: CreateExpenseDto) {
|
||||
return this.prisma.expense.create({
|
||||
data: createExpenseDto,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(user: any) {
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.expense.findMany({
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.expense.findMany({
|
||||
where: {
|
||||
userId: { in: allowedUserIds }
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.expense.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateExpenseDto: UpdateExpenseDto) {
|
||||
return this.prisma.expense.update({
|
||||
where: { id },
|
||||
data: updateExpenseDto as any,
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.expense.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { IsString, IsNotEmpty, IsOptional, IsDateString, IsEnum } from 'class-validator';
|
||||
|
||||
export class CreateFollowupDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
enquiryId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string; // Typically extracted from auth context, but simplified here
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
// Alias description for frontend compatibility if needed, but schema uses notes.
|
||||
// Frontend sends 'description', let's map it or support both.
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
|
||||
import { FollowupsService } from './followups.service';
|
||||
import { CreateFollowupDto } from './dto/create-followup.dto';
|
||||
|
||||
@Controller('followups')
|
||||
export class FollowupsController {
|
||||
constructor(private readonly followupsService: FollowupsService) { }
|
||||
|
||||
@Post()
|
||||
create(@Body() createFollowupDto: CreateFollowupDto) {
|
||||
return this.followupsService.create(createFollowupDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('userId') userId?: string,
|
||||
@Query('clientId') clientId?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.followupsService.findAll({ userId, clientId, dateFrom, dateTo, status });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.followupsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateFollowupDto: any) {
|
||||
return this.followupsService.update(id, updateFollowupDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.followupsService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FollowupsService } from './followups.service';
|
||||
import { FollowupsController } from './followups.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, WhatsappModule],
|
||||
controllers: [FollowupsController],
|
||||
providers: [FollowupsService],
|
||||
})
|
||||
export class FollowupsModule { }
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateFollowupDto } from './dto/create-followup.dto';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
|
||||
@Injectable()
|
||||
export class FollowupsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private notifications: NotificationsService,
|
||||
private whatsappService: WhatsappService
|
||||
) { }
|
||||
|
||||
async create(createFollowupDto: CreateFollowupDto) {
|
||||
// Map description to notes if provided
|
||||
const { description, ...rest } = createFollowupDto;
|
||||
const data = {
|
||||
...rest,
|
||||
notes: rest.notes || description, // Use description if notes is empty
|
||||
};
|
||||
|
||||
const followup = await this.prisma.followup.create({
|
||||
data,
|
||||
include: { client: true }
|
||||
});
|
||||
|
||||
// Trigger WhatsApp followup reminder to client
|
||||
if (followup.client?.phone) {
|
||||
try {
|
||||
await this.whatsappService.sendFollowupReminder(
|
||||
followup.client.phone,
|
||||
followup.client.name,
|
||||
new Date(followup.date).toLocaleString()
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to send followup WhatsApp:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Send Alert to Assigned User if different from creator
|
||||
if (data.userId) {
|
||||
await this.notifications.create(
|
||||
data.userId,
|
||||
'New Follow-up Assigned 📅',
|
||||
`You have been assigned a new follow-up task for client. Deadline: ${new Date(data.date).toLocaleString()}`,
|
||||
'FOLLOWUP_ASSIGNED'
|
||||
);
|
||||
}
|
||||
|
||||
return followup;
|
||||
}
|
||||
|
||||
findAll(filters?: { userId?: string; clientId?: string; dateFrom?: string; dateTo?: string; status?: string }) {
|
||||
const where: any = {};
|
||||
if (filters?.userId) where.userId = filters.userId;
|
||||
if (filters?.clientId) where.clientId = filters.clientId;
|
||||
if (filters?.status) where.status = filters.status;
|
||||
if (filters?.dateFrom || filters?.dateTo) {
|
||||
where.date = {};
|
||||
if (filters.dateFrom) where.date.gte = new Date(filters.dateFrom);
|
||||
if (filters.dateTo) where.date.lte = new Date(filters.dateTo);
|
||||
}
|
||||
return this.prisma.followup.findMany({
|
||||
where,
|
||||
include: { client: true, user: true, enquiry: true },
|
||||
orderBy: { date: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.followup.findUnique({
|
||||
where: { id },
|
||||
include: { client: true, user: true, enquiry: true }
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, updateFollowupDto: any) {
|
||||
// Fetch current state before update to detect reassignment
|
||||
const existing = await this.prisma.followup.findUnique({ where: { id } });
|
||||
|
||||
const followup = await this.prisma.followup.update({
|
||||
where: { id },
|
||||
data: updateFollowupDto,
|
||||
include: { user: true, client: true }
|
||||
});
|
||||
|
||||
// Auto-dismiss notification when marked DONE
|
||||
if (updateFollowupDto.status === 'DONE' && followup.userId) {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE notification
|
||||
SET isRead = true
|
||||
WHERE userId = ${followup.userId}
|
||||
AND type = 'FOLLOWUP_ASSIGNED'
|
||||
AND isRead = false
|
||||
`;
|
||||
}
|
||||
|
||||
// Handle Reassignment — new userId provided and it's different from existing
|
||||
if (updateFollowupDto.userId && existing && updateFollowupDto.userId !== existing.userId) {
|
||||
// Dismiss old assignee's notification
|
||||
if (existing.userId) {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE notification SET isRead = true
|
||||
WHERE userId = ${existing.userId} AND type = 'FOLLOWUP_ASSIGNED' AND isRead = false
|
||||
`;
|
||||
}
|
||||
// Notify new assignee
|
||||
const deadline = new Date(followup.date).toLocaleString();
|
||||
await this.notifications.create(
|
||||
updateFollowupDto.userId,
|
||||
'Follow-up Reassigned to You 📅',
|
||||
`A follow-up task for ${followup.client?.name || 'a client'} has been reassigned to you. Deadline: ${deadline}`,
|
||||
'FOLLOWUP_ASSIGNED'
|
||||
);
|
||||
}
|
||||
|
||||
return followup;
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.followup.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateIncentiveDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
targetAmount: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
rewardAmount?: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(['DAILY', 'MONTHLY'])
|
||||
type: 'DAILY' | 'MONTHLY';
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
startDate: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
endDate: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateIncentiveDto } from './create-incentive.dto';
|
||||
|
||||
export class UpdateIncentiveDto extends PartialType(CreateIncentiveDto) {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Incentive {}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { IncentivesService } from './incentives.service';
|
||||
import { CreateIncentiveDto } from './dto/create-incentive.dto';
|
||||
import { UpdateIncentiveDto } from './dto/update-incentive.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('incentives')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class IncentivesController {
|
||||
constructor(private readonly incentivesService: IncentivesService) { }
|
||||
|
||||
@Post()
|
||||
create(@Request() req, @Body() createIncentiveDto: CreateIncentiveDto) {
|
||||
if (!createIncentiveDto.userId) {
|
||||
createIncentiveDto.userId = req.user.id;
|
||||
}
|
||||
return this.incentivesService.create(createIncentiveDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req) {
|
||||
return this.incentivesService.findAll(req.user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.incentivesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateIncentiveDto: UpdateIncentiveDto) {
|
||||
return this.incentivesService.update(id, updateIncentiveDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.incentivesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { IncentivesService } from './incentives.service';
|
||||
import { IncentivesController } from './incentives.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
controllers: [IncentivesController],
|
||||
providers: [IncentivesService],
|
||||
})
|
||||
export class IncentivesModule {}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateIncentiveDto } from './dto/create-incentive.dto';
|
||||
import { UpdateIncentiveDto } from './dto/update-incentive.dto';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class IncentivesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService
|
||||
) { }
|
||||
|
||||
create(createIncentiveDto: CreateIncentiveDto) {
|
||||
return this.prisma.incentive.create({
|
||||
data: {
|
||||
...createIncentiveDto,
|
||||
achievedAmount: 0,
|
||||
} as any,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(user: any) {
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.incentive.findMany({
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.incentive.findMany({
|
||||
where: {
|
||||
userId: { in: allowedUserIds }
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.incentive.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateIncentiveDto: UpdateIncentiveDto) {
|
||||
return this.prisma.incentive.update({
|
||||
where: { id },
|
||||
data: updateIncentiveDto as any,
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.incentive.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { IsNumber, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CreateLocationDto {
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
lat: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
lng: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Controller, Get, Post, Body, Param, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { LocationsService } from './locations.service';
|
||||
import { CreateLocationDto } from './dto/create-location.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('locations')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class LocationsController {
|
||||
constructor(private readonly locationsService: LocationsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Request() req, @Body() createLocationDto: CreateLocationDto) {
|
||||
return this.locationsService.create(req.user.id, createLocationDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req, @Query('date') date?: string) {
|
||||
return this.locationsService.findAll(req.user, date);
|
||||
}
|
||||
|
||||
@Get('user/:userId')
|
||||
findByUser(@Param('userId') userId: string, @Request() req, @Query('date') date?: string) {
|
||||
return this.locationsService.findByUser(userId, req.user, date);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { LocationsService } from './locations.service';
|
||||
import { LocationsController } from './locations.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule],
|
||||
controllers: [LocationsController],
|
||||
providers: [LocationsService],
|
||||
exports: [LocationsService],
|
||||
})
|
||||
export class LocationsModule {}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateLocationDto } from './dto/create-location.dto';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class LocationsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
private getDateRange(dateStr?: string) {
|
||||
const start = dateStr ? new Date(dateStr) : new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
async create(userId: string, createLocationDto: CreateLocationDto) {
|
||||
return this.prisma.location.create({
|
||||
data: {
|
||||
userId,
|
||||
...createLocationDto,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(user: any, date?: string) {
|
||||
const { start, end } = this.getDateRange(date);
|
||||
|
||||
if (user.role === user_role.ADMIN) {
|
||||
return this.prisma.location.findMany({
|
||||
where: {
|
||||
timestamp: { gte: start, lte: end },
|
||||
},
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.location.findMany({
|
||||
where: {
|
||||
userId: { in: allowedUserIds },
|
||||
timestamp: { gte: start, lte: end },
|
||||
},
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByUser(userId: string, requestingUser: any, date?: string) {
|
||||
// Check if the requesting user is allowed to see this user's location
|
||||
if (requestingUser.role !== user_role.ADMIN && requestingUser.id !== userId) {
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(requestingUser.id);
|
||||
if (!subordinateIds.includes(userId)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
const { start, end } = this.getDateRange(date);
|
||||
|
||||
return this.prisma.location.findMany({
|
||||
where: {
|
||||
userId,
|
||||
timestamp: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors(); // Enable CORS for all origins
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { IsString, IsNotEmpty, IsDateString, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateMeetingDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string; // ISO Date string
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
createdBy: string; // User ID
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateMeetingDto } from './create-meeting.dto';
|
||||
|
||||
export class UpdateMeetingDto extends PartialType(CreateMeetingDto) { }
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Meeting {}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { MeetingsService } from './meetings.service';
|
||||
import { CreateMeetingDto } from './dto/create-meeting.dto';
|
||||
import { UpdateMeetingDto } from './dto/update-meeting.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('meetings')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class MeetingsController {
|
||||
constructor(private readonly meetingsService: MeetingsService) { }
|
||||
|
||||
@Post()
|
||||
create(@Body() createMeetingDto: CreateMeetingDto) {
|
||||
return this.meetingsService.create(createMeetingDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.meetingsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.meetingsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateMeetingDto: UpdateMeetingDto) {
|
||||
return this.meetingsService.update(id, updateMeetingDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.meetingsService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MeetingsService } from './meetings.service';
|
||||
import { MeetingsController } from './meetings.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [MeetingsController],
|
||||
providers: [MeetingsService],
|
||||
})
|
||||
export class MeetingsModule { }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateMeetingDto } from './dto/create-meeting.dto';
|
||||
import { UpdateMeetingDto } from './dto/update-meeting.dto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class MeetingsService {
|
||||
constructor(private prisma: PrismaService) { }
|
||||
|
||||
create(createMeetingDto: CreateMeetingDto) {
|
||||
return this.prisma.meeting.create({
|
||||
data: {
|
||||
...createMeetingDto,
|
||||
date: new Date(createMeetingDto.date)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.meeting.findMany({
|
||||
include: { client: true, user: true }
|
||||
});
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.meeting.findUnique({
|
||||
where: { id },
|
||||
include: { client: true, user: true }
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateMeetingDto: UpdateMeetingDto) {
|
||||
const data: any = { ...updateMeetingDto };
|
||||
if (updateMeetingDto.date) {
|
||||
data.date = new Date(updateMeetingDto.date);
|
||||
}
|
||||
return this.prisma.meeting.update({
|
||||
where: { id },
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.meeting.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Controller, Get, Patch, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@Get('my')
|
||||
findMine(@Request() req: any) {
|
||||
return this.notificationsService.findMyNotifications(req.user.userId);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
unreadCount(@Request() req: any) {
|
||||
return this.notificationsService.getUnreadCount(req.user.userId).then(count => ({ count }));
|
||||
}
|
||||
|
||||
@Patch('mark-all-read')
|
||||
markAllRead(@Request() req: any) {
|
||||
return this.notificationsService.markAllRead(req.user.userId);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
markOneRead(@Param('id') id: string) {
|
||||
return this.notificationsService.markOneRead(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(userId: string, title: string, body: string, type = 'INFO', metadata?: object) {
|
||||
const id = require('crypto').randomUUID();
|
||||
const metaStr = metadata ? JSON.stringify(metadata) : null;
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO notification (id, userId, title, body, type, metadata)
|
||||
VALUES (${id}, ${userId}, ${title}, ${body}, ${type}, ${metaStr})
|
||||
`;
|
||||
return { id, userId, title, body, type, metadata };
|
||||
}
|
||||
|
||||
async findMyNotifications(userId: string) {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT * FROM notification WHERE userId = ${userId} ORDER BY createdAt DESC LIMIT 50
|
||||
`;
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string) {
|
||||
const result: any = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM notification WHERE userId = ${userId} AND isRead = false
|
||||
`;
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
|
||||
async markAllRead(userId: string) {
|
||||
return this.prisma.$executeRaw`
|
||||
UPDATE notification SET isRead = true WHERE userId = ${userId} AND isRead = false
|
||||
`;
|
||||
}
|
||||
|
||||
async markOneRead(id: string) {
|
||||
return this.prisma.$executeRaw`
|
||||
UPDATE notification SET isRead = true WHERE id = ${id}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { opportunity_stage } from '@prisma/client';
|
||||
|
||||
export class CreateOpportunityDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
value: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
assignedTo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(opportunity_stage)
|
||||
stage?: opportunity_stage;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
priority?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
expectedCloseDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
creatorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
demoPersonName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
demoContactDetails?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyQueries?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
objections?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
competitorMention?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
paymentMode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
specialRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freeOffers?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
negotiationRemarks?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateOpportunityDto } from './create-opportunity.dto';
|
||||
|
||||
export class UpdateOpportunityDto extends PartialType(CreateOpportunityDto) {
|
||||
expectedCloseDate?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { OpportunitiesService } from './opportunities.service';
|
||||
import { CreateOpportunityDto } from './dto/create-opportunity.dto';
|
||||
import { UpdateOpportunityDto } from './dto/update-opportunity.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('opportunities')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class OpportunitiesController {
|
||||
constructor(private readonly opportunitiesService: OpportunitiesService) { }
|
||||
|
||||
@Post()
|
||||
create(@Request() req, @Body() createOpportunityDto: CreateOpportunityDto) {
|
||||
if (!createOpportunityDto.assignedTo) {
|
||||
createOpportunityDto.assignedTo = req.user.id;
|
||||
}
|
||||
return this.opportunitiesService.create(createOpportunityDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req) {
|
||||
return this.opportunitiesService.findAll(req.user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.opportunitiesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateOpportunityDto: UpdateOpportunityDto) {
|
||||
return this.opportunitiesService.update(id, updateOpportunityDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.opportunitiesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OpportunitiesService } from './opportunities.service';
|
||||
import { OpportunitiesController } from './opportunities.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, WhatsappModule],
|
||||
controllers: [OpportunitiesController],
|
||||
providers: [OpportunitiesService],
|
||||
})
|
||||
export class OpportunitiesModule { }
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateOpportunityDto } from './dto/create-opportunity.dto';
|
||||
import { UpdateOpportunityDto } from './dto/update-opportunity.dto';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
import { user_role } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class OpportunitiesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly whatsappService: WhatsappService
|
||||
) { }
|
||||
|
||||
async create(createOpportunityDto: CreateOpportunityDto) {
|
||||
try {
|
||||
return await this.prisma.opportunity.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
title: createOpportunityDto.title,
|
||||
value: Number(createOpportunityDto.value),
|
||||
clientId: createOpportunityDto.clientId,
|
||||
assignedTo: createOpportunityDto.assignedTo,
|
||||
stage: createOpportunityDto.stage || 'LEAD',
|
||||
priority: createOpportunityDto.priority,
|
||||
expectedCloseDate: (createOpportunityDto.expectedCloseDate && createOpportunityDto.expectedCloseDate.trim() !== '')
|
||||
? new Date(createOpportunityDto.expectedCloseDate)
|
||||
: null,
|
||||
creatorId: createOpportunityDto.creatorId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('CREATE OPPORTUNITY ERROR:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(user: any) {
|
||||
// ADMIN and GENERAL_MANAGER see everything
|
||||
if (user.role === 'ADMIN' || user.role === 'GENERAL_MANAGER') {
|
||||
return this.prisma.opportunity.findMany({
|
||||
include: {
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
const subordinateIds = await this.usersService.getSubordinateIds(user.id);
|
||||
const allowedUserIds = [user.id, ...subordinateIds];
|
||||
|
||||
return this.prisma.opportunity.findMany({
|
||||
where: {
|
||||
assignedTo: { in: allowedUserIds }
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const opp = await this.prisma.opportunity.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (!opp) throw new NotFoundException('Opportunity not found');
|
||||
return opp;
|
||||
}
|
||||
|
||||
async update(id: string, updateOpportunityDto: UpdateOpportunityDto) {
|
||||
try {
|
||||
// Fetch current state to check if we are changing stage
|
||||
const current = await this.prisma.opportunity.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('Opportunity not found');
|
||||
|
||||
const newStage = updateOpportunityDto.stage || current.stage;
|
||||
|
||||
// Validation Logic for DEMO stage
|
||||
if (newStage === 'DEMO') {
|
||||
const missing: string[] = [];
|
||||
const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === '');
|
||||
|
||||
if (check(updateOpportunityDto.demoPersonName ?? current.demoPersonName)) missing.push('Person Name');
|
||||
if (check(updateOpportunityDto.demoContactDetails ?? current.demoContactDetails)) missing.push('Contact Details');
|
||||
if (check(updateOpportunityDto.expectedCloseDate ?? current.expectedCloseDate)) missing.push('Expected Close Date');
|
||||
if (check(updateOpportunityDto.value ?? current.value)) missing.push('Value');
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`Missing for DEMO stage: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (newStage === 'WON') {
|
||||
const missing: string[] = [];
|
||||
const check = (val: any) => val === null || val === undefined || (typeof val === 'string' && val.trim() === '');
|
||||
|
||||
// Only Payment Mode is strictly mandatory for WON
|
||||
if (check(updateOpportunityDto.paymentMode ?? current.paymentMode)) missing.push('Payment Mode');
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`Missing for WON stage: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = { ...updateOpportunityDto };
|
||||
|
||||
// Handle expectedCloseDate conversion
|
||||
if (updateOpportunityDto.expectedCloseDate && updateOpportunityDto.expectedCloseDate.trim() !== '') {
|
||||
data.expectedCloseDate = new Date(updateOpportunityDto.expectedCloseDate);
|
||||
} else if (updateOpportunityDto.expectedCloseDate === '') {
|
||||
data.expectedCloseDate = null;
|
||||
}
|
||||
|
||||
if (data.value) data.value = Number(data.value);
|
||||
if (data.specialRate) data.specialRate = Number(data.specialRate);
|
||||
|
||||
// Fix foreign key constraint errors for empty strings
|
||||
if (data.creatorId === '') data.creatorId = null;
|
||||
if (data.demoOwnerId === '') data.demoOwnerId = null;
|
||||
if (data.closingOwnerId === '') data.closingOwnerId = null;
|
||||
|
||||
return await this.prisma.opportunity.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { client: true }
|
||||
}).then(async (updated) => {
|
||||
if (current.stage !== updated.stage) {
|
||||
try {
|
||||
await this.whatsappService.sendPipelineUpdate(
|
||||
updated.client.phone,
|
||||
updated.client.name,
|
||||
current.stage,
|
||||
updated.stage
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to send pipeline WhatsApp:', err.message);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('UPDATE OPPORTUNITY ERROR:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
return this.prisma.opportunity.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class CreatePerformanceDto {}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePerformanceDto } from './create-performance.dto';
|
||||
|
||||
export class UpdatePerformanceDto extends PartialType(CreatePerformanceDto) {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Performance {}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { PerformanceService } from './performance.service';
|
||||
import { CreatePerformanceDto } from './dto/create-performance.dto';
|
||||
import { UpdatePerformanceDto } from './dto/update-performance.dto';
|
||||
|
||||
@Controller('performance')
|
||||
export class PerformanceController {
|
||||
constructor(private readonly performanceService: PerformanceService) {}
|
||||
|
||||
@Post('calculate/:userId')
|
||||
calculateScore(@Param('userId') userId: string) {
|
||||
return this.performanceService.calculateUserScore(userId);
|
||||
}
|
||||
|
||||
@Get('latest/:userId')
|
||||
getLatestScore(@Param('userId') userId: string) {
|
||||
return this.performanceService.getLatestScore(userId);
|
||||
}
|
||||
|
||||
@Get('trend/:userId')
|
||||
getTrend(@Param('userId') userId: string) {
|
||||
return this.performanceService.getPerformanceTrend(userId);
|
||||
}
|
||||
|
||||
@Get('funnel/:userId')
|
||||
getFunnel(@Param('userId') userId: string) {
|
||||
return this.performanceService.getFunnelData(userId);
|
||||
}
|
||||
|
||||
@Get('quarterly/:userId')
|
||||
getQuarterly(@Param('userId') userId: string) {
|
||||
return this.performanceService.getQuarterlyStatus(userId);
|
||||
}
|
||||
|
||||
@Get('team')
|
||||
getTeam() {
|
||||
return this.performanceService.getTeamPerformance();
|
||||
}
|
||||
|
||||
@Get('funnel-config')
|
||||
getFunnelConfig() {
|
||||
return this.performanceService.getFunnelConfig();
|
||||
}
|
||||
|
||||
@Patch('funnel-config')
|
||||
setFunnelConfig(@Body() body: any) {
|
||||
return this.performanceService.setFunnelConfig(body);
|
||||
}
|
||||
|
||||
@Get('team-funnel')
|
||||
getTeamFunnel() {
|
||||
return this.performanceService.getTeamFunnel();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PerformanceService } from './performance.service';
|
||||
import { PerformanceController } from './performance.controller';
|
||||
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [PerformanceController],
|
||||
providers: [PerformanceService],
|
||||
})
|
||||
export class PerformanceModule {}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { startOfMonth, endOfMonth } from 'date-fns';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceService implements OnModuleInit {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private notifications: NotificationsService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
// Ensure systemconfig table exists (for MySQL)
|
||||
await this.prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS systemconfig (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
\`key\` VARCHAR(100) UNIQUE NOT NULL,
|
||||
value TEXT,
|
||||
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Seed default ratios if not present
|
||||
const existing: any = await this.prisma.$queryRaw`SELECT id FROM systemconfig WHERE \`key\` = 'funnel_ratios' LIMIT 1`;
|
||||
if (!existing || existing.length === 0) {
|
||||
const defaultRatios = JSON.stringify({ callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 });
|
||||
const id = require('crypto').randomUUID();
|
||||
await this.prisma.$executeRawUnsafe(`
|
||||
INSERT INTO systemconfig (id, \`key\`, value, updatedAt)
|
||||
VALUES ('${id}', 'funnel_ratios', '${defaultRatios}', NOW())
|
||||
`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error initializing PerformanceService (systemconfig):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateUserScore(userId: string) {
|
||||
const start = startOfMonth(new Date());
|
||||
const end = endOfMonth(new Date());
|
||||
|
||||
// 1. Fetch Data
|
||||
const [target, opportunities, enquiries, followups, strategicActivities] = await Promise.all([
|
||||
this.prisma.target.findFirst({
|
||||
where: { userId, month: new Date().getMonth() + 1, year: new Date().getFullYear() },
|
||||
}),
|
||||
this.prisma.opportunity.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ assignedTo: userId },
|
||||
{ creatorId: userId },
|
||||
{ demoOwnerId: userId },
|
||||
{ closingOwnerId: userId }
|
||||
],
|
||||
updatedAt: { gte: start, lte: end }
|
||||
}
|
||||
}),
|
||||
this.prisma.enquiry.findMany({ where: { userId, createdAt: { gte: start, lte: end } } }),
|
||||
this.prisma.followup.findMany({ where: { userId, date: { gte: start, lte: end } } }),
|
||||
this.prisma.strategicActivity.findMany({ where: { userId, date: { gte: start, lte: end } } }),
|
||||
]);
|
||||
|
||||
if (!target) return { message: 'No target set for this month' };
|
||||
|
||||
// 2. Revenue Achievement (Weight: 40) - LEAD SHARING LOGIC (50% Creator / 50% Closer)
|
||||
const totalRevenue = opportunities.reduce((sum, o) => {
|
||||
if (o.stage !== 'WON') return sum;
|
||||
|
||||
let contribution = 0;
|
||||
// 50% goes to the Lead Creator
|
||||
if (o.creatorId === userId) contribution += o.value * 0.5;
|
||||
|
||||
// 50% goes to the Closer (closingOwnerId or assignedTo if not specified)
|
||||
if (o.closingOwnerId === userId || (!o.closingOwnerId && o.assignedTo === userId)) {
|
||||
contribution += o.value * 0.5;
|
||||
}
|
||||
|
||||
return sum + contribution;
|
||||
}, 0);
|
||||
|
||||
const revenueScore = Math.min(40, (totalRevenue / target.monthlyTarget) * 40);
|
||||
|
||||
// 3. Conversion Efficiency (Weight: 20)
|
||||
const wonCount = opportunities.filter(o => o.stage === 'WON' && (o.closingOwnerId === userId || o.assignedTo === userId)).length;
|
||||
const conversionEfficiency = enquiries.length > 0 ? (wonCount / enquiries.length) : 0;
|
||||
const conversionScore = Math.min(20, conversionEfficiency * 100 * 0.2); // Scaling 100% to 20 points
|
||||
|
||||
// 4. Activity Discipline (Weight: 15) - Strategic Activities
|
||||
const activityCount = strategicActivities.length;
|
||||
const activityBenchmark = 20; // Assume 20 activities per month benchmark
|
||||
const activityScore = Math.min(15, (activityCount / activityBenchmark) * 15);
|
||||
|
||||
// 5. Follow-up Completion (Weight: 15)
|
||||
const completedFollowups = followups.filter(f => f.status === 'DONE').length;
|
||||
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 qualityOpps = opportunitiesWithDemo.filter(o => o.demoPersonName && o.demoContactDetails && o.keyQueries);
|
||||
const dataQualityScore = opportunitiesWithDemo.length > 0 ? (qualityOpps.length / opportunitiesWithDemo.length) * 10 : 10;
|
||||
|
||||
const totalScore = revenueScore + conversionScore + activityScore + followupScore + dataQualityScore;
|
||||
|
||||
// 7. Live Performance Tag
|
||||
let tag = 'ON_TRACK';
|
||||
if (totalScore < 50) tag = 'UNDERPERFORM';
|
||||
else if (totalScore < 80) tag = 'RISK';
|
||||
|
||||
// 8. Save Score
|
||||
const result = await this.prisma.performanceScore.create({
|
||||
data: {
|
||||
userId,
|
||||
score: totalScore,
|
||||
revenueScore,
|
||||
conversionScore,
|
||||
activityScore,
|
||||
disciplineScore: followupScore,
|
||||
dataQualityScore,
|
||||
tag,
|
||||
},
|
||||
});
|
||||
|
||||
// 9. Send Notification
|
||||
await this.notifications.create(
|
||||
userId,
|
||||
'Performance Update',
|
||||
`Your performance score for this month is ${Math.round(totalScore)}/100 (${tag}).`,
|
||||
'PERFORMANCE'
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getLatestScore(userId: string) {
|
||||
return this.prisma.performanceScore.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getPerformanceTrend(userId: string) {
|
||||
return this.prisma.performanceScore.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'asc' },
|
||||
take: 12, // Last 12 records
|
||||
});
|
||||
}
|
||||
|
||||
async getFunnelData(userId: string) {
|
||||
const start = startOfMonth(new Date());
|
||||
const end = endOfMonth(new Date());
|
||||
|
||||
const [activities, opportunities] = await Promise.all([
|
||||
this.prisma.strategicActivity.findMany({ where: { userId, date: { gte: start, lte: end } } }),
|
||||
this.prisma.opportunity.findMany({ where: { assignedTo: userId, updatedAt: { gte: start, lte: end } } }),
|
||||
]);
|
||||
|
||||
// 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 actualWon = opportunities.filter(o => o.stage === 'WON').length;
|
||||
|
||||
// Default ratios (configurable)
|
||||
const DEFAULT_RATIOS = { callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 };
|
||||
// Try loading custom config from metadata table or fallback
|
||||
let ratios = DEFAULT_RATIOS;
|
||||
try {
|
||||
const cfg: any = await this.prisma.$queryRaw`SELECT value FROM systemconfig WHERE key = 'funnel_ratios' LIMIT 1`;
|
||||
if (cfg && cfg[0]) ratios = JSON.parse(cfg[0].value);
|
||||
} catch {}
|
||||
|
||||
// Ideal Funnel (derived from actual calls and configured ratios)
|
||||
const idealQuality = actualCalls / ratios.callsToQuality;
|
||||
const idealPotential = idealQuality / ratios.qualityToPotential;
|
||||
const idealDemo = idealPotential / ratios.potentialToDemo;
|
||||
const idealWon = idealDemo / ratios.demoToWon;
|
||||
|
||||
return {
|
||||
actual: { calls: actualCalls, quality: actualQuality, potential: actualPotential, demo: actualDemo, won: actualWon },
|
||||
ideal: { calls: actualCalls, quality: Math.round(idealQuality), potential: Math.round(idealPotential), demo: Math.round(idealDemo), won: Math.round(idealWon) },
|
||||
deviation: { quality: actualQuality - idealQuality, potential: actualPotential - idealPotential, demo: actualDemo - idealDemo, won: actualWon - idealWon },
|
||||
ratios
|
||||
};
|
||||
}
|
||||
|
||||
async getFunnelConfig() {
|
||||
const DEFAULT = { callsToQuality: 5, qualityToPotential: 3.33, potentialToDemo: 1.5, demoToWon: 2 };
|
||||
try {
|
||||
const cfg: any = await this.prisma.$queryRaw`SELECT value FROM systemconfig WHERE key = 'funnel_ratios' LIMIT 1`;
|
||||
return cfg && cfg[0] ? JSON.parse(cfg[0].value) : DEFAULT;
|
||||
} catch { return DEFAULT; }
|
||||
}
|
||||
|
||||
async setFunnelConfig(ratios: { callsToQuality: number; qualityToPotential: number; potentialToDemo: number; demoToWon: number }) {
|
||||
const value = JSON.stringify(ratios);
|
||||
try {
|
||||
const exists: any = await this.prisma.$queryRaw`SELECT id FROM systemconfig WHERE key = 'funnel_ratios' LIMIT 1`;
|
||||
if (exists && exists[0]) {
|
||||
await this.prisma.$executeRaw`UPDATE systemconfig SET value = ${value}, updatedAt = NOW() WHERE key = 'funnel_ratios'`;
|
||||
} else {
|
||||
const id = require('crypto').randomUUID();
|
||||
await this.prisma.$executeRaw`INSERT INTO systemconfig (id, key, value, updatedAt) VALUES (${id}, 'funnel_ratios', ${value}, NOW())`;
|
||||
}
|
||||
} catch {}
|
||||
return ratios;
|
||||
}
|
||||
|
||||
async getTeamFunnel() {
|
||||
const start = startOfMonth(new Date());
|
||||
const end = endOfMonth(new Date());
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { status: 'APPROVED' },
|
||||
select: { id: true, name: true, role: true }
|
||||
});
|
||||
const results = await Promise.all(users.map(async u => {
|
||||
const funnel = await this.getFunnelData(u.id);
|
||||
return { ...u, funnel };
|
||||
}));
|
||||
return results;
|
||||
}
|
||||
|
||||
async getQuarterlyStatus(userId: string) {
|
||||
const scores = await this.prisma.performanceScore.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 3,
|
||||
});
|
||||
|
||||
if (scores.length < 1) return { status: 'NORMAL', suggestions: [] };
|
||||
|
||||
const lastTwoMonthsBelow = scores.length >= 2 && scores.slice(0, 2).every(s => s.score < 50);
|
||||
const lastThreeMonthsBelow = scores.length === 3 && scores.every(s => s.score < 50);
|
||||
|
||||
let status = 'NORMAL';
|
||||
if (lastThreeMonthsBelow) status = 'ACTION';
|
||||
else if (lastTwoMonthsBelow) status = 'WARNING';
|
||||
|
||||
// Automated Improvement Suggestions
|
||||
const latest = scores[0];
|
||||
const suggestions: string[] = [];
|
||||
if (latest.score < 80) {
|
||||
if (latest.revenueScore < 20) suggestions.push('Focus on high-value closures to meet revenue targets.');
|
||||
if (latest.conversionScore < 10) suggestions.push('Improve conversion rate by qualifying leads better before the Demo stage.');
|
||||
if (latest.activityScore < 7) suggestions.push('Increase daily call and meeting activity to fill your pipeline.');
|
||||
if (latest.disciplineScore < 7) suggestions.push('Ensure all follow-ups are marked as complete on time.');
|
||||
if (latest.dataQualityScore < 5) suggestions.push('Fill in all mandatory fields during the Demo stage to improve data quality.');
|
||||
} else {
|
||||
suggestions.push('Keep up the great work! You are exceeding your targets.');
|
||||
}
|
||||
|
||||
if (status !== 'NORMAL') {
|
||||
await this.notifications.create(
|
||||
userId,
|
||||
`PERFORMANCE ${status}`,
|
||||
`Your performance has been below minimum for ${status === 'ACTION' ? '3' : '2'} months. Please check your improvement plan.`,
|
||||
'PERFORMANCE_ALERT'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
suggestions,
|
||||
recentScores: scores.map(s => ({ date: s.date, score: s.score })),
|
||||
};
|
||||
}
|
||||
|
||||
async getTeamPerformance() {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { status: 'APPROVED' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
performanceScores: {
|
||||
orderBy: { date: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return users.map(u => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
score: u.performanceScores[0]?.score || 0,
|
||||
tag: u.performanceScores[0]?.tag || 'N/A',
|
||||
lastUpdated: u.performanceScores[0]?.date || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule { }
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
price: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateProductDto } from './create-product.dto';
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Product {}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@Controller('products')
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) { }
|
||||
|
||||
@Post()
|
||||
create(@Body() createProductDto: CreateProductDto) {
|
||||
return this.productsService.create(createProductDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.productsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.productsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) {
|
||||
return this.productsService.update(id, updateProductDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.productsService.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
import { ProductsController } from './products.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
create(createProductDto: CreateProductDto) {
|
||||
return this.prisma.product.create({
|
||||
data: createProductDto,
|
||||
});
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.product.findMany();
|
||||
}
|
||||
|
||||
findOne(id: string) {
|
||||
return this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, updateProductDto: UpdateProductDto) {
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: updateProductDto,
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.product.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { IsArray, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateQuoteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
enquiryId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNotEmpty()
|
||||
items: any[];
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
totalAmount: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pdfUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['DRAFT', 'SENT', 'ACCEPTED', 'REJECTED'])
|
||||
status?: 'DRAFT' | 'SENT' | 'ACCEPTED' | 'REJECTED';
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateQuoteDto } from './create-quote.dto';
|
||||
|
||||
export class UpdateQuoteDto extends PartialType(CreateQuoteDto) {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class Quote {}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { QuotesService } from './quotes.service';
|
||||
import { CreateQuoteDto } from './dto/create-quote.dto';
|
||||
import { UpdateQuoteDto } from './dto/update-quote.dto';
|
||||
|
||||
@Controller('quotes')
|
||||
export class QuotesController {
|
||||
constructor(private readonly quotesService: QuotesService) { }
|
||||
|
||||
@Post()
|
||||
create(@Body() createQuoteDto: CreateQuoteDto) {
|
||||
return this.quotesService.create(createQuoteDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.quotesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.quotesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateQuoteDto: UpdateQuoteDto) {
|
||||
return this.quotesService.update(id, updateQuoteDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.quotesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/send')
|
||||
send(@Param('id') id: string, @Body('type') type: 'whatsapp' | 'email') {
|
||||
return this.quotesService.send(id, type);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue