dashbard service created and delaer clim context related templates added

This commit is contained in:
laxmanhalaki 2026-01-02 20:18:58 +05:30
parent 0742b101b3
commit f3cc409d9a
56 changed files with 5119 additions and 355 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
import{a as t}from"./index-Dl7ujaUD.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BZmDhLpD.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-YTj2hkRM.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-DMcCouEt.js.map
import{a as t}from"./index-C331nI1Q.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-CdXsBdJs.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-DMcCouEt.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
{"version":3,"file":"conclusionApi-CdXsBdJs.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{g as s}from"./index-Dl7ujaUD.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BZmDhLpD.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-YTj2hkRM.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
//# sourceMappingURL=requestNavigation-BOiRTAb7.js.map

View File

@ -0,0 +1,2 @@
import{g as s}from"./index-C331nI1Q.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
//# sourceMappingURL=requestNavigation-DAAuTKQF.js.map

View File

@ -1 +1 @@
{"version":3,"file":"requestNavigation-BOiRTAb7.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}
{"version":3,"file":"requestNavigation-DAAuTKQF.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,15 +52,15 @@
transition: transform 0.2s ease;
}
</style>
<script type="module" crossorigin src="/assets/index-Dl7ujaUD.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js">
<script type="module" crossorigin src="/assets/index-C331nI1Q.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DIkYAdWy.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BZmDhLpD.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdaLA-IN.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-YTj2hkRM.js">
<link rel="stylesheet" crossorigin href="/assets/index-0iF6k2IE.css">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-AvM4PHvP.js">
<link rel="stylesheet" crossorigin href="/assets/index-CxZ05Q0s.css">
</head>
<body>
<div id="root"></div>

View File

@ -249,7 +249,7 @@ export const getPublicConfigurations = async (req: Request, res: Response): Prom
const { category } = req.query;
// Only allow certain categories for public access
const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING'];
const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING', 'SYSTEM_SETTINGS'];
if (category && !allowedCategories.includes(category as string)) {
res.status(403).json({
success: false,
@ -262,7 +262,7 @@ export const getPublicConfigurations = async (req: Request, res: Response): Prom
if (category) {
whereClause = `WHERE config_category = '${category}' AND is_sensitive = false`;
} else {
whereClause = `WHERE config_category IN ('DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING') AND is_sensitive = false`;
whereClause = `WHERE config_category IN ('DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING', 'SYSTEM_SETTINGS') AND is_sensitive = false`;
}
const rawConfigurations = await sequelize.query(`

View File

@ -0,0 +1,39 @@
import { Request, Response } from 'express';
import { dealerDashboardService } from '../services/dealerDashboard.service';
import logger from '@utils/logger';
export class DealerDashboardController {
/**
* Get dealer dashboard KPIs and category data
* GET /api/v1/dealer-claims/dashboard
*/
async getDashboard(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user?.userId;
const userEmail = (req as any).user?.email;
const dateRange = req.query.dateRange as string | undefined;
const startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined;
const result = await dealerDashboardService.getDashboardKPIs(
userEmail,
userId,
dateRange,
startDate,
endDate
);
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('[DealerDashboard] Error fetching dashboard:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch dealer dashboard data'
});
}
}
}

View File

@ -0,0 +1,297 @@
# Additional Approver Handling in Dealer Claim Email Templates
## Overview
This document explains how the dealer claim email notification system handles additional approvers that are added dynamically between fixed workflow steps.
---
## How Additional Approvers Work
### 1. **Additional Approver Detection**
Additional approvers are identified by their `levelName` containing "Additional Approver". The system uses this to:
- Exclude them from dealer-specific templates
- Use appropriate templates for their notifications
- Track them in the approval chain
### 2. **Step Number Shifting**
When additional approvers are added:
- **Before Step 1**: Dealer Proposal Submission remains Step 1
- **Between Step 1 and Step 2**: Additional approver becomes Step 2, Requestor Evaluation shifts to Step 3
- **Between Step 2 and Step 3**: Additional approver inserted, subsequent steps shift
- And so on...
The system handles this by:
- Using `levelName` to identify steps (not just `levelNumber`)
- Finding the next PENDING level dynamically (not just sequential)
- Detecting additional approvers by checking `levelName`
---
## Email Notification Scenarios
### Scenario 1: Dealer Submits Proposal (Step 1)
#### **Initiator Notification**
- **When**: Dealer proposal is approved (Step 1 → Step 2)
- **Template**: `dealerProposalSubmitted.template.ts`
- **Notification Type**: `proposal_submitted`
- **Handles Additional Approvers**: ✅ Yes
- If next approver is additional: Shows "Additional Approver" as next approver name
- If next approver is Step 2: Shows "Requestor Evaluation" approver name
- Uses `nextLevel` which is found dynamically (handles step shifts)
#### **Next Approver Notification**
- **When**: Next approver is assigned (could be Step 2 or Additional Approver)
- **Template**:
- If Additional Approver: `approvalRequest.template.ts` or `multiApproverRequest.template.ts`
- If Step 2 (Requestor): `approvalRequest.template.ts` or `multiApproverRequest.template.ts`
- **Notification Type**: `assignment`
- **Handles Additional Approvers**: ✅ Yes
- Additional approvers get standard approval request template
- Not dealer-specific (correct behavior)
---
### Scenario 2: Additional Approver Added Between Step 1 and Step 2
**Workflow Structure:**
```
Step 1: Dealer Proposal Submission (Dealer)
Step 2: Additional Approver (New Approver) ← Added dynamically
Step 3: Requestor Evaluation (Initiator) ← Shifted from Step 2
Step 4: Department Lead Approval
...
```
**Email Flow:**
1. **Dealer submits proposal** → Step 1 approved
2. **Initiator gets email**:
- Template: `dealerProposalSubmitted.template.ts`
- Shows: "Next approver: Additional Approver" (if metadata includes `nextApproverIsAdditional`)
3. **Additional Approver gets email**:
- Template: `approvalRequest.template.ts` (standard approval request)
- Type: `assignment`
- Shows approval chain if multiple approvers exist
4. **Additional Approver approves** → Step 2 approved
5. **Initiator gets email**:
- Template: `approvalConfirmation.template.ts` (standard approval confirmation)
- Type: `approval`
6. **Requestor (Step 3) gets email**:
- Template: `approvalRequest.template.ts` (standard approval request)
- Type: `assignment`
---
### Scenario 3: Dealer Submits Completion Documents (Step 4)
#### **Initiator Notification**
- **When**: Dealer completion documents are approved (Step 4 → Step 5)
- **Template**: `completionDocumentsSubmitted.template.ts`
- **Notification Type**: `completion_submitted`
- **Handles Additional Approvers**: ✅ Yes
- If next approver is additional: Shows "Additional Approver" as next approver name
- If next approver is Step 5: Shows "Requestor Claim Approval" approver name
---
## Key Implementation Details
### 1. **Dynamic Next Level Finding**
```typescript
// In dealerClaimApproval.service.ts
// First try sequential approach
let nextLevel = await ApprovalLevel.findOne({
where: {
requestId: level.requestId,
levelNumber: currentLevelNumber + 1
}
});
// If sequential level doesn't exist, search for next PENDING level
// This handles cases where additional approvers are added dynamically
if (!nextLevel) {
nextLevel = await ApprovalLevel.findOne({
where: {
requestId: level.requestId,
levelNumber: { [Op.gt]: currentLevelNumber },
status: ApprovalStatus.PENDING
},
order: [['levelNumber', 'ASC']]
});
}
```
### 2. **Additional Approver Detection**
```typescript
// Check if next approver is an additional approver
const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : '';
const isNextAdditionalApprover = nextLevelName.includes('additional approver');
```
### 3. **Template Selection Logic**
#### **Assignment Notifications** (`notification.service.ts`)
```typescript
case 'assignment':
// Check if this is a dealer proposal step
const levelName = currentLevel ? (currentLevel.levelName || '').toLowerCase() : '';
const isAdditionalApprover = levelName.includes('additional approver');
const isDealerProposalStep = currentLevel && !isAdditionalApprover && (
(currentLevel.levelName && (
currentLevel.levelName.toLowerCase().includes('dealer') &&
currentLevel.levelName.toLowerCase().includes('proposal')
)) ||
(currentLevel.levelNumber === 1 && requestData.workflowType === 'CLAIM_MANAGEMENT')
);
if (isDealerProposalStep) {
// Use dealer-specific template
await emailNotificationService.sendDealerProposalRequired(...);
} else {
// Use standard approval request template (works for additional approvers)
await emailNotificationService.sendApprovalRequest(...);
}
```
### 4. **Proposal Submitted Notification**
```typescript
// In dealerClaimApproval.service.ts
// When dealer proposal is approved
if (isDealerProposalApproval && (wf as any).initiatorId) {
// Get next approver (could be Step 2 or Additional Approver)
const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
// Check if next approver is additional
const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : '';
const isNextAdditionalApprover = nextLevelName.includes('additional approver');
// Send proposal_submitted notification (not approval notification)
await notificationService.sendToUsers([(wf as any).initiatorId], {
type: 'proposal_submitted', // NOT 'approval'
metadata: {
proposalData: {
nextApproverIsAdditional: isNextAdditionalApprover
},
nextApproverId: nextApproverData ? nextApproverData.userId : undefined
}
});
}
```
---
## Email Template Behavior
### ✅ **Templates That Handle Additional Approvers**
1. **Dealer Proposal Submitted** (`dealerProposalSubmitted.template.ts`)
- Shows next approver name (or "Additional Approver" if applicable)
- Works correctly when next approver is additional
2. **Completion Documents Submitted** (`completionDocumentsSubmitted.template.ts`)
- Shows next approver name (or "Additional Approver" if applicable)
- Works correctly when next approver is additional
3. **Approval Request** (`approvalRequest.template.ts` / `multiApproverRequest.template.ts`)
- Used for additional approvers
- Shows approval chain if multiple approvers exist
- Works correctly for all approvers (fixed and additional)
4. **Approval Confirmation** (`approvalConfirmation.template.ts`)
- Used when additional approvers approve
- Shows next approver in chain
- Works correctly
### ❌ **Templates That Should NOT Be Used for Additional Approvers**
1. **Dealer Proposal Required** (`dealerProposalRequired.template.ts`)
- Only for dealer (Step 1)
- Additional approvers excluded via `!isAdditionalApprover` check
---
## Testing Scenarios
### Test Case 1: No Additional Approvers
- **Step 1** (Dealer) → Gets `dealerProposalRequired` template ✅
- **Step 1 approved** → Initiator gets `dealerProposalSubmitted` template ✅
- **Step 2** (Requestor) → Gets `approvalRequest` template ✅
### Test Case 2: Additional Approver Between Step 1 and Step 2
- **Step 1** (Dealer) → Gets `dealerProposalRequired` template ✅
- **Step 1 approved** → Initiator gets `dealerProposalSubmitted` template with "Additional Approver" as next ✅
- **Step 2** (Additional Approver) → Gets `approvalRequest` template ✅
- **Step 2 approved** → Initiator gets `approvalConfirmation` template ✅
- **Step 3** (Requestor) → Gets `approvalRequest` template ✅
### Test Case 3: Multiple Additional Approvers
- **Step 1** (Dealer) → Gets `dealerProposalRequired` template ✅
- **Step 1 approved** → Initiator gets `dealerProposalSubmitted` template ✅
- **Step 2** (Additional Approver 1) → Gets `multiApproverRequest` template (if multiple approvers) ✅
- **Step 2 approved** → Next approver notified ✅
- **Step 3** (Additional Approver 2) → Gets `multiApproverRequest` template ✅
- And so on...
---
## Summary
### ✅ **What Works Correctly**
1. **Dealer Assignment**: Uses dealer-specific template (not multi-level approval)
2. **Proposal Submitted**: Initiator gets `proposal_submitted` template (not multi-level approval)
3. **Additional Approvers**: Get standard approval request templates
4. **Next Approver Detection**: Dynamically finds next approver (handles step shifts)
5. **Template Selection**: Correctly identifies dealer steps vs additional approvers
### 🔧 **Key Logic**
- **Dealer Proposal Step Detection**: Checks `levelName` contains "dealer" and "proposal" OR `levelNumber === 1` AND `workflowType === 'CLAIM_MANAGEMENT'`
- **Additional Approver Detection**: Checks `levelName` contains "additional approver"
- **Next Level Finding**: Uses dynamic search for next PENDING level (not just sequential)
- **Template Selection**: Excludes additional approvers from dealer-specific templates
### 📧 **Email Flow**
```
Dealer Submits Proposal
Step 1 Approved (System)
Initiator: proposal_submitted email ✅ (NOT multi-level approval)
Next Approver: assignment email ✅ (Standard approval request)
If Next is Additional Approver:
- Gets standard approval request template ✅
- Shows in approval chain ✅
- When approved, next approver gets assignment ✅
```
---
## Files Modified
1. **`dealerClaimApproval.service.ts`**
- Added detection for additional approvers
- Changed notification type from `approval` to `proposal_submitted` for dealer proposal
- Added `nextApproverIsAdditional` metadata
2. **`notification.service.ts`**
- Added check to exclude additional approvers from dealer-specific templates
- Improved dealer proposal step detection
3. **`emailNotification.service.ts`**
- Updated to handle `nextApproverIsAdditional` flag
- Shows "Additional Approver" when next approver is additional
---
## Conclusion
The system now correctly handles additional approvers:
- ✅ Initiator gets `proposal_submitted` template (not multi-level approval)
- ✅ Additional approvers get standard approval request templates
- ✅ Next approver is correctly identified even when steps shift
- ✅ All templates work seamlessly with dynamic approval chains

View File

@ -0,0 +1,393 @@
# Dealer Claim Email Templates - Planning Document
## Overview
This document outlines all email templates required for the Dealer Claim Management workflow, including support for additional approvers.
---
## Workflow Steps & Email Templates
### 1. **Request Created** ✅ (Already Exists)
- **When**: Claim request is created by initiator
- **Recipients**: Initiator
- **Template**: `requestCreated.template.ts`
- **Status**: ✅ Implemented
- **Notes**: Generic template works for dealer claims
---
### 2. **Dealer Assignment - Proposal Required** ✅ (Uses Existing)
- **When**: Step 1 - Dealer is assigned to submit proposal
- **Recipients**: Dealer
- **Template**: `approvalRequest.template.ts` (single approver)
- **Status**: ✅ Uses existing template
- **Notification Type**: `assignment`
- **Notes**:
- Sent when request is created
- Uses existing approval request template
- May need dealer-specific customization
---
### 3. **Proposal Submitted** 🆕 (NEW - Recommended)
- **When**: Step 1 - Dealer submits proposal
- **Recipients**:
- Initiator (Requestor)
- Next Approver (Step 2 - Requestor Evaluation)
- **Template**: `dealerProposalSubmitted.template.ts` (NEW)
- **Status**: ❌ Not Implemented
- **Notification Type**: `proposal_submitted`
- **Data Needed**:
- Request details (number, title, activity name)
- Dealer information
- Proposal details (total budget, expected completion date)
- Cost breakdown summary
- Dealer comments
- **Notes**:
- Confirms to initiator that proposal was received
- Notifies next approver (initiator) to review
---
### 4. **Proposal Approved** ✅ (Uses Existing)
- **When**: Step 2 - Requestor approves proposal
- **Recipients**:
- Initiator (confirmation)
- Next Approver (Step 3 - Department Lead)
- **Template**: `approvalConfirmation.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval`
- **Notes**: Generic approval confirmation works
---
### 5. **Proposal Rejected** ✅ (Uses Existing)
- **When**: Step 2 - Requestor rejects proposal
- **Recipients**:
- Initiator
- Dealer
- All participants
- **Template**: `rejectionNotification.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `rejection`
- **Notes**: Generic rejection notification works
---
### 6. **Department Lead Approval** ✅ (Uses Existing)
- **When**: Step 3 - Department Lead approves and organizes IO
- **Recipients**:
- Initiator (confirmation)
- Next approver (if any additional approvers before Activity Creation)
- **Template**: `approvalConfirmation.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval`
- **Notes**:
- May want IO-specific details in email
- IO details are shown in workflow tab
---
### 7. **Activity Created** 🆕 (NEW - Recommended)
- **When**: After Step 3 approval - Activity is created
- **Recipients**:
- Dealer
- Initiator (Requestor)
- Department Lead
- **Template**: `activityCreated.template.ts` (NEW)
- **Status**: ❌ Not Implemented (currently uses generic notification)
- **Notification Type**: `activity_created`
- **Data Needed**:
- Activity name and type
- Request number
- Activity date
- Location
- IO number (if available)
- Next steps information
- **Notes**:
- Currently sends generic notification (line 2141-2151 in dealerClaim.service.ts)
- Should be a dedicated template with activity-specific information
---
### 8. **Completion Documents Submitted** 🆕 (NEW - Recommended)
- **When**: Step 4 - Dealer submits completion documents
- **Recipients**:
- Initiator (Requestor)
- Next Approver (Step 5 - Requestor Claim Approval)
- **Template**: `completionDocumentsSubmitted.template.ts` (NEW)
- **Status**: ❌ Not Implemented
- **Notification Type**: `completion_submitted`
- **Data Needed**:
- Request details
- Activity completion date
- Number of participants
- Total closed expenses
- Expense breakdown summary
- Documents submitted count
- **Notes**:
- Confirms to initiator that completion docs were received
- Notifies next approver to review completion
---
### 9. **Requestor Claim Approval** ✅ (Uses Existing)
- **When**: Step 5 - Requestor approves claim
- **Recipients**:
- Initiator (confirmation)
- Next step (DMS push)
- **Template**: `approvalConfirmation.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval`
- **Notes**: Generic approval confirmation works
---
### 10. **E-Invoice Generated** 🆕 (NEW - Recommended)
- **When**: Step 6 - E-Invoice is generated via DMS
- **Recipients**:
- Initiator
- Dealer
- Finance team (if applicable)
- **Template**: `einvoiceGenerated.template.ts` (NEW)
- **Status**: ❌ Not Implemented
- **Notification Type**: `einvoice_generated`
- **Data Needed**:
- E-Invoice number
- Invoice date
- DMS number
- Invoice amount
- Request details
- Download link (if available)
- **Notes**:
- Currently logged as activity only (line 1856-1863 in dealerClaim.service.ts)
- Should notify relevant parties when invoice is ready
---
### 11. **Credit Note Sent to Dealer** 🆕 (NEW - Recommended)
- **When**: Step 8 - Credit note is sent to dealer
- **Recipients**:
- Dealer (primary)
- Initiator (for record)
- Finance team
- **Template**: `creditNoteSent.template.ts` (NEW)
- **Status**: ❌ Not Implemented (TODO comment at line 2037-2044)
- **Notification Type**: `credit_note_sent`
- **Data Needed**:
- Credit note number
- Credit note date
- Credit note amount
- Request number
- Activity name
- Dealer information
- Reason for credit note
- Download link (if available)
- **Notes**:
- Currently has TODO comment for email implementation
- Critical for dealer notification
---
### 12. **Additional Approver Assignment** ✅ (Uses Existing)
- **When**: Additional approver is added between any steps
- **Recipients**: Additional Approver
- **Template**: `approvalRequest.template.ts` or `multiApproverRequest.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `assignment`
- **Notes**:
- Can use existing approval request templates
- Should show approval chain if multiple approvers
---
### 13. **Additional Approver Approval** ✅ (Uses Existing)
- **When**: Additional approver approves/rejects
- **Recipients**:
- Initiator
- Next approver
- **Template**: `approvalConfirmation.template.ts` or `rejectionNotification.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval` or `rejection`
- **Notes**: Generic templates work for additional approvers
---
## Summary
### ✅ Already Implemented (Using Existing Templates)
1. Request Created
2. Dealer Assignment (Proposal Required)
3. Proposal Approved
4. Proposal Rejected
5. Department Lead Approval
6. Requestor Claim Approval
7. Additional Approver Assignment/Approval
### 🆕 New Templates Needed
1. **Proposal Submitted** (`dealerProposalSubmitted.template.ts`)
- Priority: Medium
- When: Dealer submits proposal (Step 1)
2. **Activity Created** (`activityCreated.template.ts`)
- Priority: High
- When: Activity is created after Step 3 approval
- Currently uses generic notification
3. **Completion Documents Submitted** (`completionDocumentsSubmitted.template.ts`)
- Priority: Medium
- When: Dealer submits completion docs (Step 4)
4. **E-Invoice Generated** (`einvoiceGenerated.template.ts`)
- Priority: High
- When: E-Invoice is generated via DMS (Step 6)
- Currently only logged as activity
5. **Credit Note Sent** (`creditNoteSent.template.ts`)
- Priority: High
- When: Credit note is sent to dealer (Step 8)
- Currently has TODO comment
---
## Implementation Priority
### High Priority (Critical for Workflow)
1. **Activity Created** - Currently using generic notification, should be branded
2. **E-Invoice Generated** - Important for financial tracking
3. **Credit Note Sent** - Critical for dealer notification (currently TODO)
### Medium Priority (Nice to Have)
4. **Proposal Submitted** - Better UX, but existing approval request works
5. **Completion Documents Submitted** - Better UX, but existing approval request works
---
## Template Design Considerations
### 1. Support for Additional Approvers
- All templates should handle dynamic approval chains
- Show approval chain when multiple approvers exist
- Use `multiApproverRequest.template.ts` pattern for multi-level scenarios
### 2. Dealer-Specific Information
- Include dealer name, code, email prominently
- Show activity name and type
- Include dealer-specific fields (dealer comments, etc.)
### 3. Financial Information
- Show budget/amount information clearly
- Include currency formatting (INR)
- Show expense breakdowns where relevant
### 4. Document Links
- Include links to view/download documents
- Link to request detail page
- Include document counts where relevant
### 5. Next Steps
- Clearly indicate what happens next
- Show who needs to take action
- Include deadlines/TAT information
---
## Integration Points
### Notification Service Integration
All new templates need to be integrated in:
- `Re_Backend/src/services/notification.service.ts`
- Add to `emailTypeMap`
- Add case in `triggerEmailByType` switch statement
### Email Notification Service Integration
All new templates need methods in:
- `Re_Backend/src/services/emailNotification.service.ts`
- Add `sendXXX` methods for each template
- Import template functions
- Handle data preparation
### Type Definitions
Add interfaces in:
- `Re_Backend/src/emailtemplates/types.ts`
- Define data interfaces for each template
### Email Preferences
Add notification types in:
- `Re_Backend/src/emailtemplates/emailPreferences.helper.ts`
- Add to `EmailNotificationType` enum
---
## Example Template Structure
Each new template should follow the pattern:
```typescript
// types.ts
export interface DealerProposalSubmittedData extends BaseEmailData {
dealerName: string;
activityName: string;
activityType: string;
proposalBudget: number;
expectedCompletionDate: string;
dealerComments?: string;
costBreakupSummary?: string; // Summary of cost items
// ... other fields
}
// dealerProposalSubmitted.template.ts
export function getDealerProposalSubmittedEmail(data: DealerProposalSubmittedData): string {
// HTML template with Royal Enfield branding
// Responsive design
// Rich text support for descriptions
// Table support for cost breakdown
}
// emailNotification.service.ts
async sendDealerProposalSubmitted(
requestData: any,
dealerData: any,
initiatorData: any,
proposalData: any
): Promise<void> {
// Prepare data
// Check preferences
// Send email
}
// notification.service.ts
case 'proposal_submitted':
await emailNotificationService.sendDealerProposalSubmitted(...);
break;
```
---
## Testing Checklist
For each new template:
- [ ] Template renders correctly
- [ ] All dynamic fields populate correctly
- [ ] Mobile responsive
- [ ] Tables display correctly (if applicable)
- [ ] Links work correctly
- [ ] Email preferences respected
- [ ] Works with additional approvers
- [ ] Handles missing optional data gracefully
- [ ] Branding consistent with other templates
---
## Notes
1. **Additional Approvers**: All templates should work seamlessly when additional approvers are added between fixed steps. The approval chain should be shown when relevant.
2. **System Steps**: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only (not approval steps), but they still need email notifications.
3. **Dealer vs Internal Users**: Dealer may not be in the system initially - templates should handle this gracefully.
4. **Financial Data**: All financial amounts should be formatted as INR currency with proper decimal places.
5. **Document Links**: Include links to view/download documents where applicable, especially for proposals and completion documents.

View File

@ -0,0 +1,358 @@
# Dealer Claim Email Templates - Implementation Summary
## ✅ All 5 Templates Created and Integrated
All 5 new email templates for the dealer claim workflow have been successfully created and integrated into the notification system.
---
## 📧 Created Templates
### 1. **Dealer Proposal Submitted**
- **File**: `dealerProposalSubmitted.template.ts`
- **Notification Type**: `proposal_submitted`
- **Email Type**: `DEALER_PROPOSAL_SUBMITTED`
- **When**: Step 1 - Dealer submits proposal
- **Recipients**: Initiator and next approver
- **Features**:
- Shows proposal budget, expected completion date
- Cost breakdown table (if available)
- Dealer comments
- Next approver information
### 2. **Activity Created**
- **File**: `activityCreated.template.ts`
- **Notification Type**: `activity_created`
- **Email Type**: `ACTIVITY_CREATED`
- **When**: After Step 3 approval - Activity is created
- **Recipients**: Dealer, Initiator, Department Lead
- **Features**:
- Activity name, type, date, location
- Dealer information
- IO number (if available)
- Next steps information
### 3. **Completion Documents Submitted**
- **File**: `completionDocumentsSubmitted.template.ts`
- **Notification Type**: `completion_submitted`
- **Email Type**: `COMPLETION_DOCUMENTS_SUBMITTED`
- **When**: Step 4 - Dealer submits completion documents
- **Recipients**: Initiator and next approver
- **Features**:
- Completion date, participants count
- Total expenses with breakdown table
- Documents count
- Next approver information
### 4. **E-Invoice Generated**
- **File**: `einvoiceGenerated.template.ts`
- **Notification Type**: `einvoice_generated`
- **Email Type**: `EINVOICE_GENERATED`
- **When**: Step 6 - E-Invoice is generated via DMS
- **Recipients**: Initiator, Dealer, Finance team
- **Features**:
- Invoice number, date, DMS number
- Invoice amount
- Download link (if available)
- IO number and dealer information
### 5. **Credit Note Sent**
- **File**: `creditNoteSent.template.ts`
- **Notification Type**: `credit_note_sent`
- **Email Type**: `CREDIT_NOTE_SENT`
- **When**: Step 8 - Credit note is sent to dealer
- **Recipients**: Dealer (primary), Initiator, Finance team
- **Features**:
- Credit note number, date, amount
- Related invoice number
- Reason for credit note
- Download link (if available)
- Completion message
---
## 🔧 Integration Points
### ✅ Type Definitions Added
- `DealerProposalSubmittedData` interface
- `ActivityCreatedData` interface
- `CompletionDocumentsSubmittedData` interface
- `EInvoiceGeneratedData` interface
- `CreditNoteSentData` interface
### ✅ Email Notification Types Added
- `DEALER_PROPOSAL_SUBMITTED`
- `ACTIVITY_CREATED`
- `COMPLETION_DOCUMENTS_SUBMITTED`
- `EINVOICE_GENERATED`
- `CREDIT_NOTE_SENT`
### ✅ Email Notification Service Methods Added
- `sendDealerProposalSubmitted()`
- `sendActivityCreated()`
- `sendCompletionDocumentsSubmitted()`
- `sendEInvoiceGenerated()`
- `sendCreditNoteSent()`
### ✅ Notification Service Integration
- Added to `emailTypeMap`
- Added switch cases in `triggerEmailByType()`
### ✅ Templates Exported
- All templates exported in `index.ts`
---
## 📝 Usage Examples
### 1. Send Proposal Submitted Email
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
title: 'Proposal Submitted',
body: `Dealer ${dealerName} has submitted a proposal for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'proposal_submitted',
priority: 'MEDIUM',
metadata: {
dealerData: {
userId: dealerId,
email: dealerEmail,
displayName: dealerName
},
proposalData: {
totalEstimatedBudget: 50000,
expectedCompletionDate: '2025-02-15',
dealerComments: 'Proposal comments...',
costBreakup: [
{ description: 'Item 1', amount: 20000 },
{ description: 'Item 2', amount: 30000 }
],
submittedAt: new Date()
},
nextApproverId: nextApproverId
}
});
```
### 2. Send Activity Created Email
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, deptLeadId], {
title: 'Activity Created',
body: `Activity "${activityName}" has been created for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'activity_created',
priority: 'MEDIUM',
metadata: {
activityData: {
activityName: 'Dealer Event',
activityType: 'Marketing Event',
location: 'Mumbai',
dealerName: 'ABC Motors',
dealerCode: 'ABC001',
initiatorName: 'John Doe',
departmentLeadName: 'Jane Smith',
ioNumber: 'IO123456',
nextSteps: 'IO confirmation to be made...'
}
}
});
```
### 3. Send Completion Documents Submitted Email
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
title: 'Completion Documents Submitted',
body: `Dealer ${dealerName} has submitted completion documents`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'completion_submitted',
priority: 'MEDIUM',
metadata: {
dealerData: {
userId: dealerId,
email: dealerEmail,
displayName: dealerName
},
completionData: {
activityCompletionDate: new Date('2025-02-10'),
numberOfParticipants: 50,
totalClosedExpenses: 45000,
closedExpenses: [
{ description: 'Expense 1', amount: 20000 },
{ description: 'Expense 2', amount: 25000 }
],
documentsCount: 5,
submittedAt: new Date()
},
nextApproverId: nextApproverId
}
});
```
### 4. Send E-Invoice Generated Email
```typescript
await notificationService.sendToUsers([initiatorId, dealerId, financeId], {
title: 'E-Invoice Generated',
body: `E-Invoice ${invoiceNumber} has been generated for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'einvoice_generated',
priority: 'HIGH',
metadata: {
invoiceData: {
invoiceNumber: 'INV-2025-001',
invoiceDate: new Date(),
dmsNumber: 'DMS123456',
amount: 50000,
dealerName: 'ABC Motors',
dealerCode: 'ABC001',
ioNumber: 'IO123456',
generatedAt: new Date(),
downloadLink: 'https://...'
}
}
});
```
### 5. Send Credit Note Sent Email
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, financeId], {
title: 'Credit Note Sent',
body: `Credit note ${creditNoteNumber} has been sent for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'credit_note_sent',
priority: 'HIGH',
metadata: {
creditNoteData: {
creditNoteNumber: 'CN-2025-001',
creditNoteDate: new Date(),
creditNoteAmount: 45000,
dealerName: 'ABC Motors',
dealerCode: 'ABC001',
dealerEmail: 'dealer@example.com',
reason: 'Claim settlement',
invoiceNumber: 'INV-2025-001',
sentAt: new Date(),
downloadLink: 'https://...'
}
}
});
```
---
## 🎨 Template Features
All templates include:
- ✅ Royal Enfield branding
- ✅ Responsive design (mobile-friendly)
- ✅ Rich text support (tables, lists, formatting)
- ✅ Table support for cost/expense breakdowns
- ✅ Proper currency formatting (INR)
- ✅ Conditional sections (only show if data available)
- ✅ View Details button with link
- ✅ Email preferences checking
- ✅ Error handling and logging
---
## 🔄 Next Steps for Backend Integration
To use these templates in the dealer claim service, update the notification calls:
### In `dealerClaim.service.ts`:
1. **Proposal Submitted** (line ~1288):
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
type: 'proposal_submitted',
metadata: { dealerData, proposalData, nextApproverId }
});
```
2. **Activity Created** (line ~2141):
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, deptLeadId], {
type: 'activity_created',
metadata: { activityData }
});
```
3. **Completion Submitted** (line ~1393):
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
type: 'completion_submitted',
metadata: { dealerData, completionData, nextApproverId }
});
```
4. **E-Invoice Generated** (line ~1862):
```typescript
await notificationService.sendToUsers([initiatorId, dealerId, financeId], {
type: 'einvoice_generated',
metadata: { invoiceData }
});
```
5. **Credit Note Sent** (line ~2029):
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, financeId], {
type: 'credit_note_sent',
metadata: { creditNoteData }
});
```
---
## ✅ Testing Checklist
- [ ] Test proposal submitted email with cost breakdown table
- [ ] Test activity created email with IO number
- [ ] Test completion documents email with expense breakdown
- [ ] Test e-invoice email with download link
- [ ] Test credit note email with all fields
- [ ] Verify mobile responsiveness
- [ ] Verify email preferences are respected
- [ ] Test with missing optional fields
- [ ] Verify tables render correctly in email clients
- [ ] Test with additional approvers in workflow
---
## 📚 Related Files
- **Templates**: `Re_Backend/src/emailtemplates/*.template.ts`
- **Types**: `Re_Backend/src/emailtemplates/types.ts`
- **Email Service**: `Re_Backend/src/services/emailNotification.service.ts`
- **Notification Service**: `Re_Backend/src/services/notification.service.ts`
- **Preferences**: `Re_Backend/src/emailtemplates/emailPreferences.helper.ts`
- **Planning Doc**: `Re_Backend/src/emailtemplates/DEALER_CLAIM_EMAIL_TEMPLATES.md`
---
## 🎯 Summary
All 5 dealer claim email templates are now:
- ✅ Created with proper structure and styling
- ✅ Integrated into the notification system
- ✅ Ready to use with proper metadata
- ✅ Supporting additional approvers
- ✅ Mobile responsive
- ✅ Table support for financial data
- ✅ Following Royal Enfield branding guidelines
The templates are ready to be used in the dealer claim workflow service!

View File

@ -0,0 +1,180 @@
/**
* Activity Created Email Template
* Sent when activity is created after Department Lead approval (Step 3)
*/
import { ActivityCreatedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getActivityCreatedEmail(data: ActivityCreatedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Activity Created</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Activity Created Successfully',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
The activity <strong style="color: #333333;">"${data.activityName}"</strong> has been created successfully for request <strong>${data.requestId}</strong>.
</p>
<!-- Activity Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Activity Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
${data.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityDate}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Location:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.location}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName} ${data.dealerCode ? `(${data.dealerCode})` : ''}
</td>
</tr>
${data.ioNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>IO Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.ioNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Created On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.createdDate} at ${data.createdTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.nextSteps ? `
<!-- Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<div style="color: #004085; font-size: 14px; line-height: 1.8;">
${wrapRichText(data.nextSteps)}
</div>
</div>
` : `
<!-- Default Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
<li>IO confirmation to be made</li>
<li>Dealer will proceed with activity execution</li>
<li>Completion documents will be submitted after activity completion</li>
</ul>
</div>
`}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -10,7 +10,7 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
const commentsSection = data.approverComments ? `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approver Comments:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #28a745; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #28a745; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.approverComments)}
</div>
</div>

View File

@ -55,6 +55,16 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
@ -92,10 +102,10 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
</tr>
</table>
<!-- Description (supports rich text HTML) -->
<!-- Description (supports rich text HTML including tables) -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>

View File

@ -99,7 +99,7 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Skipping:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #17a2b8; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #17a2b8; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.skipReason)}
</div>
</div>

View File

@ -0,0 +1,180 @@
/**
* Completion Documents Submitted Email Template
* Sent when dealer submits completion documents (Step 4)
*/
import { CompletionDocumentsSubmittedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getCompletionDocumentsSubmittedEmail(data: CompletionDocumentsSubmittedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Completion Documents Submitted</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Completion Documents Submitted',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong style="color: #333333;">${data.dealerName}</strong> has submitted completion documents for the activity <strong>"${data.activityName}"</strong> (Request ${data.requestId}).
</p>
<!-- Completion Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Completion Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Completion Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.activityCompletionDate}
</td>
</tr>
${data.numberOfParticipants ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Participants:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.numberOfParticipants}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Total Expenses:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
${data.documentsCount ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Documents:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.documentsCount} document(s) submitted
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Submitted On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.submittedDate} at ${data.submittedTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.expenseBreakdown ? `
<!-- Expense Breakdown -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Expense Breakdown:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.expenseBreakdown)}
</div>
</div>
` : ''}
<!-- Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.8;">
${data.nextApproverName
? `Completion documents are now pending review by <strong>${data.nextApproverName}</strong>. You will be notified once a decision is made.`
: `Completion documents have been submitted successfully. <strong>Your review and approval is required</strong> to proceed with the final claim approval. Please review the completion documents and take action on this request.`}
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to review the completion documents and take action.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -0,0 +1,203 @@
/**
* Credit Note Sent Email Template
* Sent when credit note is sent to dealer (Step 8)
*/
import { CreditNoteSentData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getCreditNoteSentEmail(data: CreditNoteSentData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Credit Note Sent</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Credit Note Sent',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
A credit note has been generated and sent for the claim request <strong>${data.requestNumber}</strong> (${data.requestId}).
</p>
<!-- Credit Note Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Credit Note Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Request Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestNumber}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Credit Note Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.creditNoteNumber}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Credit Note Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.creditNoteDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Credit Note Amount:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600; color: #28a745;">
${data.creditNoteAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
${data.invoiceNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Related Invoice:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.invoiceNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName} ${data.dealerCode ? `(${data.dealerCode})` : ''}
</td>
</tr>
${data.reason ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; vertical-align: top;">
<strong>Reason:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.reason}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Sent On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.sentDate} at ${data.sentTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.downloadLink ? `
<!-- Download Section -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Download Credit Note</h3>
<p style="margin: 0 0 15px; color: #004085; font-size: 14px; line-height: 1.8;">
You can download the credit note using the link below.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.downloadLink}" class="cta-button" style="display: inline-block; padding: 12px 30px; background-color: #0066cc; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 14px; font-weight: 600;">
Download Credit Note
</a>
</td>
</tr>
</table>
</div>
` : ''}
<!-- Completion Message -->
<div style="padding: 20px; background-color: #d4edda; border-left: 4px solid #28a745; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #155724; font-size: 16px; font-weight: 600;">Claim Process Completed</h3>
<p style="margin: 0; color: #155724; font-size: 14px; line-height: 1.8;">
The credit note has been sent to <strong>${data.dealerEmail}</strong>. The claim management process for this request is now complete.
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -0,0 +1,148 @@
/**
* Dealer Completion Documents Required Email Template
* Sent when dealer is assigned to submit completion documents (Step 4)
* This is different from proposal required - dealer needs to submit completion documents
*/
import { DealerProposalRequiredData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Completion Documents Required</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Completion Documents Required',
...HeaderStyles.warning
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.dealerName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
The activity <strong style="color: #333333;">"${data.activityName}"</strong> has been approved and is ready for execution. Please submit completion documents after the activity is completed.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Activity Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
${data.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityDate}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Location:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.location}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
</table>
</td>
</tr>
</table>
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Need to Submit:</h3>
<ul style="margin: 0; padding: 0 0 0 20px; color: #666666; font-size: 14px; line-height: 1.6;">
<li style="margin-bottom: 5px;">Activity completion date and details.</li>
<li style="margin-bottom: 5px;">Number of participants.</li>
<li style="margin-bottom: 5px;">Closed expenses breakdown with supporting documents.</li>
<li style="margin-bottom: 5px;">All relevant completion documents and receipts.</li>
<li>Any other supporting documents as required.</li>
</ul>
</div>
${data.dueDate ? `
<p style="margin: 0 0 30px; color: #d9534f; font-size: 16px; line-height: 1.6; font-weight: 600; text-align: center;">
Please submit completion documents by: <strong style="color: #d9534f;">${data.dueDate}</strong>
</p>
` : ''}
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
Submit Completion Documents
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to view the request details and submit your completion documents.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -0,0 +1,206 @@
/**
* Dealer Proposal Required Email Template
* Sent when dealer is assigned to submit proposal (Step 1)
* This is different from approval request - dealer needs to submit, not approve
*/
import { DealerProposalRequiredData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Proposal Required</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Proposal Required',
...HeaderStyles.info
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.dealerName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
You have been assigned to submit a proposal for a new claim request. <strong style="color: #333333;">${data.initiatorName}</strong> has created a claim request that requires your proposal submission.
</p>
<!-- Request Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
${data.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityDate}
</td>
</tr>
` : ''}
${data.location ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Location:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.location}
</td>
</tr>
` : ''}
${data.estimatedBudget ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Estimated Budget:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Requestor:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Created On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Time:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTime}
</td>
</tr>
${data.tatHours ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Due Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.dueDate || 'Please submit as soon as possible'}
</td>
</tr>
` : ''}
</table>
</td>
</tr>
</table>
<!-- Description (supports rich text HTML including tables) -->
${data.requestDescription ? `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>
` : ''}
<!-- What You Need to Submit -->
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">📋 What You Need to Submit:</h3>
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">
<li><strong>Proposal Document</strong> - Detailed proposal with requested information</li>
<li><strong>Cost Breakdown</strong> - Itemized list of costs and expenses</li>
<li><strong>Timeline</strong> - Expected completion date or number of days</li>
<li><strong>Supporting Documents</strong> - Any additional documents or attachments</li>
<li><strong>Comments</strong> - Any additional information or clarifications</li>
</ul>
</div>
<!-- Priority Section (dynamic) -->
${getPrioritySection(data.priority)}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
Submit Proposal
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to view the request details and submit your proposal.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -0,0 +1,178 @@
/**
* Dealer Proposal Submitted Email Template
* Sent when dealer submits proposal (Step 1)
*/
import { DealerProposalSubmittedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getDealerProposalSubmittedEmail(data: DealerProposalSubmittedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Proposal Submitted</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Proposal Submitted',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong style="color: #333333;">${data.dealerName}</strong> has submitted a proposal for the claim request <strong>${data.requestId}</strong>.
</p>
<!-- Proposal Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Proposal Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Proposed Budget:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.proposalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Expected Completion:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.expectedCompletionDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Submitted On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.submittedDate} at ${data.submittedTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.costBreakupSummary ? `
<!-- Cost Breakdown -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Cost Breakdown:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.costBreakupSummary)}
</div>
</div>
` : ''}
${data.dealerComments ? `
<!-- Dealer Comments -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Dealer Comments:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.dealerComments)}
</div>
</div>
` : ''}
<!-- Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.8;">
${data.nextApproverName
? `This proposal is now pending review by <strong>${data.nextApproverName}</strong>. You will be notified once a decision is made.`
: `This proposal is now pending your review. Please review and take action on this request.`}
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to review the proposal and take action.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -0,0 +1,187 @@
/**
* E-Invoice Generated Email Template
* Sent when e-invoice is generated via DMS (Step 6)
*/
import { EInvoiceGeneratedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getEInvoiceGeneratedEmail(data: EInvoiceGeneratedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>E-Invoice Generated</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'E-Invoice Generated',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
An e-invoice has been successfully generated for request <strong>${data.requestId}</strong> via DMS integration.
</p>
<!-- Invoice Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Invoice Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>E-Invoice Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.invoiceNumber}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Invoice Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.invoiceDate}
</td>
</tr>
${data.dmsNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>DMS Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dmsNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Invoice Amount:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.invoiceAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName} ${data.dealerCode ? `(${data.dealerCode})` : ''}
</td>
</tr>
${data.ioNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>IO Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.ioNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Generated On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.generatedDate} at ${data.generatedTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.downloadLink ? `
<!-- Download Section -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Download Invoice</h3>
<p style="margin: 0 0 15px; color: #004085; font-size: 14px; line-height: 1.8;">
You can download the e-invoice using the link below.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.downloadLink}" class="cta-button" style="display: inline-block; padding: 12px 30px; background-color: #0066cc; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 14px; font-weight: 600;">
Download E-Invoice
</a>
</td>
</tr>
</table>
</div>
` : ''}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
The e-invoice has been generated and is ready for processing.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -24,7 +24,14 @@ export enum EmailNotificationType {
REQUEST_CLOSED = 'request_closed',
WORKFLOW_PAUSED = 'workflow_paused',
PARTICIPANT_ADDED = 'participant_added',
APPROVER_SKIPPED = 'approver_skipped'
SPECTATOR_ADDED = 'spectator_added',
APPROVER_SKIPPED = 'approver_skipped',
// Dealer Claim Specific
DEALER_PROPOSAL_SUBMITTED = 'dealer_proposal_submitted',
ACTIVITY_CREATED = 'activity_created',
COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted',
EINVOICE_GENERATED = 'einvoice_generated',
CREDIT_NOTE_SENT = 'credit_note_sent'
}
/**

View File

@ -115,6 +115,55 @@ export function getRichTextStyles(): string {
margin: 12px 0;
}
/* Table styles for rich text content */
.rich-text-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
background-color: #ffffff;
border: 1px solid #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.rich-text-content table thead {
background-color: #f8f9fa;
}
.rich-text-content table th {
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #1a1a1a;
font-size: 14px;
border-bottom: 2px solid #e9ecef;
background-color: #f8f9fa;
}
.rich-text-content table td {
padding: 10px 15px;
color: #333333;
font-size: 14px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
}
.rich-text-content table tbody tr:last-child td {
border-bottom: none;
}
.rich-text-content table tbody tr:hover {
background-color: #f8f9fa;
}
.rich-text-content table tbody tr:nth-child(even) {
background-color: #fafafa;
}
.rich-text-content table tbody tr:nth-child(even):hover {
background-color: #f0f0f0;
}
/* Mobile adjustments for rich text */
@media only screen and (max-width: 600px) {
.rich-text-content p,
@ -127,6 +176,21 @@ export function getRichTextStyles(): string {
.rich-text-content h2 { font-size: 16px !important; }
.rich-text-content h3 { font-size: 15px !important; }
.rich-text-content h4 { font-size: 14px !important; }
/* Make tables scrollable on mobile - keep table structure */
.rich-text-content table {
width: 100% !important;
max-width: 100% !important;
display: table !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.rich-text-content table th,
.rich-text-content table td {
padding: 8px 12px !important;
font-size: 13px !important;
}
}
</style>
`;
@ -135,11 +199,96 @@ export function getRichTextStyles(): string {
/**
* Wrap rich text content with proper styling
* Use this for descriptions and comments from rich text editors
* Enhanced to support tables with inline styles for email client compatibility
*/
export function wrapRichText(htmlContent: string): string {
if (!htmlContent) return '';
// Process tables to add inline styles for email client compatibility
// Email clients often strip CSS classes, so we need inline styles
let processedContent = htmlContent;
// Add inline styles to tables for better email client support
processedContent = processedContent.replace(
/<table([^>]*)>/gi,
(match, attrs) => {
// Check if style attribute already exists
if (attrs && attrs.includes('style=')) {
return match; // Keep existing styles
}
return `<table${attrs} style="width: 100%; border-collapse: collapse; margin: 12px 0; background-color: #ffffff; border: 1px solid #e9ecef;">`;
}
);
// Add inline styles to table headers
processedContent = processedContent.replace(
/<th([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<th${attrs} style="padding: 12px 15px; text-align: left; font-weight: 600; color: #1a1a1a; font-size: 14px; border-bottom: 2px solid #e9ecef; background-color: #f8f9fa;">`;
}
);
// Add inline styles to table cells
processedContent = processedContent.replace(
/<td([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<td${attrs} style="padding: 10px 15px; color: #333333; font-size: 14px; border-bottom: 1px solid #f0f0f0; vertical-align: top;">`;
}
);
// Add inline styles to table rows for hover effect (email-safe)
processedContent = processedContent.replace(
/<tr([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<tr${attrs} style="border-bottom: 1px solid #f0f0f0;">`;
}
);
// Add inline styles to thead
processedContent = processedContent.replace(
/<thead([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<thead${attrs} style="background-color: #f8f9fa;">`;
}
);
// Add inline styles to tbody (if present)
processedContent = processedContent.replace(
/<tbody([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<tbody${attrs} style="">`;
}
);
// Add inline styles to tfoot (if present)
processedContent = processedContent.replace(
/<tfoot([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<tfoot${attrs} style="background-color: #f8f9fa; font-weight: 600;">`;
}
);
return `
<div class="rich-text-content" style="color: #666666; font-size: 14px; line-height: 1.6;">
${htmlContent}
${processedContent}
</div>
`;
}
@ -630,7 +779,7 @@ export function getConclusionSection(conclusionRemark?: string): string {
return `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Conclusion Remarks:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6f42c1; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6f42c1; border-radius: 4px; overflow-x: auto;">
${wrapRichText(conclusionRemark)}
</div>
</div>

View File

@ -25,6 +25,14 @@ export { getTATBreachedEmail } from './tatBreached.template';
export { getWorkflowPausedEmail } from './workflowPaused.template';
export { getWorkflowResumedEmail } from './workflowResumed.template';
export { getParticipantAddedEmail } from './participantAdded.template';
export { getSpectatorAddedEmail } from './spectatorAdded.template';
export { getApproverSkippedEmail } from './approverSkipped.template';
export { getRequestClosedEmail } from './requestClosed.template';
export { getDealerProposalSubmittedEmail } from './dealerProposalSubmitted.template';
export { getDealerProposalRequiredEmail } from './dealerProposalRequired.template';
export { getDealerCompletionRequiredEmail } from './dealerCompletionRequired.template';
export { getActivityCreatedEmail } from './activityCreated.template';
export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmitted.template';
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
export { getCreditNoteSentEmail } from './creditNoteSent.template';

View File

@ -55,6 +55,16 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
@ -96,10 +106,10 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
</table>
</div>
<!-- Description (supports rich text HTML) -->
<!-- Description (supports rich text HTML including tables) -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>

View File

@ -107,7 +107,7 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>

View File

@ -91,7 +91,7 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.rejectionReason)}
</div>
</div>

View File

@ -0,0 +1,155 @@
/**
* Spectator Added Email Template
*
* Sent when a user is added as a spectator to a request
*/
import { SpectatorAddedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getSpectatorAddedEmail(data: SpectatorAddedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Added as Spectator</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({
title: "You've Been Added as Spectator",
...HeaderStyles.info
}))}
<tr>
<td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.spectatorName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
You have been added as a <strong>Spectator</strong> to the following request${data.addedByName ? ` by <strong>${data.addedByName}</strong>` : ''}. ${getRoleDescription('Spectator')}
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle || 'N/A'}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
${data.requestType ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Request Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestType}
</td>
</tr>
` : ''}
${data.currentStatus ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Current Status:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.currentStatus}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Your Role:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
<strong style="color: #667eea;">Spectator</strong>
</td>
</tr>
${data.addedDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Added On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.addedDate}${data.addedTime ? ` at ${data.addedTime}` : ''}
</td>
</tr>
` : ''}
</table>
</td>
</tr>
</table>
${data.requestDescription ? `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>
` : ''}
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Your Permissions as Spectator</h3>
${getPermissionsContent('Spectator')}
</div>
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
You can now access this request, view documents, and participate in discussions.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -137,3 +137,102 @@ export interface RequestClosedData extends BaseEmailData {
documentsCount: number;
}
export interface SpectatorAddedData extends BaseEmailData {
spectatorName: string;
addedByName?: string;
initiatorName: string;
requestType?: string;
currentStatus?: string;
addedDate?: string;
addedTime?: string;
requestDescription?: string;
}
// Dealer Claim Specific Templates
export interface DealerProposalSubmittedData extends BaseEmailData {
dealerName: string;
activityName: string;
activityType: string;
proposalBudget: number;
expectedCompletionDate: string;
dealerComments?: string;
costBreakupSummary?: string; // Summary or table of cost items
submittedDate: string;
submittedTime: string;
nextApproverName?: string;
}
export interface ActivityCreatedData extends BaseEmailData {
activityName: string;
activityType: string;
activityDate?: string;
location: string;
dealerName: string;
dealerCode: string;
initiatorName: string;
departmentLeadName?: string;
ioNumber?: string;
createdDate: string;
createdTime: string;
nextSteps?: string;
}
export interface CompletionDocumentsSubmittedData extends BaseEmailData {
dealerName: string;
activityName: string;
activityCompletionDate: string;
numberOfParticipants?: number;
totalClosedExpenses: number;
expenseBreakdown?: string; // Summary or table of expenses
documentsCount?: number;
submittedDate: string;
submittedTime: string;
nextApproverName?: string;
}
export interface EInvoiceGeneratedData extends BaseEmailData {
invoiceNumber: string;
invoiceDate: string;
dmsNumber?: string;
invoiceAmount: number;
dealerName: string;
dealerCode: string;
activityName: string;
ioNumber?: string;
generatedDate: string;
generatedTime: string;
downloadLink?: string;
}
export interface CreditNoteSentData extends BaseEmailData {
creditNoteNumber: string;
creditNoteDate: string;
creditNoteAmount: number;
dealerName: string;
dealerCode: string;
dealerEmail: string;
activityName: string;
requestNumber: string;
reason?: string;
invoiceNumber?: string;
sentDate: string;
sentTime: string;
downloadLink?: string;
}
export interface DealerProposalRequiredData extends BaseEmailData {
dealerName: string;
initiatorName: string;
activityName: string;
activityType: string;
activityDate?: string;
location?: string;
estimatedBudget?: number;
requestDate: string;
requestTime: string;
requestDescription: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
tatHours?: number;
dueDate?: string;
}

View File

@ -91,7 +91,7 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Pause:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6c757d; border-radius: 4px;">
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6c757d; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.pauseReason)}
</div>
</div>

View File

@ -1,5 +1,6 @@
import { Router } from 'express';
import { DealerClaimController } from '../controllers/dealerClaim.controller';
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import multer from 'multer';
@ -7,6 +8,7 @@ import path from 'path';
const router = Router();
const dealerClaimController = new DealerClaimController();
const dealerDashboardController = new DealerDashboardController();
// Configure multer for file uploads (memory storage for direct GCS upload)
const upload = multer({
@ -25,6 +27,13 @@ const upload = multer({
},
});
/**
* @route GET /api/v1/dealer-claims/dashboard
* @desc Get dealer dashboard KPIs and category data
* @access Private
*/
router.get('/dashboard', authenticateToken, asyncHandler(dealerDashboardController.getDashboard.bind(dealerDashboardController)));
/**
* @route POST /api/v1/dealer-claims
* @desc Create a new dealer claim request

View File

@ -0,0 +1,173 @@
# Workflow Email Service Guide
## Overview
This guide explains how to add new workflow-specific email services without breaking existing implementations. The architecture uses a factory pattern with interfaces to ensure isolation between different workflow types.
## Architecture
```
┌─────────────────────────────────────┐
│ notification.service.ts │
│ (Main Router) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ workflowEmail.factory.ts │
│ (Factory Pattern) │
└──────────────┬──────────────────────┘
┌───────┴───────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Dealer Claim │ │ Custom/ │
│ Email Service│ │ Default │
└──────────────┘ └──────────────┘
```
## Adding a New Workflow Type
### Step 1: Create the Email Service
Create a new file: `Re_Backend/src/services/[workflowType]Email.service.ts`
Example: `Re_Backend/src/services/vendorPaymentEmail.service.ts`
```typescript
import { ApprovalLevel } from '@models/ApprovalLevel';
import { User } from '@models/User';
import logger from '@utils/logger';
import { IWorkflowEmailService } from './workflowEmail.interface';
import { emailNotificationService } from './emailNotification.service';
export class VendorPaymentEmailService implements IWorkflowEmailService {
async sendAssignmentEmail(
requestData: any,
approverUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null,
allLevels: ApprovalLevel[]
): Promise<void> {
try {
// Your workflow-specific logic here
// Example: Check if it's a vendor-specific step
const levelName = currentLevel?.levelName || '';
const isVendorStep = levelName.toLowerCase().includes('vendor');
if (isVendorStep) {
// Use vendor-specific template
await this.sendVendorSpecificEmail(requestData, approverUser, initiatorData, currentLevel);
} else {
// Use standard template
await this.sendStandardApprovalEmail(requestData, approverUser, initiatorData, currentLevel);
}
} catch (error) {
logger.error(`[VendorPaymentEmail] Error sending assignment email:`, error);
throw error;
}
}
private async sendVendorSpecificEmail(
requestData: any,
vendorUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null
): Promise<void> {
// Implementation for vendor-specific email
}
private async sendStandardApprovalEmail(
requestData: any,
approverUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null
): Promise<void> {
// Implementation for standard approval email
}
}
export const vendorPaymentEmailService = new VendorPaymentEmailService();
```
### Step 2: Register in Factory
Update `Re_Backend/src/services/workflowEmail.factory.ts`:
```typescript
import { vendorPaymentEmailService } from './vendorPaymentEmail.service';
class WorkflowEmailServiceFactory {
getService(workflowType: string): IWorkflowEmailService {
switch (workflowType) {
case 'CLAIM_MANAGEMENT':
return dealerClaimEmailService;
case 'VENDOR_PAYMENT': // Add your new workflow type
return vendorPaymentEmailService;
default:
return null as any;
}
}
hasDedicatedService(workflowType: string): boolean {
return workflowType === 'CLAIM_MANAGEMENT'
|| workflowType === 'VENDOR_PAYMENT'; // Add your new workflow type
}
}
```
### Step 3: Create Email Templates (if needed)
If your workflow needs custom templates, create them in:
`Re_Backend/src/emailtemplates/[templateName].template.ts`
Then add the send method to `emailNotification.service.ts`:
```typescript
async sendVendorPaymentRequired(
requestData: any,
vendorData: any,
initiatorData: any,
paymentData?: any
): Promise<void> {
// Implementation
}
```
## Benefits of This Architecture
1. **Isolation**: Each workflow type has its own service, preventing cross-workflow breakage
2. **Scalability**: Easy to add new workflow types without modifying existing code
3. **Maintainability**: Changes to one workflow don't affect others
4. **Type Safety**: Interface ensures consistent implementation
5. **Testability**: Each service can be tested independently
## Best Practices
1. **Always implement IWorkflowEmailService**: Ensures consistency
2. **Use levelName, not levelNumber**: Handles additional approvers correctly
3. **Log workflow-specific actions**: Helps with debugging
4. **Handle errors gracefully**: Don't break the entire notification system
5. **Document workflow-specific logic**: Makes it easier for others to understand
## Example: Dealer Claim Service
See `dealerClaimEmail.service.ts` for a complete example of:
- Dynamic step identification
- Multiple template types (proposal, completion, standard)
- Additional approver handling
- Proper error handling
## Testing
When adding a new workflow type:
1. Test assignment emails for all steps
2. Test with additional approvers
3. Test with missing levelName (fallback to levelNumber)
4. Test error handling
5. Verify custom workflows still work (regression test)

View File

@ -2136,8 +2136,22 @@ export class DealerClaimService {
const emailSubject = `Activity Created: ${activityName} - ${requestNumber}`;
const emailBody = `Activity "${activityName}" (${activityType}) has been created successfully for request ${requestNumber}. IO confirmation to be made.`;
// Send notifications to users in the system
// Send notifications to users in the system with proper metadata
if (userIdsForNotification.length > 0) {
// Prepare metadata for activity created email template
const activityData = {
activityName: activityName,
activityType: activityType,
activityDate: claimDetails.activityDate,
location: claimDetails.location || 'Not specified',
dealerName: claimDetails.dealerName || 'Dealer',
dealerCode: claimDetails.dealerCode,
initiatorName: initiator ? (initiator.displayName || initiator.email) : 'Initiator',
departmentLeadName: departmentLead ? (departmentLead.displayName || departmentLead.email) : undefined,
ioNumber: undefined, // IO number will be added later when IO is created
nextSteps: 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.'
};
await notificationService.sendToUsers(userIdsForNotification, {
title: emailSubject,
body: emailBody,
@ -2146,7 +2160,10 @@ export class DealerClaimService {
url: `/request/${requestNumber}`,
type: 'activity_created',
priority: 'MEDIUM',
actionRequired: false
actionRequired: false,
metadata: {
activityData: activityData
}
});
}

View File

@ -12,6 +12,7 @@
import { ApprovalLevel } from '@models/ApprovalLevel';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { User } from '@models/User';
import { ApprovalAction } from '../types/approval.types';
import { ApprovalStatus, WorkflowStatus } from '../types/common.types';
import { calculateTATPercentage } from '@utils/helpers';
@ -246,10 +247,16 @@ export class DealerClaimApprovalService {
// Handle dealer claim-specific step processing
const currentLevelName = (level.levelName || '').toLowerCase();
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
const isRequestorClaimApproval = currentLevelName.includes('requestor') &&
(currentLevelName.includes('claim') || currentLevelName.includes('approval')) ||
level.levelNumber === 5;
// Check by levelName first, use levelNumber only as fallback if levelName is missing
// This handles cases where additional approvers shift step numbers
const hasLevelNameForDeptLead = level.levelName && level.levelName.trim() !== '';
const isDeptLeadApproval = hasLevelNameForDeptLead
? currentLevelName.includes('department lead')
: (level.levelNumber === 3); // Only use levelNumber if levelName is missing
const isRequestorClaimApproval = hasLevelNameForDeptLead
? (currentLevelName.includes('requestor') && (currentLevelName.includes('claim') || currentLevelName.includes('approval')))
: (level.levelNumber === 5); // Only use levelNumber if levelName is missing
if (isDeptLeadApproval) {
// Activity Creation is now an activity log only - process it automatically
@ -280,7 +287,22 @@ export class DealerClaimApprovalService {
});
// Notify initiator about the approval
if (wf) {
// BUT skip this if it's a dealer proposal or dealer completion step - those have special notifications below
// Priority: levelName check first, then levelNumber only if levelName is missing
const hasLevelNameForApproval = level.levelName && level.levelName.trim() !== '';
const levelNameForApproval = hasLevelNameForApproval && level.levelName ? level.levelName.toLowerCase() : '';
const isDealerProposalApproval = hasLevelNameForApproval
? (levelNameForApproval.includes('dealer') && levelNameForApproval.includes('proposal'))
: (level.levelNumber === 1); // Only use levelNumber if levelName is missing
const isDealerCompletionApproval = hasLevelNameForApproval
? (levelNameForApproval.includes('dealer') && (levelNameForApproval.includes('completion') || levelNameForApproval.includes('documents')))
: (level.levelNumber === 5); // Only use levelNumber if levelName is missing
// Skip sending approval notification to initiator if they are the approver
// (they don't need to be notified that they approved their own request)
const isApproverInitiator = level.approverId && (wf as any).initiatorId && level.approverId === (wf as any).initiatorId;
if (wf && !isDealerProposalApproval && !isDealerCompletionApproval && !isApproverInitiator) {
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: `Request Approved - Level ${level.levelNumber}`,
body: `Your request "${(wf as any).title}" has been approved by ${level.approverName || level.approverEmail} and forwarded to the next approver.`,
@ -290,6 +312,8 @@ export class DealerClaimApprovalService {
type: 'approval',
priority: 'MEDIUM'
});
} else if (isApproverInitiator) {
logger.info(`[DealerClaimApproval] Skipping approval notification to initiator - they are the approver`);
}
// Notify next approver - ALWAYS send notification when there's a next level
@ -308,65 +332,209 @@ export class DealerClaimApprovalService {
const isSystemName = nextApproverName.toLowerCase() === 'system auto-process'
|| nextApproverName.toLowerCase().includes('system');
// Only send notifications to real users, NOT system processes
if (!isAutoStep && !isSystemEmail && !isSystemName && nextApproverId && nextApproverId !== 'system') {
try {
logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
await notificationService.sendToUsers([ nextApproverId ], {
title: `Action required: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
logger.info(`[DealerClaimApproval] ✅ Assignment notification sent successfully to ${nextApproverName} (${nextApproverId}) for level ${nextLevelNumber}`);
// Log assignment activity for the next approver
await activityService.log({
requestId: level.requestId,
type: 'assignment',
user: { userId: level.approverId, name: level.approverName },
timestamp: new Date().toISOString(),
action: 'Assigned to approver',
details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
} catch (notifError) {
logger.error(`[DealerClaimApproval] ❌ Failed to send notification to next approver ${nextApproverId} at level ${nextLevelNumber}:`, notifError);
// Don't throw - continue with workflow even if notification fails
}
} else {
logger.info(`[DealerClaimApproval] ⚠️ Skipping notification for system/auto-step: ${nextApproverEmail} (${nextApproverId}) at level ${nextLevelNumber}`);
}
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents)
const levelName = (level.levelName || '').toLowerCase();
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
// Check this BEFORE sending assignment notification to avoid duplicates
// Priority: levelName check first, then levelNumber only if levelName is missing
const hasLevelNameForNotification = level.levelName && level.levelName.trim() !== '';
const levelNameForNotification = hasLevelNameForNotification && level.levelName ? level.levelName.toLowerCase() : '';
const isDealerProposalApproval = hasLevelNameForNotification
? (levelNameForNotification.includes('dealer') && levelNameForNotification.includes('proposal'))
: (level.levelNumber === 1); // Only use levelNumber if levelName is missing
const isDealerCompletionApproval = hasLevelNameForNotification
? (levelNameForNotification.includes('dealer') && (levelNameForNotification.includes('completion') || levelNameForNotification.includes('documents')))
: (level.levelNumber === 5); // Only use levelNumber if levelName is missing
if ((isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) {
const stepMessage = isDealerProposalApproval
? 'Dealer proposal has been submitted and is now under review.'
: 'Dealer completion documents have been submitted and are now under review.';
// Check if next approver is the initiator (to avoid duplicate notifications)
const isNextApproverInitiator = nextApproverId && (wf as any).initiatorId && nextApproverId === (wf as any).initiatorId;
if (isDealerProposalApproval && (wf as any).initiatorId) {
// Get dealer and proposal data for the email template
const { DealerClaimDetails } = await import('@models/DealerClaimDetails');
const { DealerProposalDetails } = await import('@models/DealerProposalDetails');
const { DealerProposalCostItem } = await import('@models/DealerProposalCostItem');
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId: level.requestId } });
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId: level.requestId } });
// Get cost items if proposal exists
let costBreakup: any[] = [];
if (proposalDetails) {
const proposalId = (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id;
if (proposalId) {
const costItems = await DealerProposalCostItem.findAll({
where: { proposalId },
order: [['itemOrder', 'ASC']]
});
costBreakup = costItems.map((item: any) => ({
description: item.itemDescription || item.description,
amount: Number(item.amount) || 0
}));
}
}
// Get dealer user
const dealerUser = level.approverId ? await User.findByPk(level.approverId) : null;
const dealerData = dealerUser ? dealerUser.toJSON() : {
userId: level.approverId,
email: level.approverEmail || '',
displayName: level.approverName || level.approverEmail || 'Dealer'
};
// Get next approver (could be Step 2 - Requestor Evaluation, or an additional approver if one was added between Step 1 and Step 2)
// The nextLevel is already found above using dynamic logic that handles additional approvers correctly
const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
// Check if next approver is an additional approver (handles cases where additional approvers are added between Step 1 and Step 2)
const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : '';
const isNextAdditionalApprover = nextLevelName.includes('additional approver');
// Send proposal submitted notification with proper type and metadata
// This will use the dealerProposalSubmitted template, not the multi-level approval template
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted',
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
title: 'Proposal Submitted',
body: `Dealer ${dealerData.displayName || dealerData.email} has submitted a proposal for your claim request "${(wf as any).title}".`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
type: 'proposal_submitted',
priority: 'MEDIUM',
actionRequired: false
actionRequired: false,
metadata: {
dealerData: dealerData,
proposalData: {
totalEstimatedBudget: proposalDetails ? (proposalDetails as any).totalEstimatedBudget : 0,
expectedCompletionDate: proposalDetails ? (proposalDetails as any).expectedCompletionDate : undefined,
dealerComments: proposalDetails ? (proposalDetails as any).dealerComments : undefined,
costBreakup: costBreakup,
submittedAt: proposalDetails ? (proposalDetails as any).submittedAt : new Date(),
nextApproverIsAdditional: isNextAdditionalApprover,
nextApproverIsInitiator: isNextApproverInitiator
},
nextApproverId: nextApproverData ? nextApproverData.userId : undefined,
// Add activity information from claimDetails
activityName: claimDetails ? (claimDetails as any).activityName : undefined,
activityType: claimDetails ? (claimDetails as any).activityType : undefined
}
});
logger.info(`[DealerClaimApproval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'}`);
logger.info(`[DealerClaimApproval] Sent proposal_submitted notification to initiator for Dealer Proposal Submission. Next approver: ${isNextApproverInitiator ? 'Initiator (self)' : (isNextAdditionalApprover ? 'Additional Approver' : 'Step 2 (Requestor Evaluation)')}`);
} else if (isDealerCompletionApproval && (wf as any).initiatorId) {
// Get dealer and completion data for the email template
const { DealerClaimDetails } = await import('@models/DealerClaimDetails');
const { DealerCompletionDetails } = await import('@models/DealerCompletionDetails');
const { DealerCompletionExpense } = await import('@models/DealerCompletionExpense');
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId: level.requestId } });
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId: level.requestId } });
// Get expense items if completion exists
let closedExpenses: any[] = [];
if (completionDetails) {
const expenses = await DealerCompletionExpense.findAll({
where: { requestId: level.requestId },
order: [['createdAt', 'ASC']]
});
closedExpenses = expenses.map((item: any) => ({
description: item.description || '',
amount: Number(item.amount) || 0
}));
}
// Get dealer user
const dealerUser = level.approverId ? await User.findByPk(level.approverId) : null;
const dealerData = dealerUser ? dealerUser.toJSON() : {
userId: level.approverId,
email: level.approverEmail || '',
displayName: level.approverName || level.approverEmail || 'Dealer'
};
// Get next approver (could be Step 5 - Requestor Claim Approval, or an additional approver if one was added between Step 4 and Step 5)
const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
// Check if next approver is an additional approver (handles cases where additional approvers are added between Step 4 and Step 5)
const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : '';
const isNextAdditionalApprover = nextLevelName.includes('additional approver');
// Check if next approver is the initiator (to show appropriate message in email)
const isNextApproverInitiator = nextApproverData && (wf as any).initiatorId && nextApproverData.userId === (wf as any).initiatorId;
// Send completion submitted notification with proper type and metadata
// This will use the completionDocumentsSubmitted template, not the multi-level approval template
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: 'Completion Documents Submitted',
body: `Dealer ${dealerData.displayName || dealerData.email} has submitted completion documents for your claim request "${(wf as any).title}".`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'completion_submitted',
priority: 'MEDIUM',
actionRequired: false,
metadata: {
dealerData: dealerData,
completionData: {
activityCompletionDate: completionDetails ? (completionDetails as any).activityCompletionDate : undefined,
numberOfParticipants: completionDetails ? (completionDetails as any).numberOfParticipants : undefined,
totalClosedExpenses: completionDetails ? (completionDetails as any).totalClosedExpenses : 0,
closedExpenses: closedExpenses,
documentsCount: undefined, // Documents count can be retrieved from documents table if needed
submittedAt: completionDetails ? (completionDetails as any).submittedAt : new Date(),
nextApproverIsAdditional: isNextAdditionalApprover,
nextApproverIsInitiator: isNextApproverInitiator
},
nextApproverId: nextApproverData ? nextApproverData.userId : undefined
}
});
logger.info(`[DealerClaimApproval] Sent completion_submitted notification to initiator for Dealer Completion Documents. Next approver: ${isNextAdditionalApprover ? 'Additional Approver' : 'Step 5 (Requestor Claim Approval)'}`);
}
// Only send assignment notification to next approver if:
// 1. It's NOT a dealer proposal/completion step (those have special notifications above)
// 2. Next approver is NOT the initiator (to avoid duplicate notifications)
// 3. It's not a system/auto step
if (!isDealerProposalApproval && !isDealerCompletionApproval && !isNextApproverInitiator) {
if (!isAutoStep && !isSystemEmail && !isSystemName && nextApproverId && nextApproverId !== 'system') {
try {
logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
await notificationService.sendToUsers([ nextApproverId ], {
title: `Action required: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
logger.info(`[DealerClaimApproval] ✅ Assignment notification sent successfully to ${nextApproverName} (${nextApproverId}) for level ${nextLevelNumber}`);
// Log assignment activity for the next approver
await activityService.log({
requestId: level.requestId,
type: 'assignment',
user: { userId: level.approverId, name: level.approverName },
timestamp: new Date().toISOString(),
action: 'Assigned to approver',
details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
} catch (notifError) {
logger.error(`[DealerClaimApproval] ❌ Failed to send notification to next approver ${nextApproverId} at level ${nextLevelNumber}:`, notifError);
// Don't throw - continue with workflow even if notification fails
}
} else {
logger.info(`[DealerClaimApproval] ⚠️ Skipping notification for system/auto-step: ${nextApproverEmail} (${nextApproverId}) at level ${nextLevelNumber}`);
}
} else {
if (isDealerProposalApproval || isDealerCompletionApproval) {
logger.info(`[DealerClaimApproval] ⚠️ Skipping assignment notification - dealer-specific notification already sent`);
}
if (isNextApproverInitiator) {
logger.info(`[DealerClaimApproval] ⚠️ Skipping assignment notification - next approver is the initiator (already notified)`);
}
}
}
} else {

View File

@ -0,0 +1,333 @@
/**
* Dealer Claim Email Service
*
* Dedicated service for handling email template selection and sending
* for dealer claim workflows (CLAIM_MANAGEMENT).
*
* This service is separate from the main notification service to:
* - Isolate dealer claim-specific logic
* - Prevent breaking custom workflows
* - Handle dynamic step identification (by levelName, not levelNumber)
* - Support additional approvers between steps
*/
import { ApprovalLevel } from '@models/ApprovalLevel';
import { User } from '@models/User';
import logger from '@utils/logger';
import { IWorkflowEmailService } from './workflowEmail.interface';
import { emailNotificationService } from './emailNotification.service';
export class DealerClaimEmailService implements IWorkflowEmailService {
/**
* Determine and send the appropriate email template for dealer claim assignment notifications
* Handles:
* - Dealer Proposal Step (Step 1)
* - Dealer Completion Documents Step (Step 4)
* - Standard approval steps (Steps 2, 3, 5)
* - Additional approvers (always use standard template)
*/
async sendAssignmentEmail(
requestData: any,
approverUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null,
allLevels: ApprovalLevel[]
): Promise<void> {
try {
// SAFETY CHECK: Ensure this is actually a dealer claim workflow
// This prevents dealer-specific logic from being applied to custom workflows
const workflowType = requestData.workflowType || requestData.templateType || 'CUSTOM';
if (workflowType !== 'CLAIM_MANAGEMENT') {
logger.warn(`[DealerClaimEmail] ⚠️ Wrong workflow type (${workflowType}) - falling back to standard email. This service should only handle CLAIM_MANAGEMENT workflows.`);
// Fall back to standard approval email
const approverData = approverUser.toJSON();
if (currentLevel) {
(approverData as any).levelNumber = (currentLevel as any).levelNumber;
}
const isMultiLevel = allLevels.length > 1;
const { emailNotificationService } = await import('./emailNotification.service');
await emailNotificationService.sendApprovalRequest(
requestData,
approverData,
initiatorData,
isMultiLevel,
isMultiLevel ? allLevels.map((l: any) => l.toJSON()) : undefined
);
return;
}
if (!currentLevel) {
logger.warn(`[DealerClaimEmail] No current level found, sending standard approval email`);
await this.sendStandardApprovalEmail(requestData, approverUser, initiatorData, currentLevel);
return;
}
// Reload level from DB to ensure we have the latest levelName
const level = await ApprovalLevel.findByPk((currentLevel as any).levelId) || currentLevel;
const levelName = (level.levelName || '').toLowerCase().trim();
logger.info(`[DealerClaimEmail] Level: "${level.levelName}" (${level.levelNumber}), Approver: ${approverUser.email}`);
// Check if it's an additional approver (always use standard template)
// Additional approvers can have various levelName formats:
// - "Additional Approver" (from addApproverAtLevel)
// - "Additional Approver - Level X" (fallback)
// - "Additional Approver - ${designation}" (from addApproverAtLevel with designation)
// - Custom stepName from frontend (when isAdditional=true)
const isAdditionalApprover = levelName.includes('additional approver') ||
(levelName.includes('additional') && levelName.includes('approver'));
if (isAdditionalApprover) {
logger.info(`[DealerClaimEmail] ✅ Additional approver detected - sending standard approval email`);
await this.sendStandardApprovalEmail(requestData, approverUser, initiatorData, level);
return;
}
// SIMPLE DETECTION: Use levelName as the primary source of truth
// Level names are always set correctly:
// - "Dealer Proposal Submission" (Step 1)
// - "Dealer Completion Documents" (Step 4)
const isDealerProposalStep = levelName.includes('dealer') && levelName.includes('proposal');
const isDealerCompletionStep = levelName.includes('dealer') &&
(levelName.includes('completion') || levelName.includes('documents')) &&
!levelName.includes('proposal'); // Explicitly exclude proposal
// Safety check: If proposal already submitted, don't send proposal email
// This prevents sending proposal email if levelName somehow matches both conditions
if (isDealerProposalStep && requestData.requestId) {
try {
const { DealerProposalDetails } = await import('@models/DealerProposalDetails');
const existingProposal = await DealerProposalDetails.findOne({
where: { requestId: requestData.requestId }
});
if (existingProposal) {
logger.warn(`[DealerClaimEmail] ⚠️ Proposal already submitted but levelName indicates proposal step. Forcing completion step.`);
// If proposal exists, this MUST be completion step, not proposal
await this.sendDealerCompletionRequiredEmail(requestData, approverUser, initiatorData, level);
return;
}
} catch (e) {
logger.error(`[DealerClaimEmail] Error checking proposal:`, e);
// Continue with normal flow if check fails
}
}
// Route to appropriate template
if (isDealerCompletionStep) {
logger.info(`[DealerClaimEmail] ✅ DEALER COMPLETION step - sending completion documents required email`);
await this.sendDealerCompletionRequiredEmail(requestData, approverUser, initiatorData, level);
} else if (isDealerProposalStep) {
logger.info(`[DealerClaimEmail] ✅ DEALER PROPOSAL step - sending proposal required email`);
await this.sendDealerProposalRequiredEmail(requestData, approverUser, initiatorData, level);
} else {
logger.info(`[DealerClaimEmail] ✅ STANDARD approval step - sending standard approval email`);
await this.sendStandardApprovalEmail(requestData, approverUser, initiatorData, level);
}
} catch (error) {
logger.error(`[DealerClaimEmail] Error sending assignment email:`, error);
throw error;
}
}
/**
* Send dealer proposal required email
*/
private async sendDealerProposalRequiredEmail(
requestData: any,
dealerUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null
): Promise<void> {
logger.info(`[DealerClaimEmail] Sending dealer proposal required email to ${dealerUser.email}`);
// Get claim details for dealer-specific data
const { DealerClaimDetails } = await import('@models/DealerClaimDetails');
const claimDetails = await DealerClaimDetails.findOne({
where: { requestId: requestData.requestId }
});
const claimData = claimDetails ? (claimDetails as any).toJSON() : {};
await emailNotificationService.sendDealerProposalRequired(
requestData,
dealerUser.toJSON(),
initiatorData,
{
activityName: claimData.activityName || requestData.title,
activityType: claimData.activityType || 'N/A',
activityDate: claimData.activityDate,
location: claimData.location,
estimatedBudget: claimData.estimatedBudget,
dealerName: claimData.dealerName,
tatHours: currentLevel ? (currentLevel as any).tatHours : undefined
}
);
}
/**
* Send dealer completion documents required email
*/
private async sendDealerCompletionRequiredEmail(
requestData: any,
dealerUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null
): Promise<void> {
logger.info(`[DealerClaimEmail] Sending dealer completion documents required email to ${dealerUser.email}`);
// Get claim details for dealer-specific data
const { DealerClaimDetails } = await import('@models/DealerClaimDetails');
const claimDetails = await DealerClaimDetails.findOne({
where: { requestId: requestData.requestId }
});
const claimData = claimDetails ? (claimDetails as any).toJSON() : {};
// Use dedicated completion documents required template
await emailNotificationService.sendDealerCompletionRequired(
requestData,
dealerUser.toJSON(),
initiatorData,
{
activityName: claimData.activityName || requestData.title,
activityType: claimData.activityType || 'N/A',
activityDate: claimData.activityDate,
location: claimData.location,
estimatedBudget: claimData.estimatedBudget,
dealerName: claimData.dealerName,
tatHours: currentLevel ? (currentLevel as any).tatHours : undefined
}
);
}
/**
* Send standard approval email (single approver template)
* For dealer claim workflows, enrich with dealer claim-specific details
*/
private async sendStandardApprovalEmail(
requestData: any,
approverUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null
): Promise<void> {
logger.info(`[DealerClaimEmail] Sending enhanced approval email to ${approverUser.email}`);
// Get dealer claim details to enrich the email
const { DealerClaimDetails } = await import('@models/DealerClaimDetails');
const { DealerProposalDetails } = await import('@models/DealerProposalDetails');
const claimDetails = await DealerClaimDetails.findOne({
where: { requestId: requestData.requestId }
});
const proposalDetails = await DealerProposalDetails.findOne({
where: { requestId: requestData.requestId }
});
// Enrich requestData with dealer claim-specific information
const enrichedRequestData = {
...requestData,
// Add dealer claim details to description if not already present
description: this.enrichDescriptionWithClaimDetails(
requestData.description || '',
claimDetails,
proposalDetails
),
// Add activity information
activityName: claimDetails ? (claimDetails as any).activityName : undefined,
activityType: claimDetails ? (claimDetails as any).activityType : undefined,
dealerName: claimDetails ? (claimDetails as any).dealerName : undefined,
dealerCode: claimDetails ? (claimDetails as any).dealerCode : undefined,
location: claimDetails ? (claimDetails as any).location : undefined,
proposalBudget: proposalDetails ? (proposalDetails as any).totalEstimatedBudget : undefined
};
const approverData = approverUser.toJSON();
// Add level number if available
if (currentLevel) {
(approverData as any).levelNumber = (currentLevel as any).levelNumber;
}
// Always use single approver template for dealer claim workflows
// (not multi-level, even if there are multiple steps)
await emailNotificationService.sendApprovalRequest(
enrichedRequestData,
approverData,
initiatorData,
false, // isMultiLevel = false for dealer claim workflows
undefined // No approval chain needed
);
}
/**
* Enrich request description with dealer claim-specific details
*/
private enrichDescriptionWithClaimDetails(
existingDescription: string,
claimDetails: any,
proposalDetails: any
): string {
if (!claimDetails) {
return existingDescription;
}
const claimData = (claimDetails as any).toJSON();
let enrichedDescription = existingDescription || '';
// Add dealer claim details section if not already present
const detailsSection = `
<div style="margin-top: 20px; padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Claim Details:</h3>
<table style="width: 100%; border-collapse: collapse;">
${claimData.activityName ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;"><strong>Activity Name:</strong></td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">${claimData.activityName}</td>
</tr>
` : ''}
${claimData.activityType ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"><strong>Activity Type:</strong></td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">${claimData.activityType}</td>
</tr>
` : ''}
${claimData.dealerName ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"><strong>Dealer:</strong></td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">${claimData.dealerName}${claimData.dealerCode ? ` (${claimData.dealerCode})` : ''}</td>
</tr>
` : ''}
${claimData.location ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"><strong>Location:</strong></td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">${claimData.location}</td>
</tr>
` : ''}
${claimData.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"><strong>Activity Date:</strong></td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">${new Date(claimData.activityDate).toLocaleDateString('en-IN', { year: 'numeric', month: 'long', day: 'numeric' })}</td>
</tr>
` : ''}
${proposalDetails && (proposalDetails as any).totalEstimatedBudget ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"><strong>Proposed Budget:</strong></td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">${Number((proposalDetails as any).totalEstimatedBudget).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
</tr>
` : ''}
</table>
</div>
`;
// Append details section if not already in description
if (!enrichedDescription.includes('Claim Details:') && !enrichedDescription.includes('Activity Name:')) {
enrichedDescription += detailsSection;
}
return enrichedDescription;
}
}
export const dealerClaimEmailService = new DealerClaimEmailService();

View File

@ -0,0 +1,335 @@
import { WorkflowRequest } from '@models/WorkflowRequest';
import { DealerClaimDetails } from '@models/DealerClaimDetails';
import { ClaimCreditNote } from '@models/ClaimCreditNote';
import { DealerProposalDetails } from '@models/DealerProposalDetails';
import { ClaimBudgetTracking } from '@models/ClaimBudgetTracking';
import { Op, QueryTypes } from 'sequelize';
import { sequelize } from '@config/database';
import dayjs from 'dayjs';
import logger from '@utils/logger';
import { User } from '@models/User';
interface DateRangeFilter {
start: Date;
end: Date;
}
interface DashboardKPIs {
totalClaims: number;
totalValue: number;
approved: number;
rejected: number;
pending: number;
credited: number;
pendingCredit: number;
approvedValue: number;
rejectedValue: number;
pendingValue: number;
creditedValue: number;
pendingCreditValue: number;
}
interface CategoryData {
activityType: string;
raised: number;
raisedValue: number;
approved: number;
approvedValue: number;
rejected: number;
rejectedValue: number;
pending: number;
pendingValue: number;
credited: number;
creditedValue: number;
pendingCredit: number;
pendingCreditValue: number;
approvalRate: number;
creditRate: number;
}
export class DealerDashboardService {
/**
* Parse date range string to Date objects
*/
private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): DateRangeFilter {
if (dateRange === 'custom' && startDate && endDate) {
const start = dayjs(startDate).startOf('day').toDate();
const end = dayjs(endDate).endOf('day').toDate();
const now = dayjs();
const actualEnd = end > now.toDate() ? now.endOf('day').toDate() : end;
return { start, end: actualEnd };
}
if (dateRange === 'custom' && (!startDate || !endDate)) {
const now = dayjs();
return {
start: now.subtract(30, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate()
};
}
const now = dayjs();
switch (dateRange) {
case 'today':
return {
start: now.startOf('day').toDate(),
end: now.endOf('day').toDate()
};
case 'week':
return {
start: now.startOf('week').toDate(),
end: now.endOf('week').toDate()
};
case 'month':
return {
start: now.startOf('month').toDate(),
end: now.endOf('month').toDate()
};
case 'quarter':
const currentMonth = now.month();
const quarterStartMonth = Math.floor(currentMonth / 3) * 3;
return {
start: now.month(quarterStartMonth).startOf('month').toDate(),
end: now.month(quarterStartMonth + 2).endOf('month').toDate()
};
case 'year':
return {
start: now.startOf('year').toDate(),
end: now.endOf('year').toDate()
};
default:
return {
start: now.subtract(30, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate()
};
}
}
/**
* Get dealer email from user email or user ID
*/
private async getDealerEmail(userEmail?: string, userId?: string): Promise<string | null> {
try {
if (userEmail) {
// Check if user email matches a dealer email in dealer_claim_details
const dealerClaim = await DealerClaimDetails.findOne({
where: {
dealerEmail: { [Op.iLike]: userEmail.toLowerCase() }
},
limit: 1
});
if (dealerClaim) {
return dealerClaim.dealerEmail?.toLowerCase() || null;
}
}
if (userId) {
// Get user email from userId
const user = await User.findByPk(userId);
if (user?.email) {
const dealerClaim = await DealerClaimDetails.findOne({
where: {
dealerEmail: { [Op.iLike]: user.email.toLowerCase() }
},
limit: 1
});
if (dealerClaim) {
return dealerClaim.dealerEmail?.toLowerCase() || null;
}
}
}
return null;
} catch (error) {
logger.error('[DealerDashboard] Error getting dealer email:', error);
return null;
}
}
/**
* Get dashboard KPIs for dealer
*/
async getDashboardKPIs(
userEmail?: string,
userId?: string,
dateRange?: string,
startDate?: string,
endDate?: string
): Promise<{ kpis: DashboardKPIs; categoryData: CategoryData[] }> {
try {
const dealerEmail = await this.getDealerEmail(userEmail, userId);
if (!dealerEmail) {
logger.warn('[DealerDashboard] No dealer email found for user');
return {
kpis: {
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
},
categoryData: []
};
}
const applyDateRange = dateRange !== undefined && dateRange !== null && dateRange !== 'all';
const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null;
// Build date filter
const dateFilter = applyDateRange && range
? `AND (
(wf.submission_date BETWEEN :start AND :end AND wf.submission_date IS NOT NULL)
OR (wf.submission_date IS NULL AND wf.created_at BETWEEN :start AND :end)
)`
: `1=1`;
const replacements: any = { dealerEmail: dealerEmail.toLowerCase() };
if (applyDateRange && range) {
replacements.start = range.start;
replacements.end = range.end;
}
// Get all dealer claims with their details
// Filter by both workflow_type and template_type for compatibility
const claimsQuery = `
SELECT
wf.request_id,
wf.status,
dcd.activity_type,
COALESCE(dpd.total_estimated_budget, cbt.proposal_estimated_budget, 0)::numeric AS estimated_budget,
COALESCE(cbt.approved_budget, cbt.proposal_estimated_budget, dpd.total_estimated_budget, 0)::numeric AS approved_budget,
cbt.final_claim_amount::numeric AS final_claim_amount,
ccn.credit_note_number,
ccn.credit_note_date,
ccn.credit_amount::numeric AS credit_note_amount
FROM workflow_requests wf
INNER JOIN dealer_claim_details dcd ON wf.request_id = dcd.request_id
LEFT JOIN dealer_proposal_details dpd ON wf.request_id = dpd.request_id
LEFT JOIN claim_budget_tracking cbt ON wf.request_id = cbt.request_id
LEFT JOIN claim_credit_notes ccn ON wf.request_id = ccn.request_id
WHERE (wf.workflow_type = 'CLAIM_MANAGEMENT' OR wf.template_type = 'DEALER CLAIM')
AND wf.is_draft = false
AND (wf.is_deleted IS NULL OR wf.is_deleted = false)
AND dcd.dealer_email ILIKE :dealerEmail
AND ${dateFilter}
`;
const claims = await sequelize.query(claimsQuery, {
replacements,
type: QueryTypes.SELECT
}) as any[];
// Calculate KPIs
const kpis: DashboardKPIs = {
totalClaims: claims.length,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
};
// Group by category
const categoryMap = new Map<string, CategoryData>();
for (const claim of claims) {
const activityType = claim.activity_type || 'Unknown';
const status = (claim.status || '').toUpperCase();
const estimatedBudget = parseFloat(claim.estimated_budget || 0);
const approvedBudget = parseFloat(claim.approved_budget || estimatedBudget);
const finalClaimAmount = parseFloat(claim.final_claim_amount || approvedBudget);
const hasCreditNote = !!(claim.credit_note_number && claim.credit_note_date);
const creditNoteAmount = parseFloat(claim.credit_note_amount || finalClaimAmount);
// Initialize category if not exists
if (!categoryMap.has(activityType)) {
categoryMap.set(activityType, {
activityType,
raised: 0,
raisedValue: 0,
approved: 0,
approvedValue: 0,
rejected: 0,
rejectedValue: 0,
pending: 0,
pendingValue: 0,
credited: 0,
creditedValue: 0,
pendingCredit: 0,
pendingCreditValue: 0,
approvalRate: 0,
creditRate: 0,
});
}
const category = categoryMap.get(activityType)!;
// Count and values by status
category.raised++;
category.raisedValue += estimatedBudget;
kpis.totalValue += estimatedBudget;
if (status === 'APPROVED' || status === 'CLOSED') {
category.approved++;
category.approvedValue += approvedBudget;
kpis.approved++;
kpis.approvedValue += approvedBudget;
if (hasCreditNote) {
category.credited++;
category.creditedValue += creditNoteAmount;
kpis.credited++;
kpis.creditedValue += creditNoteAmount;
} else {
category.pendingCredit++;
category.pendingCreditValue += finalClaimAmount;
kpis.pendingCredit++;
kpis.pendingCreditValue += finalClaimAmount;
}
} else if (status === 'REJECTED') {
category.rejected++;
category.rejectedValue += estimatedBudget;
kpis.rejected++;
kpis.rejectedValue += estimatedBudget;
} else if (status === 'PENDING' || status === 'IN_PROGRESS') {
category.pending++;
category.pendingValue += estimatedBudget;
kpis.pending++;
kpis.pendingValue += estimatedBudget;
}
}
// Calculate rates for each category
const categoryData = Array.from(categoryMap.values()).map(cat => {
cat.approvalRate = cat.raised > 0 ? (cat.approved / cat.raised) * 100 : 0;
cat.creditRate = cat.approved > 0 ? (cat.credited / cat.approved) * 100 : 0;
return cat;
});
return {
kpis,
categoryData
};
} catch (error) {
logger.error('[DealerDashboard] Error fetching dashboard KPIs:', error);
throw error;
}
}
}
export const dealerDashboardService = new DealerDashboardService();

View File

@ -17,8 +17,16 @@ import {
getWorkflowPausedEmail,
getWorkflowResumedEmail,
getParticipantAddedEmail,
getSpectatorAddedEmail,
getApproverSkippedEmail,
getRequestClosedEmail,
getDealerProposalSubmittedEmail,
getDealerProposalRequiredEmail,
getDealerCompletionRequiredEmail,
getActivityCreatedEmail,
getCompletionDocumentsSubmittedEmail,
getEInvoiceGeneratedEmail,
getCreditNoteSentEmail,
getViewDetailsLink,
CompanyInfo,
RequestCreatedData,
@ -31,8 +39,15 @@ import {
WorkflowPausedData,
WorkflowResumedData,
ParticipantAddedData,
SpectatorAddedData,
ApproverSkippedData,
RequestClosedData,
DealerProposalSubmittedData,
DealerProposalRequiredData,
ActivityCreatedData,
CompletionDocumentsSubmittedData,
EInvoiceGeneratedData,
CreditNoteSentData,
ApprovalChainItem
} from '../emailtemplates';
import {
@ -155,6 +170,7 @@ export class EmailNotificationService {
const data: MultiApproverRequestData = {
recipientName: approverData.displayName || approverData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
approverName: approverData.displayName || approverData.email,
initiatorName: initiatorData.displayName || initiatorData.email,
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
@ -186,6 +202,7 @@ export class EmailNotificationService {
const data: ApprovalRequestData = {
recipientName: approverData.displayName || approverData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
approverName: approverData.displayName || approverData.email,
initiatorName: initiatorData.displayName || initiatorData.email,
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
@ -810,7 +827,551 @@ export class EmailNotificationService {
}
}
// Add more email methods as needed...
/**
* 11. Send Spectator Added Email
*/
async sendSpectatorAdded(
requestData: any,
spectatorData: any,
addedByData?: any,
initiatorData?: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
spectatorData.userId,
EmailNotificationType.SPECTATOR_ADDED
);
if (!canSend) {
logger.info(`Email skipped (preferences): Spectator Added for ${spectatorData.email}`);
return;
}
// Get initiator name
let initiatorName = 'Initiator';
if (initiatorData) {
initiatorName = initiatorData.displayName || initiatorData.email || 'Initiator';
} else if (requestData.initiatorId) {
try {
const { User } = await import('@models/index');
const initiator = await User.findByPk(requestData.initiatorId);
if (initiator) {
const initiatorJson = initiator.toJSON();
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
}
} catch (error) {
logger.warn(`Failed to fetch initiator for spectator added email: ${error}`);
}
}
// Get added by name
let addedByName: string | undefined;
if (addedByData) {
addedByName = addedByData.displayName || addedByData.email;
}
// Get participant to check when they were added
const { Participant } = await import('@models/index');
const participant = await Participant.findOne({
where: {
requestId: requestData.requestId,
userId: spectatorData.userId
}
});
const addedDate = participant ? this.formatDate((participant as any).addedAt || new Date()) : this.formatDate(new Date());
const addedTime = participant ? this.formatTime((participant as any).addedAt || new Date()) : this.formatTime(new Date());
const data: SpectatorAddedData = {
recipientName: spectatorData.displayName || spectatorData.email,
spectatorName: spectatorData.displayName || spectatorData.email,
addedByName: addedByName,
initiatorName: initiatorName,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
requestType: requestData.templateType || requestData.workflowType || undefined,
currentStatus: requestData.status || undefined,
addedDate: addedDate,
addedTime: addedTime,
requestDescription: requestData.description || undefined,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getSpectatorAddedEmail(data);
const subject = `[${requestData.requestNumber}] Added as Spectator`;
const result = await emailService.sendEmail({
to: spectatorData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Spectator Added Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Spectator Added email sent to ${spectatorData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Spectator Added email:`, error);
throw error;
}
}
/**
* 12. Send Dealer Proposal Required Email
*/
async sendDealerProposalRequired(
requestData: any,
dealerData: any,
initiatorData: any,
claimData?: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
dealerData.userId,
EmailNotificationType.APPROVAL_REQUEST // Use approval_request type for preferences
);
if (!canSend) {
logger.info(`Email skipped (preferences): Dealer Proposal Required for ${dealerData.email}`);
return;
}
// Calculate due date from TAT if available
let dueDate: string | undefined;
if (claimData?.tatHours) {
const dueDateObj = dayjs().add(claimData.tatHours, 'hour');
dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A');
}
const data: DealerProposalRequiredData = {
recipientName: dealerData.displayName || dealerData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
dealerName: dealerData.displayName || dealerData.email || claimData?.dealerName || 'Dealer',
initiatorName: initiatorData.displayName || initiatorData.email,
activityName: claimData?.activityName || requestData.title,
activityType: claimData?.activityType || 'N/A',
activityDate: claimData?.activityDate ? this.formatDate(claimData.activityDate) : undefined,
location: claimData?.location,
estimatedBudget: claimData?.estimatedBudget,
requestDate: this.formatDate(requestData.createdAt),
requestTime: this.formatTime(requestData.createdAt),
requestDescription: requestData.description || '',
priority: requestData.priority || 'MEDIUM',
tatHours: claimData?.tatHours,
dueDate: dueDate,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getDealerProposalRequiredEmail(data);
const subject = `[${requestData.requestNumber}] Proposal Required - ${data.activityName}`;
const result = await emailService.sendEmail({
to: dealerData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Dealer Proposal Required Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Dealer Proposal Required email sent to ${dealerData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Dealer Proposal Required email:`, error);
throw error;
}
}
/**
* 12b. Send Dealer Completion Documents Required Email
*/
async sendDealerCompletionRequired(
requestData: any,
dealerData: any,
initiatorData: any,
claimData?: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
dealerData.userId,
EmailNotificationType.APPROVAL_REQUEST // Use approval_request type for preferences
);
if (!canSend) {
logger.info(`Email skipped (preferences): Dealer Completion Required for ${dealerData.email}`);
return;
}
// Calculate due date from TAT if available
let dueDate: string | undefined;
if (claimData?.tatHours) {
const dueDateObj = dayjs().add(claimData.tatHours, 'hour');
dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A');
}
const data: DealerProposalRequiredData = {
recipientName: dealerData.displayName || dealerData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
dealerName: dealerData.displayName || dealerData.email || claimData?.dealerName || 'Dealer',
initiatorName: initiatorData.displayName || initiatorData.email,
activityName: claimData?.activityName || requestData.title,
activityType: claimData?.activityType || 'N/A',
activityDate: claimData?.activityDate ? this.formatDate(claimData.activityDate) : undefined,
location: claimData?.location,
estimatedBudget: claimData?.estimatedBudget,
requestDate: this.formatDate(requestData.createdAt),
requestTime: this.formatTime(requestData.createdAt),
requestDescription: requestData.description || '',
priority: requestData.priority || 'MEDIUM',
tatHours: claimData?.tatHours,
dueDate: dueDate,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getDealerCompletionRequiredEmail(data);
const subject = `[${requestData.requestNumber}] Completion Documents Required - ${data.activityName}`;
const result = await emailService.sendEmail({
to: dealerData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Dealer Completion Required Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Dealer Completion Required email sent to ${dealerData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Dealer Completion Required email:`, error);
throw error;
}
}
/**
* 13. Send Dealer Proposal Submitted Email
*/
async sendDealerProposalSubmitted(
requestData: any,
dealerData: any,
recipientData: any,
proposalData: any,
nextApproverData?: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.DEALER_PROPOSAL_SUBMITTED
);
if (!canSend) {
logger.info(`Email skipped (preferences): Dealer Proposal Submitted for ${recipientData.email}`);
return;
}
// Format cost breakdown summary if available
let costBreakupSummary: string | undefined;
if (proposalData.costBreakup && Array.isArray(proposalData.costBreakup) && proposalData.costBreakup.length > 0) {
costBreakupSummary = '<table style="width: 100%; border-collapse: collapse;"><thead><tr style="background-color: #f8f9fa;"><th style="padding: 10px; text-align: left; border-bottom: 2px solid #e9ecef;">Description</th><th style="padding: 10px; text-align: right; border-bottom: 2px solid #e9ecef;">Amount</th></tr></thead><tbody>';
proposalData.costBreakup.forEach((item: any) => {
costBreakupSummary += `<tr><td style="padding: 8px; border-bottom: 1px solid #f0f0f0;">${item.description || ''}</td><td style="padding: 8px; text-align: right; border-bottom: 1px solid #f0f0f0;">₹${(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td></tr>`;
});
costBreakupSummary += '</tbody></table>';
}
// Check if next approver is the recipient (initiator reviewing their own request)
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId);
const data: DealerProposalSubmittedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
dealerName: dealerData.displayName || dealerData.email || dealerData.name,
activityName: requestData.activityName || requestData.title,
activityType: requestData.activityType || 'N/A',
proposalBudget: proposalData.totalEstimatedBudget || proposalData.proposalBudget || 0,
expectedCompletionDate: proposalData.expectedCompletionDate || 'Not specified',
dealerComments: proposalData.dealerComments,
costBreakupSummary: costBreakupSummary,
submittedDate: this.formatDate(proposalData.submittedAt || new Date()),
submittedTime: this.formatTime(proposalData.submittedAt || new Date()),
nextApproverName: isNextApproverInitiator
? undefined // Don't show next approver name if it's the recipient themselves
: (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getDealerProposalSubmittedEmail(data);
const subject = `[${requestData.requestNumber}] Proposal Submitted - ${data.activityName}`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Dealer Proposal Submitted Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Dealer Proposal Submitted email sent to ${recipientData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Dealer Proposal Submitted email:`, error);
throw error;
}
}
/**
* 14. Send Activity Created Email
*/
async sendActivityCreated(
requestData: any,
recipientData: any,
activityData: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.ACTIVITY_CREATED
);
if (!canSend) {
logger.info(`Email skipped (preferences): Activity Created for ${recipientData.email}`);
return;
}
const data: ActivityCreatedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
activityName: activityData.activityName || requestData.title,
activityType: activityData.activityType || 'N/A',
activityDate: activityData.activityDate ? this.formatDate(activityData.activityDate) : undefined,
location: activityData.location || 'Not specified',
dealerName: activityData.dealerName || 'Dealer',
dealerCode: activityData.dealerCode,
initiatorName: activityData.initiatorName || 'Initiator',
departmentLeadName: activityData.departmentLeadName,
ioNumber: activityData.ioNumber,
createdDate: this.formatDate(new Date()),
createdTime: this.formatTime(new Date()),
nextSteps: activityData.nextSteps || 'IO confirmation to be made. Dealer will proceed with activity execution.',
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getActivityCreatedEmail(data);
const subject = `[${requestData.requestNumber}] Activity Created - ${data.activityName}`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Activity Created Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Activity Created email sent to ${recipientData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Activity Created email:`, error);
throw error;
}
}
/**
* 15. Send Completion Documents Submitted Email
*/
async sendCompletionDocumentsSubmitted(
requestData: any,
dealerData: any,
recipientData: any,
completionData: any,
nextApproverData?: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.COMPLETION_DOCUMENTS_SUBMITTED
);
if (!canSend) {
logger.info(`Email skipped (preferences): Completion Documents Submitted for ${recipientData.email}`);
return;
}
// Format expense breakdown summary if available
let expenseBreakdown: string | undefined;
if (completionData.closedExpenses && Array.isArray(completionData.closedExpenses) && completionData.closedExpenses.length > 0) {
expenseBreakdown = '<table style="width: 100%; border-collapse: collapse;"><thead><tr style="background-color: #f8f9fa;"><th style="padding: 10px; text-align: left; border-bottom: 2px solid #e9ecef;">Description</th><th style="padding: 10px; text-align: right; border-bottom: 2px solid #e9ecef;">Amount</th></tr></thead><tbody>';
completionData.closedExpenses.forEach((item: any) => {
expenseBreakdown += `<tr><td style="padding: 8px; border-bottom: 1px solid #f0f0f0;">${item.description || ''}</td><td style="padding: 8px; text-align: right; border-bottom: 1px solid #f0f0f0;">₹${(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td></tr>`;
});
expenseBreakdown += '</tbody></table>';
}
// Check if next approver is the recipient (initiator reviewing their own request)
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId);
const data: CompletionDocumentsSubmittedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
dealerName: dealerData.displayName || dealerData.email || dealerData.name,
activityName: requestData.activityName || requestData.title,
activityCompletionDate: completionData.activityCompletionDate ? this.formatDate(completionData.activityCompletionDate) : 'Not specified',
numberOfParticipants: completionData.numberOfParticipants,
totalClosedExpenses: completionData.totalClosedExpenses || 0,
expenseBreakdown: expenseBreakdown,
documentsCount: completionData.documentsCount,
submittedDate: this.formatDate(completionData.submittedAt || new Date()),
submittedTime: this.formatTime(completionData.submittedAt || new Date()),
nextApproverName: isNextApproverInitiator
? undefined // Don't show next approver name if it's the recipient themselves
: (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getCompletionDocumentsSubmittedEmail(data);
const subject = `[${requestData.requestNumber}] Completion Documents Submitted - ${data.activityName}`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Completion Documents Submitted Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Completion Documents Submitted email sent to ${recipientData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Completion Documents Submitted email:`, error);
throw error;
}
}
/**
* 16. Send E-Invoice Generated Email
*/
async sendEInvoiceGenerated(
requestData: any,
recipientData: any,
invoiceData: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.EINVOICE_GENERATED
);
if (!canSend) {
logger.info(`Email skipped (preferences): E-Invoice Generated for ${recipientData.email}`);
return;
}
const data: EInvoiceGeneratedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
invoiceNumber: invoiceData.invoiceNumber || invoiceData.eInvoiceNumber || 'N/A',
invoiceDate: invoiceData.invoiceDate ? this.formatDate(invoiceData.invoiceDate) : this.formatDate(new Date()),
dmsNumber: invoiceData.dmsNumber,
invoiceAmount: invoiceData.amount || invoiceData.invoiceAmount || 0,
dealerName: invoiceData.dealerName || requestData.dealerName || 'Dealer',
dealerCode: invoiceData.dealerCode || requestData.dealerCode,
activityName: requestData.activityName || requestData.title,
ioNumber: invoiceData.ioNumber || requestData.ioNumber,
generatedDate: this.formatDate(invoiceData.generatedAt || new Date()),
generatedTime: this.formatTime(invoiceData.generatedAt || new Date()),
downloadLink: invoiceData.downloadLink,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getEInvoiceGeneratedEmail(data);
const subject = `[${requestData.requestNumber}] E-Invoice Generated - ${data.invoiceNumber}`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 E-Invoice Generated Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ E-Invoice Generated email sent to ${recipientData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send E-Invoice Generated email:`, error);
throw error;
}
}
/**
* 17. Send Credit Note Sent Email
*/
async sendCreditNoteSent(
requestData: any,
recipientData: any,
creditNoteData: any
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.CREDIT_NOTE_SENT
);
if (!canSend) {
logger.info(`Email skipped (preferences): Credit Note Sent for ${recipientData.email}`);
return;
}
const data: CreditNoteSentData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
requestNumber: requestData.requestNumber,
creditNoteNumber: creditNoteData.creditNoteNumber || 'N/A',
creditNoteDate: creditNoteData.creditNoteDate ? this.formatDate(creditNoteData.creditNoteDate) : this.formatDate(new Date()),
creditNoteAmount: creditNoteData.creditNoteAmount || 0,
dealerName: creditNoteData.dealerName || requestData.dealerName || 'Dealer',
dealerCode: creditNoteData.dealerCode || requestData.dealerCode,
dealerEmail: creditNoteData.dealerEmail || requestData.dealerEmail || '',
activityName: requestData.activityName || requestData.title,
reason: creditNoteData.reason || 'Claim settlement',
invoiceNumber: creditNoteData.invoiceNumber,
sentDate: this.formatDate(creditNoteData.sentAt || new Date()),
sentTime: this.formatTime(creditNoteData.sentAt || new Date()),
downloadLink: creditNoteData.downloadLink,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getCreditNoteSentEmail(data);
const subject = `[${requestData.requestNumber}] Credit Note Sent - ${data.creditNoteNumber}`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Credit Note Sent Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Credit Note Sent email sent to ${recipientData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Credit Note Sent email:`, error);
throw error;
}
}
}
// Singleton instance

View File

@ -298,6 +298,13 @@ class NotificationService {
'summary_generated': null,
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
'spectator_added': EmailNotificationType.SPECTATOR_ADDED,
// Dealer Claim Specific
'proposal_submitted': EmailNotificationType.DEALER_PROPOSAL_SUBMITTED,
'activity_created': EmailNotificationType.ACTIVITY_CREATED,
'completion_submitted': EmailNotificationType.COMPLETION_DOCUMENTS_SUBMITTED,
'einvoice_generated': EmailNotificationType.EINVOICE_GENERATED,
'credit_note_sent': EmailNotificationType.CREDIT_NOTE_SENT,
'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
'pause_retriggered': null
};
@ -420,23 +427,51 @@ class NotificationService {
where: { requestId: payload.requestId },
order: [['levelNumber', 'ASC']]
});
const isMultiLevel = allLevels.length > 1;
const approverData = approverUser.toJSON();
// Find the level that matches this approver
const matchingLevel = allLevels.find((l: any) => l.approverId === userId);
// Add level number if available
const currentLevel = allLevels.find((l: any) => l.approverId === userId);
if (currentLevel) {
(approverData as any).levelNumber = (currentLevel as any).levelNumber;
// Always reload from DB to ensure we have fresh levelName
const currentLevel = matchingLevel
? (await ApprovalLevel.findByPk((matchingLevel as any).levelId) || matchingLevel as any)
: null;
const workflowType = requestData.workflowType || 'CUSTOM';
logger.info(`[Email] Assignment - workflowType: ${workflowType}, approver: ${approverUser.email}, level: "${(currentLevel as any)?.levelName || 'N/A'}" (${(currentLevel as any)?.levelNumber || 'N/A'})`);
// Use factory to get the appropriate email service
const { workflowEmailServiceFactory } = await import('./workflowEmail.factory');
const workflowEmailService = workflowEmailServiceFactory.getService(workflowType);
if (workflowEmailService && workflowEmailServiceFactory.hasDedicatedService(workflowType)) {
// Use workflow-specific email service
await workflowEmailService.sendAssignmentEmail(
requestData,
approverUser,
initiatorData,
currentLevel,
allLevels
);
} else {
// Custom workflow or unknown type - use standard logic
const isMultiLevel = allLevels.length > 1;
const approverData = approverUser.toJSON();
// Add level number if available
if (currentLevel) {
(approverData as any).levelNumber = (currentLevel as any).levelNumber;
}
await emailNotificationService.sendApprovalRequest(
requestData,
approverData,
initiatorData,
isMultiLevel,
isMultiLevel ? allLevels.map((l: any) => l.toJSON()) : undefined
);
}
await emailNotificationService.sendApprovalRequest(
requestData,
approverData,
initiatorData,
isMultiLevel,
isMultiLevel ? allLevels.map((l: any) => l.toJSON()) : undefined
);
}
break;
@ -458,8 +493,24 @@ class NotificationService {
const approvedCount = allLevels.filter((l: any) => l.status === 'APPROVED').length;
const isFinalApproval = approvedCount === allLevels.length;
// Find next level - get the first PENDING level (handles dynamic approvers)
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
// Get next approver user data
let nextApprover = null;
if (nextLevel) {
const nextApproverUser = await User.findByPk((nextLevel as any).approverId);
if (nextApproverUser) {
nextApprover = nextApproverUser.toJSON();
} else {
// Fallback: use approverName/approverEmail from level if User not found
nextApprover = {
userId: (nextLevel as any).approverId,
displayName: (nextLevel as any).approverName || (nextLevel as any).approverEmail,
email: (nextLevel as any).approverEmail
};
}
}
// Get the approver who just approved from the approved level
let approverData = user; // Fallback to user if we can't find the approver
@ -473,12 +524,22 @@ class NotificationService {
}
}
// Skip sending approval confirmation email if the approver is the initiator
// (they don't need to be notified that they approved their own request)
const approverId = (approverData as any).userId || (approvedLevel as any)?.approverId;
const isApproverInitiator = approverId && initiatorData.userId && approverId === initiatorData.userId;
if (isApproverInitiator) {
logger.info(`[Email] Skipping approval confirmation email - approver is the initiator (${approverId})`);
return;
}
await emailNotificationService.sendApprovalConfirmation(
requestData,
approverData, // Approver who just approved
initiatorData,
isFinalApproval,
nextApprover ? nextApprover.toJSON() : undefined
nextApprover // Next approver data
);
}
break;
@ -857,6 +918,165 @@ class NotificationService {
}
break;
case 'spectator_added':
{
// Get the spectator user (the one being added)
const spectatorUser = await User.findByPk(userId);
if (!spectatorUser) {
logger.warn(`[Email] Spectator user ${userId} not found`);
return;
}
// Get the user who added the spectator (if available in metadata)
const addedByUserId = payload.metadata?.addedBy;
const addedByUser = addedByUserId ? await User.findByPk(addedByUserId) : null;
await emailNotificationService.sendSpectatorAdded(
requestData,
spectatorUser.toJSON(),
addedByUser ? addedByUser.toJSON() : undefined,
initiatorData
);
}
break;
case 'proposal_submitted':
{
// Get dealer and proposal data from metadata
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const proposalData = payload.metadata?.proposalData || {};
// Get activity information from metadata (not from requestData as it doesn't have these fields)
const activityName = payload.metadata?.activityName || requestData.title;
const activityType = payload.metadata?.activityType || 'N/A';
// Add activity info to requestData for the email template
const requestDataWithActivity = {
...requestData,
activityName: activityName,
activityType: activityType
};
// Get next approver if available
const nextApproverId = payload.metadata?.nextApproverId;
const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null;
// Check if next approver is the recipient (initiator)
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApprover && nextApprover.userId === userId);
await emailNotificationService.sendDealerProposalSubmitted(
requestDataWithActivity,
dealerData,
user.toJSON(),
{
...proposalData,
nextApproverIsInitiator: isNextApproverInitiator
},
nextApprover && !isNextApproverInitiator ? nextApprover.toJSON() : undefined
);
}
break;
case 'activity_created':
{
// Get activity data from metadata (should be provided by processActivityCreation)
const activityData = payload.metadata?.activityData || {
activityName: requestData.title,
activityType: 'N/A',
activityDate: payload.metadata?.activityDate,
location: payload.metadata?.location || 'Not specified',
dealerName: payload.metadata?.dealerName || 'Dealer',
dealerCode: payload.metadata?.dealerCode,
initiatorName: initiatorData.displayName || initiatorData.email,
departmentLeadName: payload.metadata?.departmentLeadName,
ioNumber: payload.metadata?.ioNumber,
nextSteps: payload.metadata?.nextSteps || 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.'
};
await emailNotificationService.sendActivityCreated(
requestData,
user.toJSON(),
activityData
);
}
break;
case 'completion_submitted':
{
// Get dealer and completion data from metadata
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const completionData = payload.metadata?.completionData || {};
// Get next approver if available
const nextApproverId = payload.metadata?.nextApproverId;
const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null;
// Check if next approver is the recipient (initiator)
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApprover && nextApprover.userId === userId);
await emailNotificationService.sendCompletionDocumentsSubmitted(
requestData,
dealerData,
user.toJSON(),
{
...completionData,
nextApproverIsInitiator: isNextApproverInitiator
},
nextApprover && !isNextApproverInitiator ? nextApprover.toJSON() : undefined
);
}
break;
case 'einvoice_generated':
{
// Get invoice data from metadata
const invoiceData = payload.metadata?.invoiceData || {
invoiceNumber: payload.metadata?.invoiceNumber || payload.metadata?.eInvoiceNumber,
invoiceDate: payload.metadata?.invoiceDate,
dmsNumber: payload.metadata?.dmsNumber,
amount: payload.metadata?.amount || payload.metadata?.invoiceAmount,
dealerName: payload.metadata?.dealerName,
dealerCode: payload.metadata?.dealerCode,
ioNumber: payload.metadata?.ioNumber,
generatedAt: payload.metadata?.generatedAt,
downloadLink: payload.metadata?.downloadLink
};
await emailNotificationService.sendEInvoiceGenerated(
requestData,
user.toJSON(),
invoiceData
);
}
break;
case 'credit_note_sent':
{
// Get credit note data from metadata
const creditNoteData = payload.metadata?.creditNoteData || {
creditNoteNumber: payload.metadata?.creditNoteNumber,
creditNoteDate: payload.metadata?.creditNoteDate,
creditNoteAmount: payload.metadata?.creditNoteAmount,
dealerName: payload.metadata?.dealerName,
dealerCode: payload.metadata?.dealerCode,
dealerEmail: payload.metadata?.dealerEmail,
reason: payload.metadata?.reason,
invoiceNumber: payload.metadata?.invoiceNumber,
sentAt: payload.metadata?.sentAt,
downloadLink: payload.metadata?.downloadLink
};
await emailNotificationService.sendCreditNoteSent(
requestData,
user.toJSON(),
creditNoteData
);
}
break;
default:
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
}

View File

@ -74,15 +74,16 @@ export class WorkflowService {
}
// Add as approver participant
// APPROVERS: Can approve, download documents, and need action
const participant = await Participant.create({
requestId,
userId,
userEmail: email.toLowerCase(),
userName,
participantType: ParticipantType.APPROVER,
participantType: ParticipantType.APPROVER, // Differentiates from SPECTATOR in database
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
canDownloadDocuments: true, // Approvers can download
notificationEnabled: true,
addedBy,
isActive: true
@ -107,13 +108,18 @@ export class WorkflowService {
details: `${userName} (${email}) has been added as an approver by ${addedByName}`
});
// Send notification to new approver
// Send notification to new approver (in-app, email, and web push)
// APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email
// This differentiates from 'spectator_added' type used for spectators
await notificationService.sendToUsers([userId], {
title: 'New Request Assignment',
body: `You have been added as an approver to request ${requestNumber}: ${title}`,
requestId,
requestNumber,
url: `/request/${requestNumber}`
url: `/request/${requestNumber}`,
type: 'assignment', // CRITICAL: Differentiates from 'spectator_added' - triggers approval request email
priority: 'HIGH',
actionRequired: true // Approvers need to take action
});
logger.info(`[Workflow] Added approver ${email} to request ${requestId}`);
@ -472,13 +478,18 @@ export class WorkflowService {
details: `${userName} (${email}) has been added as approver at Level ${targetLevel} with TAT of ${tatHours} hours by ${addedByName}`
});
// Send notification to new approver
// Send notification to new additional approver (in-app, email, and web push)
// ADDITIONAL APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email
// This works the same as regular approvers - they need to review and approve
await notificationService.sendToUsers([userId], {
title: 'New Request Assignment',
body: `You have been added as Level ${targetLevel} approver to request ${(workflow as any).requestNumber}: ${(workflow as any).title}`,
requestId,
requestNumber: (workflow as any).requestNumber,
url: `/request/${(workflow as any).requestNumber}`
url: `/request/${(workflow as any).requestNumber}`,
type: 'assignment', // CRITICAL: This triggers the approval request email notification
priority: 'HIGH',
actionRequired: true // Additional approvers need to take action
});
logger.info(`[Workflow] Added approver ${email} at level ${targetLevel} to request ${requestId}`);
@ -524,15 +535,16 @@ export class WorkflowService {
}
// Add as spectator participant
// SPECTATORS: View-only access, no approval rights, no document downloads
const participant = await Participant.create({
requestId,
userId,
userEmail: email.toLowerCase(),
userName,
participantType: ParticipantType.SPECTATOR,
participantType: ParticipantType.SPECTATOR, // Differentiates from APPROVER in database
canComment: true,
canViewDocuments: true,
canDownloadDocuments: false,
canDownloadDocuments: false, // Spectators cannot download
notificationEnabled: true,
addedBy,
isActive: true
@ -557,13 +569,20 @@ export class WorkflowService {
details: `${userName} (${email}) has been added as a spectator by ${addedByName}`
});
// Send notification to new spectator
// Send notification to new spectator (in-app, email, and web push)
// SPECTATOR NOTIFICATION: Uses 'spectator_added' type to trigger spectator added email
// This differentiates from 'assignment' type used for approvers
await notificationService.sendToUsers([userId], {
title: 'Added to Request',
body: `You have been added as a spectator to request ${requestNumber}: ${title}`,
requestId,
requestNumber,
url: `/request/${requestNumber}`
url: `/request/${requestNumber}`,
type: 'spectator_added', // CRITICAL: Differentiates from 'assignment' - triggers spectator added email
priority: 'MEDIUM', // Lower priority than approvers (no action required)
metadata: {
addedBy: addedBy // Used in email to show who added the spectator
}
});
logger.info(`[Workflow] Added spectator ${email} to request ${requestId}`);
@ -2510,40 +2529,9 @@ export class WorkflowService {
userAgent: requestMetadata?.userAgent || undefined
});
// Send notification to INITIATOR confirming submission
await notificationService.sendToUsers([initiatorId], {
title: 'Request Submitted Successfully',
body: `Your request "${workflowData.title}" has been submitted and is now with the first approver.`,
requestNumber: requestNumber,
requestId: (workflow as any).requestId,
url: `/request/${requestNumber}`,
type: 'request_submitted',
priority: 'MEDIUM'
});
// Send notification to FIRST APPROVER for assignment
const firstLevel = await ApprovalLevel.findOne({ where: { requestId: (workflow as any).requestId, levelNumber: 1 } });
if (firstLevel) {
await notificationService.sendToUsers([(firstLevel as any).approverId], {
title: 'New Request Assigned',
body: `${workflowData.title}`,
requestNumber: requestNumber,
requestId: (workflow as any).requestId,
url: `/request/${requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
activityService.log({
requestId: (workflow as any).requestId,
type: 'assignment',
user: { userId: initiatorId, name: initiatorName },
timestamp: new Date().toISOString(),
action: 'Assigned to approver',
details: `Request assigned to ${(firstLevel as any).approverName || (firstLevel as any).approverEmail || 'approver'} for review`
});
}
// NOTE: Notifications are NOT sent here because workflows are created as DRAFTS
// Notifications will be sent in submitWorkflow() when the draft is actually submitted
// This prevents approvers from being notified about draft requests
return workflow;
} catch (error) {
@ -3145,7 +3133,9 @@ export class WorkflowService {
}
// Update participants if provided (only for drafts)
if (isDraft && updateData.participants && Array.isArray(updateData.participants)) {
// IMPORTANT: Skip if participants array is empty - this means "don't update participants"
// Frontend sends empty array when it expects backend to auto-generate, but we should preserve existing participants
if (isDraft && updateData.participants && Array.isArray(updateData.participants) && updateData.participants.length > 0) {
// Get existing participants
const existingParticipants = await Participant.findAll({
where: { requestId: actualRequestId }
@ -3211,6 +3201,9 @@ export class WorkflowService {
}
logger.info(`Synced ${updateData.participants.length} participants for workflow ${actualRequestId}`);
} else if (isDraft && updateData.participants && Array.isArray(updateData.participants) && updateData.participants.length === 0) {
// Empty array means "preserve existing participants" - don't delete them
logger.info(`[Workflow] Empty participants array provided for draft ${actualRequestId} - preserving existing participants`);
}
// Delete documents if requested (only for drafts)
@ -3271,6 +3264,11 @@ export class WorkflowService {
const workflow = await this.findWorkflowByIdentifier(requestId);
if (!workflow) return null;
// Get the actual requestId (UUID) - handle both UUID and requestNumber cases
const actualRequestId = (workflow as any).getDataValue
? (workflow as any).getDataValue('requestId')
: (workflow as any).requestId;
const now = new Date();
const updated = await workflow.update({
status: WorkflowStatus.PENDING,
@ -3283,12 +3281,13 @@ export class WorkflowService {
const initiator = initiatorId ? await User.findByPk(initiatorId) : null;
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
const workflowTitle = (updated as any).title || 'Request';
const requestNumber = (updated as any).requestNumber;
// Check if this was a previously saved draft (has activity history before submission)
// or a direct submission (createWorkflow + submitWorkflow in same flow)
const { Activity } = require('@models/Activity');
const existingActivities = await Activity.count({
where: { requestId: (updated as any).requestId }
where: { requestId: actualRequestId }
});
// Only log "Request submitted" if this is a draft being submitted (has prior activities)
@ -3296,7 +3295,7 @@ export class WorkflowService {
if (existingActivities > 1) {
// This is a saved draft being submitted later
activityService.log({
requestId: (updated as any).requestId,
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
@ -3306,7 +3305,7 @@ export class WorkflowService {
} else {
// Direct submission - just update the status, createWorkflow already logged the activity
activityService.log({
requestId: (updated as any).requestId,
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
@ -3316,7 +3315,7 @@ export class WorkflowService {
}
const current = await ApprovalLevel.findOne({
where: { requestId: (updated as any).requestId, levelNumber: (updated as any).currentLevel || 1 }
where: { requestId: actualRequestId, levelNumber: (updated as any).currentLevel || 1 }
});
if (current) {
// Set the first level's start time and schedule TAT jobs
@ -3328,7 +3327,7 @@ export class WorkflowService {
// Log assignment activity for the first approver (similar to createWorkflow)
activityService.log({
requestId: (updated as any).requestId,
requestId: actualRequestId,
type: 'assignment',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
@ -3340,26 +3339,80 @@ export class WorkflowService {
try {
const workflowPriority = (updated as any).priority || 'STANDARD';
await tatSchedulerService.scheduleTatJobs(
(updated as any).requestId,
actualRequestId,
(current as any).levelId,
(current as any).approverId,
Number((current as any).tatHours),
now,
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
);
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${(updated as any).requestNumber} (Priority: ${workflowPriority})`);
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${requestNumber} (Priority: ${workflowPriority})`);
} catch (tatError) {
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
// Don't fail the submission if TAT scheduling fails
}
// NOTE: Notifications are already sent in createWorkflow() when the workflow is created
// We should NOT send "Request submitted" to the approver here - that's incorrect
// The approver should only receive "New Request Assigned" notification (sent in createWorkflow)
// The initiator receives "Request Submitted Successfully" notification (sent in createWorkflow)
//
// If this is a draft being submitted, notifications were already sent during creation,
// so we don't need to send them again here to avoid duplicates
// Send notifications when workflow is submitted (not when created as draft)
// Send notification to INITIATOR confirming submission
await notificationService.sendToUsers([initiatorId], {
title: 'Request Submitted Successfully',
body: `Your request "${workflowTitle}" has been submitted and is now with the first approver.`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'request_submitted',
priority: 'MEDIUM'
});
// Send notification to FIRST APPROVER for assignment
await notificationService.sendToUsers([(current as any).approverId], {
title: 'New Request Assigned',
body: `${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
// Send notifications to SPECTATORS (in-app, email, and web push)
// Moved outside the if(current) block to ensure spectators are always notified on submission
try {
logger.info(`[Workflow] Querying spectators for request ${requestNumber} (requestId: ${actualRequestId})`);
const spectators = await Participant.findAll({
where: {
requestId: actualRequestId, // Use the actual UUID requestId
participantType: ParticipantType.SPECTATOR,
isActive: true,
notificationEnabled: true
},
attributes: ['userId', 'userEmail', 'userName']
});
logger.info(`[Workflow] Found ${spectators.length} active spectators for request ${requestNumber}`);
if (spectators.length > 0) {
const spectatorUserIds = spectators.map((s: any) => s.userId);
logger.info(`[Workflow] Sending notifications to ${spectatorUserIds.length} spectators: ${spectatorUserIds.join(', ')}`);
await notificationService.sendToUsers(spectatorUserIds, {
title: 'Added to Request',
body: `You have been added as a spectator to request ${requestNumber}: ${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'spectator_added',
priority: 'MEDIUM'
});
logger.info(`[Workflow] Successfully sent notifications to ${spectators.length} spectators for request ${requestNumber}`);
} else {
logger.info(`[Workflow] No active spectators found for request ${requestNumber} (requestId: ${actualRequestId})`);
}
} catch (spectatorError) {
logger.error(`[Workflow] Failed to send spectator notifications for request ${requestNumber} (requestId: ${actualRequestId}):`, spectatorError);
// Don't fail the submission if spectator notifications fail
}
return updated;
} catch (error) {

View File

@ -0,0 +1,53 @@
/**
* Workflow Email Service Factory
*
* Factory pattern to get the appropriate email service based on workflow type.
* This ensures that each workflow type uses its own dedicated service,
* preventing cross-workflow breakage.
*
* To add a new workflow type:
* 1. Create a new service file (e.g., `newWorkflowEmail.service.ts`)
* 2. Implement IWorkflowEmailService interface
* 3. Register it in this factory's getService method
*/
import { IWorkflowEmailService } from './workflowEmail.interface';
import { dealerClaimEmailService } from './dealerClaimEmail.service';
import logger from '@utils/logger';
class WorkflowEmailServiceFactory {
/**
* Get the appropriate email service for a workflow type
*
* @param workflowType - The type of workflow (e.g., 'CLAIM_MANAGEMENT', 'CUSTOM', etc.)
* @returns The appropriate email service implementation
*/
getService(workflowType: string): IWorkflowEmailService {
switch (workflowType) {
case 'CLAIM_MANAGEMENT':
return dealerClaimEmailService;
// Add new workflow types here:
// case 'NEW_WORKFLOW_TYPE':
// return newWorkflowEmailService;
default:
// For custom workflows or unknown types, return null
// The caller should handle this and use default logic
logger.warn(`[WorkflowEmailFactory] Unknown workflow type: ${workflowType}, using default email logic`);
return null as any; // Return null, caller will handle default logic
}
}
/**
* Check if a workflow type has a dedicated email service
*/
hasDedicatedService(workflowType: string): boolean {
return workflowType === 'CLAIM_MANAGEMENT';
// Add new workflow types here:
// || workflowType === 'NEW_WORKFLOW_TYPE';
}
}
export const workflowEmailServiceFactory = new WorkflowEmailServiceFactory();

View File

@ -0,0 +1,25 @@
/**
* Workflow Email Service Interface
*
* Base interface for workflow-specific email services.
* Each workflow type (dealer claim, custom, etc.) should implement this interface
* to ensure consistent behavior and prevent breaking other workflows.
*/
import { User } from '@models/User';
import { ApprovalLevel } from '@models/ApprovalLevel';
export interface IWorkflowEmailService {
/**
* Send assignment email to approver
* Each workflow type can implement its own logic for template selection
*/
sendAssignmentEmail(
requestData: any,
approverUser: User,
initiatorData: any,
currentLevel: ApprovalLevel | null,
allLevels: ApprovalLevel[]
): Promise<void>;
}